Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/curly-donkeys-say.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/vercel': patch
---

Fixes server islands returning 404 responses in Vercel deployments using `output: "static"`
18 changes: 13 additions & 5 deletions packages/integrations/vercel/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ function getAdapter({
adapterFeatures: {
buildOutput,
middlewareMode,
preserveBuildServerDir: true,
staticHeaders,
},
supportedAstroFeatures: {
Expand Down Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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: [
{
Expand Down Expand Up @@ -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;
},

Expand All @@ -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);
Expand All @@ -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));
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -538,7 +546,7 @@ export default function vercelAdapter({
continue: true,
},
];
if (_buildOutput === 'server') {
if (_hasServerBuild) {
finalRoutes.push(...routeDefinitions);
}

Expand Down Expand Up @@ -638,7 +646,7 @@ export default function vercelAdapter({
});

// Remove temporary folder
if (_buildOutput === 'server') {
if (_hasServerBuild) {
await removeDir(_buildTempFolder);
}
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import vercel from '@astrojs/vercel';
import { defineConfig } from 'astro/config';

export default defineConfig({
output: 'static',
adapter: vercel(),
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "@test/vercel-server-islands-static",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/vercel": "workspace:*",
"astro": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h1>I'm an island</h1>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
import Island from '../components/Island.astro';
---
<html>
<head>
<title>One</title>
</head>
<body>
<h1>One</h1>
<Island server:defer />
</body>
</html>
63 changes: 63 additions & 0 deletions packages/integrations/vercel/test/server-islands.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<Response> } };

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');
});
});
8 changes: 8 additions & 0 deletions packages/integrations/vercel/test/static.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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,
);
});
});
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading