From ce9f1da4867f07206dec720fdab7f1d02112f73e Mon Sep 17 00:00:00 2001 From: "Houston (Bot)" <108291165+astrobot-houston@users.noreply.github.com> Date: Fri, 14 Aug 2026 06:47:32 -0700 Subject: [PATCH] Fix server islands endpoint missing in static Vercel builds (#17680) * fix(vercel): create serverless function for server islands in static builds When `output: "static"` with `server:defer` components, the Vercel adapter now detects the SSR entry built by Astro core and creates a `_render.func` serverless function with the necessary `/_server-islands/*` route in config.json. Previously, all function creation was gated behind `buildOutput === 'server'`, causing server island requests to 404. Fixes #17678 * fix(vercel): detect static server island builds --------- Co-authored-by: Matthew Phillips --- .changeset/curly-donkeys-say.md | 5 ++ packages/integrations/vercel/src/index.ts | 18 ++++-- .../server-islands-static/astro.config.mjs | 7 +++ .../server-islands-static/package.json | 9 +++ .../src/components/Island.astro | 1 + .../src/pages/index.astro | 12 ++++ .../vercel/test/server-islands.test.ts | 63 +++++++++++++++++++ .../integrations/vercel/test/static.test.ts | 8 +++ pnpm-lock.yaml | 9 +++ 9 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 .changeset/curly-donkeys-say.md create mode 100644 packages/integrations/vercel/test/fixtures/server-islands-static/astro.config.mjs create mode 100644 packages/integrations/vercel/test/fixtures/server-islands-static/package.json create mode 100644 packages/integrations/vercel/test/fixtures/server-islands-static/src/components/Island.astro create mode 100644 packages/integrations/vercel/test/fixtures/server-islands-static/src/pages/index.astro diff --git a/.changeset/curly-donkeys-say.md b/.changeset/curly-donkeys-say.md new file mode 100644 index 000000000000..016945265cba --- /dev/null +++ b/.changeset/curly-donkeys-say.md @@ -0,0 +1,5 @@ +--- +'@astrojs/vercel': patch +--- + +Fixes server islands returning 404 responses in Vercel deployments using `output: "static"` diff --git a/packages/integrations/vercel/src/index.ts b/packages/integrations/vercel/src/index.ts index 569a0a77dcf4..93705420d940 100644 --- a/packages/integrations/vercel/src/index.ts +++ b/packages/integrations/vercel/src/index.ts @@ -127,6 +127,7 @@ function getAdapter({ adapterFeatures: { buildOutput, middlewareMode, + preserveBuildServerDir: true, staticHeaders, }, supportedAstroFeatures: { @@ -266,6 +267,7 @@ export default function vercelAdapter({ let _buildTempFolder: URL; let _serverEntry: string; let _middlewareEntryPoint: URL | undefined; + let _hasServerBuild = false; let _routeToHeaders: RouteToHeaders | undefined = undefined; // Extra files to be merged with `includeFiles` during build const extraFilesToInclude: URL[] = []; @@ -295,6 +297,9 @@ export default function vercelAdapter({ build: { format: 'directory', redirects: false, + ...(config.output === 'static' + ? { server: new URL('./.vercel/output/server/', config.root) } + : {}), }, integrations: [ { @@ -393,10 +398,12 @@ export default function vercelAdapter({ _serverEntry = config.build.serverEntry; }, 'astro:build:start': async () => { + _hasServerBuild = false; // Ensure to have `.vercel/output` empty. await emptyDir(new URL('./.vercel/output/', _config.root)); }, 'astro:build:ssr': async ({ middlewareEntryPoint }) => { + _hasServerBuild = true; _middlewareEntryPoint = middlewareEntryPoint; }, @@ -405,6 +412,7 @@ export default function vercelAdapter({ }, 'astro:build:done': async ({ logger }: HookParameters<'astro:build:done'>) => { const outDir = new URL('./.vercel/output/', _config.root); + if (staticDir) { if (existsSync(staticDir)) { await emptyDir(staticDir); @@ -428,9 +436,9 @@ export default function vercelAdapter({ middlewarePath?: string; }> = []; - if (_buildOutput === 'server') { + if (_hasServerBuild) { // Merge any includes from `vite.assetsInclude - if (_config.vite.assetsInclude) { + if (_buildOutput === 'server' && _config.vite.assetsInclude) { const mergeGlobbedIncludes = (globPattern: unknown) => { if (typeof globPattern === 'string') { const entries = globSync(globPattern).map((p) => pathToFileURL(p)); @@ -460,7 +468,7 @@ export default function vercelAdapter({ ); const entryFile = new URL(_serverEntry, _buildTempFolder); - if (isr) { + if (_buildOutput === 'server' && isr) { const isrConfig = typeof isr === 'object' ? isr : {}; await builder.buildServerlessFolder(entryFile, NODE_PATH, _config.root); if (isrConfig.exclude?.length) { @@ -538,7 +546,7 @@ export default function vercelAdapter({ continue: true, }, ]; - if (_buildOutput === 'server') { + if (_hasServerBuild) { finalRoutes.push(...routeDefinitions); } @@ -638,7 +646,7 @@ export default function vercelAdapter({ }); // Remove temporary folder - if (_buildOutput === 'server') { + if (_hasServerBuild) { await removeDir(_buildTempFolder); } }, diff --git a/packages/integrations/vercel/test/fixtures/server-islands-static/astro.config.mjs b/packages/integrations/vercel/test/fixtures/server-islands-static/astro.config.mjs new file mode 100644 index 000000000000..b769a9820d32 --- /dev/null +++ b/packages/integrations/vercel/test/fixtures/server-islands-static/astro.config.mjs @@ -0,0 +1,7 @@ +import vercel from '@astrojs/vercel'; +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + output: 'static', + adapter: vercel(), +}); diff --git a/packages/integrations/vercel/test/fixtures/server-islands-static/package.json b/packages/integrations/vercel/test/fixtures/server-islands-static/package.json new file mode 100644 index 000000000000..1d323e8bb5ef --- /dev/null +++ b/packages/integrations/vercel/test/fixtures/server-islands-static/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/vercel-server-islands-static", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/vercel": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/vercel/test/fixtures/server-islands-static/src/components/Island.astro b/packages/integrations/vercel/test/fixtures/server-islands-static/src/components/Island.astro new file mode 100644 index 000000000000..9d2832bc1878 --- /dev/null +++ b/packages/integrations/vercel/test/fixtures/server-islands-static/src/components/Island.astro @@ -0,0 +1 @@ +

I'm an island

diff --git a/packages/integrations/vercel/test/fixtures/server-islands-static/src/pages/index.astro b/packages/integrations/vercel/test/fixtures/server-islands-static/src/pages/index.astro new file mode 100644 index 000000000000..835126c2bc10 --- /dev/null +++ b/packages/integrations/vercel/test/fixtures/server-islands-static/src/pages/index.astro @@ -0,0 +1,12 @@ +--- +import Island from '../components/Island.astro'; +--- + + + One + + +

One

+ + + diff --git a/packages/integrations/vercel/test/server-islands.test.ts b/packages/integrations/vercel/test/server-islands.test.ts index 0667b051b790..6fd91d254e9d 100644 --- a/packages/integrations/vercel/test/server-islands.test.ts +++ b/packages/integrations/vercel/test/server-islands.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; import { before, describe, it } from 'node:test'; import { type Fixture, loadFixture, getVercelConfig } from './test-utils.ts'; @@ -24,3 +25,65 @@ describe('Server Islands', () => { assert.notEqual(found, null, 'Default server islands route included'); }); }); + +describe('Server Islands (static output)', () => { + let fixture: Fixture; + let renderFunction: { default: { fetch(request: Request): Promise } }; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/server-islands-static/', + }); + await fixture.build({}); + + const functionConfig = JSON.parse( + await fixture.readFile('../.vercel/output/functions/_render.func/.vc-config.json'), + ); + const functionEntry = new URL( + `../.vercel/output/functions/_render.func/${functionConfig.handler}`, + fixture.config.outDir, + ); + renderFunction = await import(functionEntry.href); + }); + + it('creates _render.func for server islands in static builds', { timeout: 30000 }, async () => { + const renderFuncDir = new URL('.vercel/output/functions/_render.func/', fixture.config.root); + assert.ok(existsSync(renderFuncDir), '_render.func directory should exist'); + assert.ok( + existsSync(new URL('.vc-config.json', renderFuncDir)), + '.vc-config.json should exist in _render.func', + ); + }); + + it('renders the server island', { timeout: 30000 }, async () => { + const html = await fixture.readFile('../.vercel/output/static/index.html'); + const islandUrl = /fetch\((["'])(\/_server-islands\/[^"']+)\1/.exec(html)?.[2]; + assert.ok(islandUrl, 'prerendered HTML should include the server island URL'); + + const response = await renderFunction.default.fetch( + new Request(new URL(islandUrl, 'https://example.com')), + ); + assert.equal(response.status, 200); + assert.match(await response.text(), /I'm an island/); + }); + + it('does not publish the server entry as a static file', () => { + assert.equal( + existsSync(new URL('.vercel/output/static/entry.mjs', fixture.config.root)), + false, + ); + }); + + it('includes server islands route in config', { timeout: 30000 }, async () => { + const config = await getVercelConfig(fixture); + let found = null; + for (const route of config.routes) { + if (route.src?.includes('_server-islands')) { + found = route; + break; + } + } + assert.ok(found, 'Server islands route should be in config'); + assert.equal(found.dest, '_render', 'Server islands route should point to _render'); + }); +}); diff --git a/packages/integrations/vercel/test/static.test.ts b/packages/integrations/vercel/test/static.test.ts index c8d8ffef8495..4b118bfd197f 100644 --- a/packages/integrations/vercel/test/static.test.ts +++ b/packages/integrations/vercel/test/static.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; import { before, describe, it } from 'node:test'; import { type Fixture, loadFixture } from './test-utils.ts'; @@ -21,4 +22,11 @@ describe('static routing', () => { status: 404, }); }); + + it('does not create a serverless function', () => { + assert.equal( + existsSync(new URL('.vercel/output/functions/_render.func/', fixture.config.root)), + false, + ); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ce0119e5c7b..ff19820a3c84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6453,6 +6453,15 @@ importers: specifier: workspace:* version: link:../../../../../astro + packages/integrations/vercel/test/fixtures/server-islands-static: + dependencies: + '@astrojs/vercel': + specifier: workspace:* + version: link:../../.. + astro: + specifier: workspace:* + version: link:../../../../../astro + packages/integrations/vercel/test/fixtures/serverless-prerender: dependencies: '@astrojs/vercel':