diff --git a/.changeset/tidy-shrimps-grab.md b/.changeset/tidy-shrimps-grab.md new file mode 100644 index 0000000000..55e52375e5 --- /dev/null +++ b/.changeset/tidy-shrimps-grab.md @@ -0,0 +1,7 @@ +--- +'astro': patch +--- + +Fix for Server Islands in Vercel adapter + +Vercel, and probably other adapters only allow pre-defined routes. This makes it so that the `astro:build:done` hook includes the `_server-islands/` route as part of the route data, which is used to configure available routes. diff --git a/packages/astro/src/container/index.ts b/packages/astro/src/container/index.ts index 758d65505e..a8641b5eab 100644 --- a/packages/astro/src/container/index.ts +++ b/packages/astro/src/container/index.ts @@ -20,7 +20,8 @@ import { Logger } from '../core/logger/core.js'; import { nodeLogDestination } from '../core/logger/node.js'; import { removeLeadingForwardSlash } from '../core/path.js'; import { RenderContext } from '../core/render-context.js'; -import { getParts, getPattern, validateSegment } from '../core/routing/manifest/create.js'; +import { getParts, validateSegment } from '../core/routing/manifest/create.js'; +import { getPattern } from '../core/routing/manifest/pattern.js'; import type { AstroComponentFactory } from '../runtime/server/index.js'; import { ContainerPipeline } from './pipeline.js'; diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index 04a7d90a85..a2b1d4f2e5 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -88,7 +88,7 @@ export class App { constructor(manifest: SSRManifest, streaming = true) { this.#manifest = manifest; - this.#manifestData = injectDefaultRoutes({ + this.#manifestData = injectDefaultRoutes(manifest, { routes: manifest.routes.map((route) => route.routeData), }); this.#baseWithoutTrailingSlash = removeTrailingForwardSlash(this.#manifest.base); diff --git a/packages/astro/src/core/build/index.ts b/packages/astro/src/core/build/index.ts index 7933b77f97..23e7b78354 100644 --- a/packages/astro/src/core/build/index.ts +++ b/packages/astro/src/core/build/index.ts @@ -32,6 +32,7 @@ import { collectPagesData } from './page-data.js'; import { staticBuild, viteBuild } from './static-build.js'; import type { StaticBuildOptions } from './types.js'; import { getTimeStat } from './util.js'; +import { getServerIslandRouteData } from '../server-islands/endpoint.js'; export interface BuildOptions { /** @@ -216,7 +217,10 @@ class AstroBuilder { pages: pageNames, routes: Object.values(allPages) .flat() - .map((pageData) => pageData.route), + .map((pageData) => pageData.route).concat( + this.settings.config.experimental.serverIslands ? + [ getServerIslandRouteData(this.settings.config) ] : [] + ), logging: this.logger, cacheManifest: internals.cacheManifestUsed, }); diff --git a/packages/astro/src/core/routing/default.ts b/packages/astro/src/core/routing/default.ts index f617bd0dc1..dd3c8cc538 100644 --- a/packages/astro/src/core/routing/default.ts +++ b/packages/astro/src/core/routing/default.ts @@ -12,10 +12,10 @@ import { ensure404Route, } from './astro-designed-error-pages.js'; -export function injectDefaultRoutes(manifest: ManifestData) { - ensure404Route(manifest); - ensureServerIslandRoute(manifest); - return manifest; +export function injectDefaultRoutes(ssrManifest: SSRManifest, routeManifest: ManifestData) { + ensure404Route(routeManifest); + ensureServerIslandRoute(ssrManifest, routeManifest); + return routeManifest; } type DefaultRouteParams = { diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/manifest/create.ts index 4a36c8536d..b022c383b9 100644 --- a/packages/astro/src/core/routing/manifest/create.ts +++ b/packages/astro/src/core/routing/manifest/create.ts @@ -22,6 +22,7 @@ import { removeLeadingForwardSlash, slash } from '../../path.js'; import { resolvePages } from '../../util.js'; import { routeComparator } from '../priority.js'; import { getRouteGenerator } from './generator.js'; +import { getPattern } from './pattern.js'; const require = createRequire(import.meta.url); interface Item { @@ -70,59 +71,6 @@ export function getParts(part: string, file: string) { return result; } -export function getPattern( - segments: RoutePart[][], - base: AstroConfig['base'], - addTrailingSlash: AstroConfig['trailingSlash'] -) { - const pathname = segments - .map((segment) => { - if (segment.length === 1 && segment[0].spread) { - return '(?:\\/(.*?))?'; - } else { - return ( - '\\/' + - segment - .map((part) => { - if (part.spread) { - return '(.*?)'; - } else if (part.dynamic) { - return '([^/]+?)'; - } else { - return part.content - .normalize() - .replace(/\?/g, '%3F') - .replace(/#/g, '%23') - .replace(/%5B/g, '[') - .replace(/%5D/g, ']') - .replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - } - }) - .join('') - ); - } - }) - .join(''); - - const trailing = - addTrailingSlash && segments.length ? getTrailingSlashPattern(addTrailingSlash) : '$'; - let initial = '\\/'; - if (addTrailingSlash === 'never' && base !== '/') { - initial = ''; - } - return new RegExp(`^${pathname || initial}${trailing}`); -} - -function getTrailingSlashPattern(addTrailingSlash: AstroConfig['trailingSlash']): string { - if (addTrailingSlash === 'always') { - return '\\/$'; - } - if (addTrailingSlash === 'never') { - return '$'; - } - return '\\/?$'; -} - export function validateSegment(segment: string, file = '') { if (!file) file = segment; @@ -486,7 +434,7 @@ function isStaticSegment(segment: RoutePart[]) { * For example, `/foo/[bar]` and `/foo/[baz]` or `/foo/[...bar]` and `/foo/[...baz]` * but not `/foo/[bar]` and `/foo/[...baz]`. */ -function detectRouteCollision(a: RouteData, b: RouteData, config: AstroConfig, logger: Logger) { +function detectRouteCollision(a: RouteData, b: RouteData, _config: AstroConfig, logger: Logger) { if (a.type === 'fallback' || b.type === 'fallback') { // If either route is a fallback route, they don't collide. // Fallbacks are always added below other routes exactly to avoid collisions. diff --git a/packages/astro/src/core/routing/manifest/pattern.ts b/packages/astro/src/core/routing/manifest/pattern.ts new file mode 100644 index 0000000000..320d02e204 --- /dev/null +++ b/packages/astro/src/core/routing/manifest/pattern.ts @@ -0,0 +1,57 @@ +import type { + AstroConfig, + RoutePart, +} from '../../../@types/astro.js'; + +export function getPattern( + segments: RoutePart[][], + base: AstroConfig['base'], + addTrailingSlash: AstroConfig['trailingSlash'] +) { + const pathname = segments + .map((segment) => { + if (segment.length === 1 && segment[0].spread) { + return '(?:\\/(.*?))?'; + } else { + return ( + '\\/' + + segment + .map((part) => { + if (part.spread) { + return '(.*?)'; + } else if (part.dynamic) { + return '([^/]+?)'; + } else { + return part.content + .normalize() + .replace(/\?/g, '%3F') + .replace(/#/g, '%23') + .replace(/%5B/g, '[') + .replace(/%5D/g, ']') + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + }) + .join('') + ); + } + }) + .join(''); + + const trailing = + addTrailingSlash && segments.length ? getTrailingSlashPattern(addTrailingSlash) : '$'; + let initial = '\\/'; + if (addTrailingSlash === 'never' && base !== '/') { + initial = ''; + } + return new RegExp(`^${pathname || initial}${trailing}`); +} + +function getTrailingSlashPattern(addTrailingSlash: AstroConfig['trailingSlash']): string { + if (addTrailingSlash === 'always') { + return '\\/$'; + } + if (addTrailingSlash === 'never') { + return '$'; + } + return '\\/?$'; +} diff --git a/packages/astro/src/core/server-islands/endpoint.ts b/packages/astro/src/core/server-islands/endpoint.ts index 7b6857e1ac..1e01d08284 100644 --- a/packages/astro/src/core/server-islands/endpoint.ts +++ b/packages/astro/src/core/server-islands/endpoint.ts @@ -11,33 +11,41 @@ import { renderTemplate, } from '../../runtime/server/index.js'; import { createSlotValueFromString } from '../../runtime/server/render/slot.js'; +import { getPattern } from '../routing/manifest/pattern.js'; export const SERVER_ISLAND_ROUTE = '/_server-islands/[name]'; export const SERVER_ISLAND_COMPONENT = '_server-islands.astro'; -export function ensureServerIslandRoute(manifest: ManifestData) { - if (manifest.routes.some((route) => route.route === '/_server-islands/[name]')) { - return; - } +type ConfigFields = Pick; +export function getServerIslandRouteData(config: ConfigFields) { + const segments = [ + [{ content: '_server-islands', dynamic: false, spread: false }], + [{ content: 'name', dynamic: true, spread: false }], + ]; const route: RouteData = { type: 'page', component: SERVER_ISLAND_COMPONENT, generate: () => '', params: ['name'], - segments: [ - [{ content: '_server-islands', dynamic: false, spread: false }], - [{ content: 'name', dynamic: true, spread: false }], - ], - // eslint-disable-next-line - pattern: /^\/_server-islands\/([^/]+?)$/, + segments, + pattern: getPattern(segments, config.base, config.trailingSlash), prerender: false, isIndex: false, fallbackRoutes: [], route: SERVER_ISLAND_ROUTE, }; + return route; +} - manifest.routes.push(route); + + +export function ensureServerIslandRoute(config: ConfigFields, routeManifest: ManifestData) { + if (routeManifest.routes.some((route) => route.route === '/_server-islands/[name]')) { + return; + } + + routeManifest.routes.push(getServerIslandRouteData(config)); } type RenderOptions = { diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts index f9d0073bb0..56cba80ecd 100644 --- a/packages/astro/src/vite-plugin-astro-server/plugin.ts +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -35,7 +35,7 @@ export default function createVitePluginAstroServer({ configureServer(viteServer) { const loader = createViteLoader(viteServer); const manifest = createDevelopmentManifest(settings); - let manifestData: ManifestData = injectDefaultRoutes( + let manifestData: ManifestData = injectDefaultRoutes(manifest, createRouteManifest({ settings, fsMod }, logger) ); const pipeline = DevPipeline.create(manifestData, { loader, logger, manifest, settings }); @@ -46,7 +46,7 @@ export default function createVitePluginAstroServer({ function rebuildManifest(needsManifestRebuild: boolean) { pipeline.clearRouteCache(); if (needsManifestRebuild) { - manifestData = injectDefaultRoutes(createRouteManifest({ settings }, logger)); + manifestData = injectDefaultRoutes(manifest, createRouteManifest({ settings }, logger)); pipeline.setManifestData(manifestData); } } diff --git a/packages/integrations/vercel/test/fixtures/server-islands/astro.config.mjs b/packages/integrations/vercel/test/fixtures/server-islands/astro.config.mjs new file mode 100644 index 0000000000..534197429c --- /dev/null +++ b/packages/integrations/vercel/test/fixtures/server-islands/astro.config.mjs @@ -0,0 +1,10 @@ +import vercel from '@astrojs/vercel/serverless'; +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + output: "server", + adapter: vercel(), + experimental: { + serverIslands: true, + } +}); diff --git a/packages/integrations/vercel/test/fixtures/server-islands/package.json b/packages/integrations/vercel/test/fixtures/server-islands/package.json new file mode 100644 index 0000000000..a21ff176a8 --- /dev/null +++ b/packages/integrations/vercel/test/fixtures/server-islands/package.json @@ -0,0 +1,10 @@ +{ + "name": "@test/vercel-server-islands", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/vercel": "workspace:*", + "astro": "workspace:*" + } +} + diff --git a/packages/integrations/vercel/test/fixtures/server-islands/src/components/Island.astro b/packages/integrations/vercel/test/fixtures/server-islands/src/components/Island.astro new file mode 100644 index 0000000000..9d2832bc18 --- /dev/null +++ b/packages/integrations/vercel/test/fixtures/server-islands/src/components/Island.astro @@ -0,0 +1 @@ +

I'm an island

diff --git a/packages/integrations/vercel/test/fixtures/server-islands/src/pages/index.astro b/packages/integrations/vercel/test/fixtures/server-islands/src/pages/index.astro new file mode 100644 index 0000000000..835126c2bc --- /dev/null +++ b/packages/integrations/vercel/test/fixtures/server-islands/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.js b/packages/integrations/vercel/test/server-islands.test.js new file mode 100644 index 0000000000..0604925848 --- /dev/null +++ b/packages/integrations/vercel/test/server-islands.test.js @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { loadFixture } from './test-utils.js'; + +describe('Server Islands', () => { + /** @type {import('./test-utils.js').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/server-islands/', + }); + await fixture.build(); + }); + + it('server islands route is in the config', async () => { + const config = JSON.parse( + await fixture.readFile('../.vercel/output/config.json') + ); + let found = null; + for(let route of config.routes) { + if(route.src?.includes('_server-islands')) { + found = route; + break; + } + } + assert.notEqual(found, null, 'Default server islands route included'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b73390f553..33056a56eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5542,6 +5542,15 @@ importers: specifier: workspace:* version: link:../../../../../astro + packages/integrations/vercel/test/fixtures/server-islands: + dependencies: + '@astrojs/vercel': + specifier: workspace:* + version: link:../../.. + astro: + specifier: workspace:* + version: link:../../../../../astro + packages/integrations/vercel/test/fixtures/serverless-prerender: dependencies: '@astrojs/vercel':