diff --git a/.changeset/sour-taxis-warn.md b/.changeset/sour-taxis-warn.md new file mode 100644 index 000000000000..b3cc9ee42507 --- /dev/null +++ b/.changeset/sour-taxis-warn.md @@ -0,0 +1,5 @@ +--- +'astro': minor +--- + +Added config option for setting a hostname to be prepended to Server Island URLs. This will allow users to deploy Server Island server-side code seperately from generated static output. diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index cf1176df2cb1..830b18131eb4 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -106,7 +106,8 @@ export type SSRManifest = { pageModule?: SinglePageBuiltModule; pageMap?: Map; serverIslandMappings?: () => Promise | ServerIslandMappings; - key: Promise; + apiPrefix?: AstroConfig['build']['apiPrefix']; + key: Promise; i18n: SSRManifestI18n | undefined; middleware?: () => Promise | AstroMiddlewareInstance; logger?: () => diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index 1e8b1da01b66..db8e47be9415 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -365,6 +365,7 @@ async function buildManifest( middlewareMode, site: settings.config.site, base: settings.config.base, + apiPrefix: settings.config.build.apiPrefix, userAssetsBase: settings.config?.vite?.base, trailingSlash: settings.config.trailingSlash, compressHTML: settings.config.compressHTML, diff --git a/packages/astro/src/core/config/schemas/base.ts b/packages/astro/src/core/config/schemas/base.ts index 54868c40431f..a81a44c90069 100644 --- a/packages/astro/src/core/config/schemas/base.ts +++ b/packages/astro/src/core/config/schemas/base.ts @@ -75,6 +75,7 @@ export const ASTRO_CONFIG_DEFAULTS = { redirects: true, inlineStylesheets: 'auto', concurrency: 1, + apiPrefix: '', }, image: { endpoint: { entrypoint: undefined, route: '/_image' }, @@ -225,6 +226,7 @@ export const AstroConfigSchema = z.object({ .optional() .or(z.object({ fallback: z.string() }).and(z.record(z.string(), z.string()))) .optional(), + apiPrefix: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.apiPrefix), serverEntry: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.serverEntry), redirects: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.build.redirects), inlineStylesheets: z diff --git a/packages/astro/src/core/config/schemas/relative.ts b/packages/astro/src/core/config/schemas/relative.ts index c108b7d3793a..9a33b8f8312f 100644 --- a/packages/astro/src/core/config/schemas/relative.ts +++ b/packages/astro/src/core/config/schemas/relative.ts @@ -79,6 +79,7 @@ export function createRelativeSchema(cmd: string, fileProtocolRoot: string) { .string() .optional() .or(z.object({ fallback: z.string() }).and(z.record(z.string(), z.string())).optional()), + apiPrefix: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.apiPrefix), serverEntry: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.serverEntry), redirects: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.build.redirects), inlineStylesheets: z diff --git a/packages/astro/src/core/fetch/fetch-state.ts b/packages/astro/src/core/fetch/fetch-state.ts index b0dc5b79163d..b9e447fbbcb9 100644 --- a/packages/astro/src/core/fetch/fetch-state.ts +++ b/packages/astro/src/core/fetch/fetch-state.ts @@ -407,6 +407,7 @@ export class FetchState implements AstroFetchState { }, key: manifest.key, trailingSlash: manifest.trailingSlash, + apiPrefix: manifest.apiPrefix, _metadata: { hasHydrationScript: false, rendererSpecificHydrationScripts: new Set(), diff --git a/packages/astro/src/core/routing/router.ts b/packages/astro/src/core/routing/router.ts index e24a12c78413..1d3cc031a4f7 100644 --- a/packages/astro/src/core/routing/router.ts +++ b/packages/astro/src/core/routing/router.ts @@ -14,6 +14,7 @@ export interface RouterOptions { base: AstroConfig['base']; trailingSlash: AstroConfig['trailingSlash']; buildFormat: NonNullable['format']; + apiPrefix?: AstroConfig['build']['apiPrefix']; } interface RouterMatchRoute { diff --git a/packages/astro/src/manifest/serialized.ts b/packages/astro/src/manifest/serialized.ts index a76740a4f5d0..be7585fba0b3 100644 --- a/packages/astro/src/manifest/serialized.ts +++ b/packages/astro/src/manifest/serialized.ts @@ -198,6 +198,7 @@ async function createSerializedManifest( clientDirectives: Array.from(settings.clientDirectives.entries()), renderers: [], base: settings.config.base, + apiPrefix: settings.config.build.apiPrefix, userAssetsBase: settings.config?.vite?.base, assetsPrefix: settings.config.build.assetsPrefix, site: settings.config.site, diff --git a/packages/astro/src/runtime/server/render/server-islands.ts b/packages/astro/src/runtime/server/render/server-islands.ts index 62b211866272..51700d9ba2d9 100644 --- a/packages/astro/src/runtime/server/render/server-islands.ts +++ b/packages/astro/src/runtime/server/render/server-islands.ts @@ -182,8 +182,9 @@ export class ServerIslandComponent { : await encryptString(key, JSON.stringify(renderedSlots), `slots:${componentId}`); const hostId = await this.getHostId(); - const slash = this.result.base.endsWith('/') ? '' : '/'; - let serverIslandUrl = `${this.result.base}${slash}_server-islands/${componentId}${this.result.trailingSlash === 'always' ? '/' : ''}`; + const apiPrefix = this.result.apiPrefix?.replace(/\/$/, '') || ''; + const base = this.result.base === '/' ? '' : this.result.base.replace(/\/$/, ''); + let serverIslandUrl = `${apiPrefix}${base}/_server-islands/${componentId}${this.result.trailingSlash === 'always' ? '/' : ''}`; // Determine if its safe to use a GET request const potentialSearchParams = createSearchParams( diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index 70353a45a35d..129adf1d165e 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -1200,6 +1200,31 @@ export interface AstroUserConfig< * */ assetsPrefix?: AssetsPrefix; + /** + * @docs + * @name apiPrefix + * @type {string} + * @description + * Sets a hostname or origin prefix to use when Astro fetches server island content. + * This value is prepended to the server island request URL, which is useful when + * you want those requests to go to a different origin (for example, a CDN or + * dedicated API domain) than the page itself. + * + * The value should include the protocol and, if needed, a port. Astro will + * normalize the URL so the server island route is appended correctly. + * + * ```js + * { + * build: { + * apiPrefix: 'https://cdn.example.com' + * } + * } + * ``` + * + * With a base path of `/docs`, a server island request will be fetched from: + * `https://cdn.example.com/docs/_server-islands/...` + */ + apiPrefix?: string; /** * @docs * @name build.serverEntry diff --git a/packages/astro/src/types/public/internal.ts b/packages/astro/src/types/public/internal.ts index c9a0c500f471..987c72b6dbb7 100644 --- a/packages/astro/src/types/public/internal.ts +++ b/packages/astro/src/types/public/internal.ts @@ -226,6 +226,7 @@ export interface SSRResult { cookies: AstroCookies | undefined; getServerIslandNameMap: () => Promise>; trailingSlash: AstroConfig['trailingSlash']; + apiPrefix?: AstroConfig['build']['apiPrefix']; key: Promise; _metadata: SSRMetadata; /** diff --git a/packages/astro/test/units/manifest/serialized.test.ts b/packages/astro/test/units/manifest/serialized.test.ts index f61fcc70da5a..ee782bb8f899 100644 --- a/packages/astro/test/units/manifest/serialized.test.ts +++ b/packages/astro/test/units/manifest/serialized.test.ts @@ -117,6 +117,18 @@ describe('serializedManifestPlugin - dev mode', () => { }); }); + describe('apiPrefix', () => { + it('preserves the configured hostname', async () => { + const settings = await createBasicSettings({ + build: { + apiPrefix: 'https://example.com' + }, + }); + const manifest = await getManifest(settings); + assert.equal(manifest.apiPrefix, 'https://example.com'); + }); + }); + describe('serverLike', () => { it('is true when buildOutput is server', async () => { const settings = await createBasicSettings({}); diff --git a/packages/astro/test/units/server-islands/server-islands-render.test.ts b/packages/astro/test/units/server-islands/server-islands-render.test.ts index f0e0d8093017..95f2e3dbf689 100644 --- a/packages/astro/test/units/server-islands/server-islands-render.test.ts +++ b/packages/astro/test/units/server-islands/server-islands-render.test.ts @@ -224,6 +224,50 @@ describe('ServerIslandComponent', () => { ); }); + it('uses the configured apiPrefix as a URL prefix', async () => { + const result = await createStubResult({ + base: '/docs', + apiPrefix: 'https://cdn.example.com', + }); + const component = new ServerIslandComponent(result, islandProps(), {}, 'Island'); + const content = await component.getIslandContent(); + assert.ok( + content.includes('https://cdn.example.com/docs/_server-islands/Island'), + `server island URL should be prefixed by apiPrefix, got: ${content}`, + ); + }); + + it('normalizes a trailing slash in apiPrefix', async () => { + const result = await createStubResult({ + base: '/docs', + apiPrefix: 'https://cdn.example.com/', + }); + const component = new ServerIslandComponent(result, islandProps(), {}, 'Island'); + const content = await component.getIslandContent(); + assert.ok( + content.includes('https://cdn.example.com/docs/_server-islands/Island'), + `server island URL should not contain a double slash, got: ${content}`, + ); + assert.ok( + !content.includes('https://cdn.example.com//docs/_server-islands/Island'), + 'server island URL should not double-escape the base path', + ); + }); + + it('keeps the relative URL when apiPrefix is unset', async () => { + const result = await createStubResult({ apiPrefix: undefined }); + const component = new ServerIslandComponent(result, islandProps(), {}, 'Island'); + const content = await component.getIslandContent(); + assert.ok( + content.includes('/_server-islands/Island'), + `server island URL should remain relative when unset, got: ${content}`, + ); + assert.ok( + !content.includes('https://') && !content.includes('http://'), + 'server island URL should not add an absolute origin when unset', + ); + }); + it('appends a trailing slash when trailingSlash is "always"', async () => { const result = await createStubResult({ trailingSlash: 'always' }); const component = new ServerIslandComponent(result, islandProps(), {}, 'Island');