diff --git a/.changeset/brown-parents-sniff.md b/.changeset/brown-parents-sniff.md new file mode 100644 index 000000000000..8063156aada8 --- /dev/null +++ b/.changeset/brown-parents-sniff.md @@ -0,0 +1,5 @@ +--- +"@astrojs/vercel": patch +--- + +Fixes an issue where edge middleware did not work. diff --git a/packages/integrations/vercel/src/lib/fs.ts b/packages/integrations/vercel/src/lib/fs.ts index b3dadcfc2ed5..152ee7d7c0d3 100644 --- a/packages/integrations/vercel/src/lib/fs.ts +++ b/packages/integrations/vercel/src/lib/fs.ts @@ -31,8 +31,6 @@ export async function getFilesFromFolder(dir: URL) { return files; } -export const getVercelOutput = (root: URL) => new URL('./.vercel/output/', root); - /** * Copies files into a folder keeping the folder structure intact. * The resulting file tree will start at the common ancestor. diff --git a/packages/integrations/vercel/src/serverless/adapter.ts b/packages/integrations/vercel/src/serverless/adapter.ts index 5a3c92b02abb..4f2a5092ec54 100644 --- a/packages/integrations/vercel/src/serverless/adapter.ts +++ b/packages/integrations/vercel/src/serverless/adapter.ts @@ -8,14 +8,14 @@ import type { import { AstroError } from 'astro/errors'; import glob from 'fast-glob'; import { basename } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { pathToFileURL } from 'node:url'; import { getAstroImageConfig, getDefaultImageConfig, type DevImageService, type VercelImageConfig, } from '../image/shared.js'; -import { getVercelOutput, removeDir, writeJson } from '../lib/fs.js'; +import { removeDir, writeJson } from '../lib/fs.js'; import { copyDependenciesToFunction } from '../lib/nft.js'; import { getRedirects } from '../lib/redirects.js'; import { @@ -29,9 +29,25 @@ import { import { generateEdgeMiddleware } from './middleware.js'; const PACKAGE_NAME = '@astrojs/vercel/serverless'; + +/** + * The edge function calls the node server at /_render, + * with the original path as the value of this header. + */ +export const ASTRO_PATH_HEADER = 'x-astro-path'; + +/** + * The edge function calls the node server at /_render, + * with the locals serialized into this header. + */ export const ASTRO_LOCALS_HEADER = 'x-astro-locals'; export const VERCEL_EDGE_MIDDLEWARE_FILE = 'vercel-edge-middleware'; +// Vercel routes the folder names to a path on the deployed website. +// We attempt to avoid interfering by prefixing with an underscore. +export const NODE_PATH = '_render'; +const MIDDLEWARE_PATH = '_middleware'; + // https://vercel.com/docs/concepts/functions/serverless-functions/runtimes/node-js#node.js-version const SUPPORTED_NODE_VERSIONS: Record< string, @@ -111,8 +127,8 @@ export interface VercelServerlessConfig { export default function vercelServerless({ webAnalytics, speedInsights, - includeFiles, - excludeFiles = [], + includeFiles: _includeFiles = [], + excludeFiles: _excludeFiles = [], imageService, imagesConfig, devImageService = 'sharp', @@ -130,9 +146,10 @@ export default function vercelServerless({ } let _config: AstroConfig; - let buildTempFolder: URL; - let serverEntry: string; + let _buildTempFolder: URL; + let _serverEntry: string; let _entryPoints: Map; + let _middlewareEntryPoint: URL | undefined; // Extra files to be merged with `includeFiles` during build const extraFilesToInclude: URL[] = []; @@ -162,13 +179,12 @@ export default function vercelServerless({ if (command === 'build' && speedInsights?.enabled) { injectScript('page', 'import "@astrojs/vercel/speed-insights"'); } - const outDir = getVercelOutput(config.root); + updateConfig({ - outDir, + outDir: new URL('./.vercel/output/', config.root), build: { - serverEntry: 'entry.mjs', - client: new URL('./static/', outDir), - server: new URL('./dist/', config.root), + client: new URL('./.vercel/output/static/', config.root), + server: new URL('./.vercel/output/_functions/', config.root), redirects: false, }, vite: { @@ -195,10 +211,12 @@ export default function vercelServerless({ `\tYou can set functionPerRoute: false to prevent surpassing the limit.\n` ); } + setAdapter(getAdapter({ functionPerRoute, edgeMiddleware })); + _config = config; - buildTempFolder = config.build.server; - serverEntry = config.build.serverEntry; + _buildTempFolder = config.build.server; + _serverEntry = config.build.serverEntry; if (config.output === 'static') { throw new AstroError( @@ -208,20 +226,7 @@ export default function vercelServerless({ }, 'astro:build:ssr': async ({ entryPoints, middlewareEntryPoint }) => { _entryPoints = entryPoints; - if (middlewareEntryPoint) { - const outPath = fileURLToPath(buildTempFolder); - const vercelEdgeMiddlewareHandlerPath = new URL( - VERCEL_EDGE_MIDDLEWARE_FILE, - _config.srcDir - ); - const bundledMiddlewarePath = await generateEdgeMiddleware( - middlewareEntryPoint, - outPath, - vercelEdgeMiddlewareHandlerPath - ); - // let's tell the adapter that we need to save this file - extraFilesToInclude.push(bundledMiddlewarePath); - } + _middlewareEntryPoint = middlewareEntryPoint; }, 'astro:build:done': async ({ routes, logger }) => { // Merge any includes from `vite.assetsInclude @@ -240,9 +245,14 @@ export default function vercelServerless({ mergeGlobbedIncludes(_config.vite.assetsInclude); } - const routeDefinitions: { src: string; dest: string }[] = []; - const filesToInclude = includeFiles?.map((file) => new URL(file, _config.root)) || []; - filesToInclude.push(...extraFilesToInclude); + const routeDefinitions: Array<{ + src: string + dest: string + middlewarePath?: string + }> = []; + + const includeFiles = _includeFiles.map((file) => new URL(file, _config.root)).concat(extraFilesToInclude); + const excludeFiles = _excludeFiles.map((file) => new URL(file, _config.root)); const runtime = getRuntime(process, logger); @@ -267,7 +277,7 @@ export default function vercelServerless({ config: _config, logger, NTF_CACHE, - includeFiles: filesToInclude, + includeFiles, excludeFiles, maxDuration, }); @@ -278,24 +288,28 @@ export default function vercelServerless({ } } else { await createFunctionFolder({ - functionName: 'render', + functionName: NODE_PATH, runtime, - entry: new URL(serverEntry, buildTempFolder), + entry: new URL(_serverEntry, _buildTempFolder), config: _config, logger, NTF_CACHE, - includeFiles: filesToInclude, + includeFiles, excludeFiles, maxDuration, }); + const dest = _middlewareEntryPoint ? MIDDLEWARE_PATH : NODE_PATH; for (const route of routes) { - if (route.prerender) continue; - routeDefinitions.push({ - src: route.pattern.source, - dest: 'render', - }); + if (!route.prerender) routeDefinitions.push({ src: route.pattern.source, dest }); } } + if (_middlewareEntryPoint) { + await createMiddlewareFolder({ + functionName: MIDDLEWARE_PATH, + entry: _middlewareEntryPoint, + config: _config, + }); + } const fourOhFourRoute = routes.find((route) => route.pathname === '/404'); // Output configuration // https://vercel.com/docs/build-output-api/v3#build-output-configuration @@ -314,7 +328,9 @@ export default function vercelServerless({ ? [ { src: '/.*', - dest: fourOhFourRoute.prerender ? '/404.html' : 'render', + dest: fourOhFourRoute.prerender ? '/404.html' + : _middlewareEntryPoint ? MIDDLEWARE_PATH + : NODE_PATH, status: 404, }, ] @@ -337,7 +353,7 @@ export default function vercelServerless({ }); // Remove temporary folder - await removeDir(buildTempFolder); + await removeDir(_buildTempFolder); }, }, }; @@ -345,6 +361,31 @@ export default function vercelServerless({ type Runtime = `nodejs${string}.x`; +interface CreateMiddlewareFolderArgs { + config: AstroConfig + entry: URL + functionName: string +} + +async function createMiddlewareFolder({ + functionName, + entry, + config, +}: CreateMiddlewareFolderArgs) { + const functionFolder = new URL(`./functions/${functionName}.func/`, config.outDir); + + await generateEdgeMiddleware( + entry, + new URL(VERCEL_EDGE_MIDDLEWARE_FILE, config.srcDir), + new URL('./middleware.mjs', functionFolder), + ) + + await writeJson(new URL(`./.vc-config.json`, functionFolder), { + runtime: 'edge', + entrypoint: 'middleware.mjs', + }); +} + interface CreateFunctionFolderArgs { functionName: string; runtime: Runtime; @@ -353,7 +394,7 @@ interface CreateFunctionFolderArgs { logger: AstroIntegrationLogger; NTF_CACHE: any; includeFiles: URL[]; - excludeFiles: string[]; + excludeFiles: URL[]; maxDuration: number | undefined; } @@ -379,7 +420,7 @@ async function createFunctionFolder({ entry, outDir: functionFolder, includeFiles, - excludeFiles: excludeFiles.map((file) => new URL(file, config.root)), + excludeFiles, logger, }, NTF_CACHE @@ -393,7 +434,7 @@ async function createFunctionFolder({ // https://vercel.com/docs/build-output-api/v3#vercel-primitives/serverless-functions/configuration await writeJson(vcConfig, { runtime, - handler, + handler: handler.replaceAll("\\","/"), launcherType: 'Nodejs', maxDuration, supportsResponseStreaming: true, @@ -411,15 +452,18 @@ function getRuntime(process: NodeJS.Process, logger: AstroIntegrationLogger): Ru `\tYour project will use Node.js 18 as the runtime instead.\n` + `\tConsider switching your local version to 18.\n` ); + return 'nodejs18.x'; } if (support.status === 'current') { return `nodejs${major}.x`; - } else if (support?.status === 'beta') { + } + if (support.status === 'beta') { logger.warn( `Your project is being built for Node.js ${major} as the runtime, which is currently in beta for Vercel Serverless Functions.` ); return `nodejs${major}.x`; - } else if (support.status === 'deprecated') { + } + if (support.status === 'deprecated') { const removeDate = new Intl.DateTimeFormat(undefined, { dateStyle: 'long' }).format( support.removal ); @@ -430,14 +474,6 @@ function getRuntime(process: NodeJS.Process, logger: AstroIntegrationLogger): Ru `\tConsider upgrading your local version to 18.\n` ); return `nodejs${major}.x`; - } else { - logger.warn( - `\n` + - `\tThe local Node.js version (${major}) is not supported by Vercel Serverless Functions.\n` + - `\tYour project will use Node.js 18 as the runtime instead.\n` + - `\tConsider switching your local version to 18.\n` - ); - return 'nodejs18.x'; } - return `nodejs${major}.x`; + return 'nodejs18.x'; } diff --git a/packages/integrations/vercel/src/serverless/entrypoint.ts b/packages/integrations/vercel/src/serverless/entrypoint.ts index dedc7fcc1a64..a300b8a12f8b 100644 --- a/packages/integrations/vercel/src/serverless/entrypoint.ts +++ b/packages/integrations/vercel/src/serverless/entrypoint.ts @@ -1,7 +1,7 @@ import type { SSRManifest } from 'astro'; import { applyPolyfills, NodeApp } from 'astro/app/node'; import type { IncomingMessage, ServerResponse } from 'node:http'; -import { ASTRO_LOCALS_HEADER } from './adapter.js'; +import { ASTRO_PATH_HEADER, ASTRO_LOCALS_HEADER } from './adapter.js'; applyPolyfills(); @@ -10,6 +10,10 @@ export const createExports = (manifest: SSRManifest) => { const handler = async (req: IncomingMessage, res: ServerResponse) => { const clientAddress = req.headers['x-forwarded-for'] as string | undefined; const localsHeader = req.headers[ASTRO_LOCALS_HEADER]; + const realPath = req.headers[ASTRO_PATH_HEADER]; + if (typeof realPath === 'string') { + req.url = realPath; + } const locals = typeof localsHeader === 'string' ? JSON.parse(localsHeader) diff --git a/packages/integrations/vercel/src/serverless/middleware.ts b/packages/integrations/vercel/src/serverless/middleware.ts index b3ada80d15bf..81abaee434ee 100644 --- a/packages/integrations/vercel/src/serverless/middleware.ts +++ b/packages/integrations/vercel/src/serverless/middleware.ts @@ -1,7 +1,7 @@ import { existsSync } from 'node:fs'; -import { join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { ASTRO_LOCALS_HEADER } from './adapter.js'; +import { builtinModules } from 'node:module'; +import { ASTRO_LOCALS_HEADER, ASTRO_PATH_HEADER, NODE_PATH } from './adapter.js'; /** * It generates the Vercel Edge Middleware file. @@ -16,16 +16,12 @@ import { ASTRO_LOCALS_HEADER } from './adapter.js'; */ export async function generateEdgeMiddleware( astroMiddlewareEntryPointPath: URL, - outPath: string, - vercelEdgeMiddlewareHandlerPath: URL + vercelEdgeMiddlewareHandlerPath: URL, + outPath: URL, ): Promise { - const entryPointPathURLAsString = JSON.stringify( - fileURLToPath(astroMiddlewareEntryPointPath).replace(/\\/g, '/') - ); - - const code = edgeMiddlewareTemplate(entryPointPathURLAsString, vercelEdgeMiddlewareHandlerPath); + const code = edgeMiddlewareTemplate(astroMiddlewareEntryPointPath, vercelEdgeMiddlewareHandlerPath); // https://vercel.com/docs/concepts/functions/edge-middleware#create-edge-middleware - const bundledFilePath = join(outPath, 'middleware.mjs'); + const bundledFilePath = fileURLToPath(outPath); const esbuild = await import('esbuild'); await esbuild.build({ stdin: { @@ -36,17 +32,27 @@ export async function generateEdgeMiddleware( platform: 'browser', // https://runtime-keys.proposal.wintercg.org/#edge-light conditions: ['edge-light', 'worker', 'browser'], - external: ['astro/middleware'], outfile: bundledFilePath, allowOverwrite: true, format: 'esm', bundle: true, minify: false, + // ensure node built-in modules are namespaced with `node:` + plugins: [{ + name: 'esbuild-namespace-node-built-in-modules', + setup(build) { + const filter = new RegExp(builtinModules.map((mod) => `(^${mod}$)`).join('|')); + build.onResolve({ filter }, (args) => ({ path: 'node:' + args.path, external: true })); + }, + }] }); return pathToFileURL(bundledFilePath); } -function edgeMiddlewareTemplate(middlewarePath: string, vercelEdgeMiddlewareHandlerPath: URL) { +function edgeMiddlewareTemplate(astroMiddlewareEntryPointPath: URL, vercelEdgeMiddlewareHandlerPath: URL) { + const middlewarePath = JSON.stringify( + fileURLToPath(astroMiddlewareEntryPointPath).replace(/\\/g, '/') + ); const filePathEdgeMiddleware = fileURLToPath(vercelEdgeMiddlewareHandlerPath); let handlerTemplateImport = ''; let handlerTemplateCall = '{}'; @@ -61,20 +67,20 @@ function edgeMiddlewareTemplate(middlewarePath: string, vercelEdgeMiddlewareHand import { onRequest } from ${middlewarePath}; import { createContext, trySerializeLocals } from 'astro/middleware'; export default async function middleware(request, context) { - const url = new URL(request.url); const ctx = createContext({ request, params: {} }); ctx.locals = ${handlerTemplateCall}; - const next = async () => { - const response = await fetch(url, { + const { origin } = new URL(request.url); + const next = () => + fetch(new URL('${NODE_PATH}', request.url), { headers: { - ${JSON.stringify(ASTRO_LOCALS_HEADER)}: trySerializeLocals(ctx.locals) + ...Object.fromEntries(request.headers.entries()), + '${ASTRO_PATH_HEADER}': request.url.replace(origin, ''), + '${ASTRO_LOCALS_HEADER}': trySerializeLocals(ctx.locals) } - }); - return response; - }; + }) return onRequest(ctx, next); }`; diff --git a/packages/integrations/vercel/src/static/adapter.ts b/packages/integrations/vercel/src/static/adapter.ts index 5924af759a5b..7bc793e40e8f 100644 --- a/packages/integrations/vercel/src/static/adapter.ts +++ b/packages/integrations/vercel/src/static/adapter.ts @@ -6,7 +6,7 @@ import { type DevImageService, type VercelImageConfig, } from '../image/shared.js'; -import { emptyDir, getVercelOutput, writeJson } from '../lib/fs.js'; +import { emptyDir, writeJson } from '../lib/fs.js'; import { isServerLikeOutput } from '../lib/prerender.js'; import { getRedirects } from '../lib/redirects.js'; import { @@ -79,7 +79,7 @@ export default function vercelStatic({ if (command === 'build' && speedInsights?.enabled) { injectScript('page', 'import "@astrojs/vercel/speed-insights"'); } - const outDir = new URL('./static/', getVercelOutput(config.root)); + const outDir = new URL('./.vercel/output/static/', config.root); updateConfig({ outDir, build: { @@ -110,12 +110,12 @@ export default function vercelStatic({ // Ensure to have `.vercel/output` empty. // This is because, when building to static, outDir = .vercel/output/static/, // so .vercel/output itself won't get cleaned. - await emptyDir(getVercelOutput(_config.root)); + await emptyDir(new URL('./.vercel/output/', _config.root)); }, 'astro:build:done': async ({ routes }) => { // Output configuration // https://vercel.com/docs/build-output-api/v3#build-output-configuration - await writeJson(new URL(`./config.json`, getVercelOutput(_config.root)), { + await writeJson(new URL('./.vercel/output/config.json', _config.root), { version: 3, routes: [ ...getRedirects(routes, _config), diff --git a/packages/integrations/vercel/test/edge-middleware.test.js b/packages/integrations/vercel/test/edge-middleware.test.js index deaeb416020c..91146a41c0f8 100644 --- a/packages/integrations/vercel/test/edge-middleware.test.js +++ b/packages/integrations/vercel/test/edge-middleware.test.js @@ -3,6 +3,34 @@ import chaiJestSnapshot from 'chai-jest-snapshot'; import { loadFixture } from './test-utils.js'; describe('Vercel edge middleware', () => { + /** @type {import('../../../astro/test/test-utils.js').Fixture} */ + let build; + before(async () => { + build = await loadFixture({ + root: './fixtures/middleware-with-edge-file/', + }); + await build.build(); + }); + + it('an edge function is created', async () => { + const contents = await build.readFile( + '../.vercel/output/functions/_middleware.func/.vc-config.json' + ); + expect(JSON.parse(contents)).to.deep.include({ + "runtime": "edge", + "entrypoint": "middleware.mjs" + }); + }); + + + it('deployment config points to the middleware edge function', async () => { + const contents = await build.readFile( + '../.vercel/output/config.json' + ); + const { routes } = JSON.parse(contents); + expect(routes.some(route => route.dest === '_middleware')).to.be.true; + }); + // TODO: The path here seems to be inconsistent? it.skip('with edge handle file, should successfully build the middleware', async () => { const fixture = await loadFixture({ diff --git a/packages/integrations/vercel/test/max-duration.test.js b/packages/integrations/vercel/test/max-duration.test.js index 8f0af3b89df4..79991290515c 100644 --- a/packages/integrations/vercel/test/max-duration.test.js +++ b/packages/integrations/vercel/test/max-duration.test.js @@ -14,7 +14,7 @@ describe('maxDuration', () => { it('makes it to vercel function configuration', async () => { const vcConfig = JSON.parse( - await fixture.readFile('../.vercel/output/functions/render.func/.vc-config.json') + await fixture.readFile('../.vercel/output/functions/_render.func/.vc-config.json') ); expect(vcConfig).to.deep.include({ maxDuration: 60 }); }); diff --git a/packages/integrations/vercel/test/streaming.test.js b/packages/integrations/vercel/test/streaming.test.js index 93dc95c39508..205946973a8c 100644 --- a/packages/integrations/vercel/test/streaming.test.js +++ b/packages/integrations/vercel/test/streaming.test.js @@ -1,7 +1,7 @@ import { loadFixture } from './test-utils.js'; import { expect } from 'chai'; -describe('maxDuration', () => { +describe('streaming', () => { /** @type {import('./test-utils.js').Fixture} */ let fixture; @@ -14,7 +14,7 @@ describe('maxDuration', () => { it('makes it to vercel function configuration', async () => { const vcConfig = JSON.parse( - await fixture.readFile('../.vercel/output/functions/render.func/.vc-config.json') + await fixture.readFile('../.vercel/output/functions/_render.func/.vc-config.json') ); expect(vcConfig).to.deep.include({ supportsResponseStreaming: true }); });