-
-
Notifications
You must be signed in to change notification settings - Fork 2.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(vercel): edge middleware #9585
Changes from 3 commits
5c7d8ca
b014bb1
8081fad
94fd816
20858ff
0c3f4c7
8f708c4
a2d6eef
c1df03f
6ffb099
f7d295c
930f851
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 { | ||
|
@@ -111,8 +111,8 @@ export interface VercelServerlessConfig { | |
export default function vercelServerless({ | ||
webAnalytics, | ||
speedInsights, | ||
includeFiles, | ||
excludeFiles = [], | ||
includeFiles: _includeFiles = [], | ||
excludeFiles: _excludeFiles = [], | ||
imageService, | ||
imagesConfig, | ||
devImageService = 'sharp', | ||
|
@@ -130,9 +130,10 @@ export default function vercelServerless({ | |
} | ||
|
||
let _config: AstroConfig; | ||
let buildTempFolder: URL; | ||
let serverEntry: string; | ||
let _buildTempFolder: URL; | ||
let _serverEntry: string; | ||
let _entryPoints: Map<RouteData, URL>; | ||
let _middlewareEntryPoint: URL | undefined; | ||
// Extra files to be merged with `includeFiles` during build | ||
const extraFilesToInclude: URL[] = []; | ||
|
||
|
@@ -162,13 +163,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 +195,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 +210,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 +229,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 +261,7 @@ export default function vercelServerless({ | |
config: _config, | ||
logger, | ||
NTF_CACHE, | ||
includeFiles: filesToInclude, | ||
includeFiles, | ||
excludeFiles, | ||
maxDuration, | ||
}); | ||
|
@@ -278,24 +272,28 @@ export default function vercelServerless({ | |
} | ||
} else { | ||
await createFunctionFolder({ | ||
functionName: 'render', | ||
functionName: '_render', | ||
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' : '_render'; | ||
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', | ||
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 +312,9 @@ export default function vercelServerless({ | |
? [ | ||
{ | ||
src: '/.*', | ||
dest: fourOhFourRoute.prerender ? '/404.html' : 'render', | ||
dest: fourOhFourRoute.prerender ? '/404.html' | ||
: _middlewareEntryPoint ? '_middleware' | ||
: 'render', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we should move this strings ( There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right, it is supposed to be |
||
status: 404, | ||
}, | ||
] | ||
|
@@ -337,14 +337,39 @@ export default function vercelServerless({ | |
}); | ||
|
||
// Remove temporary folder | ||
await removeDir(buildTempFolder); | ||
await removeDir(_buildTempFolder); | ||
}, | ||
}, | ||
}; | ||
} | ||
|
||
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', | ||
}); | ||
} | ||
|
||
Comment on lines
+364
to
+388
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We create an edge function, complete with its own folder and vc-config. |
||
interface CreateFunctionFolderArgs { | ||
functionName: string; | ||
runtime: Runtime; | ||
|
@@ -353,7 +378,7 @@ interface CreateFunctionFolderArgs { | |
logger: AstroIntegrationLogger; | ||
NTF_CACHE: any; | ||
includeFiles: URL[]; | ||
excludeFiles: string[]; | ||
excludeFiles: URL[]; | ||
maxDuration: number | undefined; | ||
} | ||
|
||
|
@@ -379,7 +404,7 @@ async function createFunctionFolder({ | |
entry, | ||
outDir: functionFolder, | ||
includeFiles, | ||
excludeFiles: excludeFiles.map((file) => new URL(file, config.root)), | ||
excludeFiles, | ||
logger, | ||
}, | ||
NTF_CACHE | ||
|
@@ -393,7 +418,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("\\","/"), | ||
ematipico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
launcherType: 'Nodejs', | ||
maxDuration, | ||
supportsResponseStreaming: true, | ||
|
@@ -411,15 +436,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 +458,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'; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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['x-astro-path']; | ||
if (typeof realPath === 'string') { | ||
req.url = realPath; | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's this change about? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is so that the edge function can call the serverless function in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I still think it's worth adding a comment because the read and write of the header happens in two different places, so it could be helpful to give some context. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
const locals = | ||
typeof localsHeader === 'string' | ||
? JSON.parse(localsHeader) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,6 @@ | ||
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'; | ||
|
||
/** | ||
* It generates the Vercel Edge Middleware file. | ||
|
@@ -16,16 +15,12 @@ import { ASTRO_LOCALS_HEADER } from './adapter.js'; | |
*/ | ||
export async function generateEdgeMiddleware( | ||
astroMiddlewareEntryPointPath: URL, | ||
outPath: string, | ||
vercelEdgeMiddlewareHandlerPath: URL | ||
vercelEdgeMiddlewareHandlerPath: URL, | ||
outPath: URL, | ||
): Promise<URL> { | ||
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 +31,26 @@ 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, | ||
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 })); | ||
}, | ||
}] | ||
ematipico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}); | ||
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 +65,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('/_render', request.url), { | ||
headers: { | ||
${JSON.stringify(ASTRO_LOCALS_HEADER)}: trySerializeLocals(ctx.locals) | ||
...Object.fromEntries(request.headers.entries()), | ||
'x-astro-path': request.url.replace(origin, ''), | ||
'x-astro-locals': trySerializeLocals(ctx.locals) | ||
} | ||
}); | ||
return response; | ||
}; | ||
}) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The edge function calls the node server at |
||
|
||
return onRequest(ctx, next); | ||
}`; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why
_middleware
and notmiddleware
?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Vercel routes the folder names to a path on the deployed website so we avoid interfering this way.