-
-
Notifications
You must be signed in to change notification settings - Fork 806
feat(vercel): allow overriding function config by route #4124
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
Changes from all commits
da1808e
24d92dc
ba05eb0
2758d9b
af5775a
0b8d4c5
a904f13
88c1972
ac313e1
1dd21d5
fba759c
90d8bb6
beb380e
54892f1
8c23741
5355a5b
c55c0e2
64492d9
0ad3564
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 |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ import { defu } from "defu"; | |
| import { writeFile } from "../_utils/fs.ts"; | ||
| import type { Nitro, NitroRouteRules } from "nitro/types"; | ||
| import { dirname, relative, resolve } from "pathe"; | ||
| import { Router } from "../../routing.ts"; | ||
| import { joinURL, withLeadingSlash, withoutLeadingSlash } from "ufo"; | ||
| import type { | ||
| PrerenderFunctionConfig, | ||
|
|
@@ -48,17 +49,44 @@ export async function generateFunctionFiles(nitro: Nitro) { | |
| const buildConfig = generateBuildConfig(nitro, o11Routes); | ||
| await writeFile(buildConfigPath, JSON.stringify(buildConfig, null, 2)); | ||
|
|
||
| const functionConfigPath = resolve(nitro.options.output.serverDir, ".vc-config.json"); | ||
| const functionConfig: VercelServerlessFunctionConfig = { | ||
| const baseFunctionConfig: VercelServerlessFunctionConfig = { | ||
| handler: "index.mjs", | ||
| launcherType: "Nodejs", | ||
| shouldAddHelpers: false, | ||
| supportsResponseStreaming: true, | ||
| ...nitro.options.vercel?.functions, | ||
| }; | ||
| await writeFile(functionConfigPath, JSON.stringify(functionConfig, null, 2)); | ||
|
|
||
| if ( | ||
| Array.isArray(baseFunctionConfig.experimentalTriggers) && | ||
| baseFunctionConfig.experimentalTriggers.length > 0 | ||
| ) { | ||
| nitro.logger.warn( | ||
| "`experimentalTriggers` on the base `vercel.functions` config applies to the catch-all function and is likely not what you want. " + | ||
| "Routes with queue triggers are not accesible on the web." + | ||
| "Use `vercel.functionRules` to attach triggers to specific routes instead." | ||
| ); | ||
| } | ||
|
|
||
| const functionConfigPath = resolve(nitro.options.output.serverDir, ".vc-config.json"); | ||
| await writeFile(functionConfigPath, JSON.stringify(baseFunctionConfig, null, 2)); | ||
|
|
||
| const functionRules = nitro.options.vercel?.functionRules; | ||
| const hasfunctionRules = functionRules && Object.keys(functionRules).length > 0; | ||
| let routeFuncRouter: Router<VercelServerlessFunctionConfig> | undefined; | ||
| if (hasfunctionRules) { | ||
| routeFuncRouter = new Router<VercelServerlessFunctionConfig>(); | ||
| routeFuncRouter._update( | ||
| Object.entries(functionRules).map(([route, data]) => ({ | ||
| route, | ||
| method: "", | ||
| data, | ||
| })) | ||
| ); | ||
| } | ||
|
|
||
| // Write ISR functions | ||
| const isrFuncDirs = new Set<string>(); | ||
| for (const [key, value] of Object.entries(nitro.options.routeRules)) { | ||
| if (!value.isr) { | ||
| continue; | ||
|
|
@@ -70,18 +98,58 @@ export async function generateFunctionFiles(nitro: Nitro) { | |
| normalizeRouteDest(key) + ISR_SUFFIX | ||
| ); | ||
| await fsp.mkdir(dirname(funcPrefix), { recursive: true }); | ||
| await fsp.symlink( | ||
| "./" + relative(dirname(funcPrefix), nitro.options.output.serverDir), | ||
| funcPrefix + ".func", | ||
| "junction" | ||
| ); | ||
|
|
||
| const matchData = routeFuncRouter?.match("", key); | ||
| if (matchData) { | ||
| isrFuncDirs.add( | ||
| resolve(nitro.options.output.serverDir, "..", normalizeRouteDest(key) + ".func") | ||
| ); | ||
| await createFunctionDirWithCustomConfig( | ||
| funcPrefix + ".func", | ||
| nitro.options.output.serverDir, | ||
| baseFunctionConfig, | ||
| matchData, | ||
| normalizeRouteDest(key) + ISR_SUFFIX | ||
| ); | ||
| } else { | ||
| await fsp.symlink( | ||
| "./" + relative(dirname(funcPrefix), nitro.options.output.serverDir), | ||
| funcPrefix + ".func", | ||
| "junction" | ||
| ); | ||
| } | ||
|
|
||
| await writePrerenderConfig( | ||
| funcPrefix + ".prerender-config.json", | ||
| value.isr, | ||
| nitro.options.vercel?.config?.bypassToken | ||
| ); | ||
| } | ||
|
|
||
| // Write functionRules custom function directories | ||
| const createdFuncDirs = new Set<string>(); | ||
| if (hasfunctionRules) { | ||
| for (const [pattern, overrides] of Object.entries(functionRules!)) { | ||
| const funcDir = resolve( | ||
| nitro.options.output.serverDir, | ||
| "..", | ||
| normalizeRouteDest(pattern) + ".func" | ||
| ); | ||
| // Skip if ISR already created a custom config function for this route | ||
| if (isrFuncDirs.has(funcDir)) { | ||
| continue; | ||
| } | ||
| await createFunctionDirWithCustomConfig( | ||
| funcDir, | ||
| nitro.options.output.serverDir, | ||
| baseFunctionConfig, | ||
| overrides, | ||
| normalizeRouteDest(pattern) | ||
| ); | ||
| createdFuncDirs.add(funcDir); | ||
| } | ||
| } | ||
|
|
||
| // Write observability routes | ||
| if (o11Routes.length === 0) { | ||
| return; | ||
|
|
@@ -94,12 +162,30 @@ export async function generateFunctionFiles(nitro: Nitro) { | |
| continue; // #3563 | ||
| } | ||
| const funcPrefix = resolve(nitro.options.output.serverDir, "..", route.dest); | ||
| await fsp.mkdir(dirname(funcPrefix), { recursive: true }); | ||
| await fsp.symlink( | ||
| "./" + relative(dirname(funcPrefix), nitro.options.output.serverDir), | ||
| funcPrefix + ".func", | ||
| "junction" | ||
| ); | ||
| const funcDir = funcPrefix + ".func"; | ||
|
|
||
| // Skip if already created by functionRules | ||
| if (createdFuncDirs.has(funcDir)) { | ||
| continue; | ||
| } | ||
|
|
||
| const matchData = routeFuncRouter?.match("", route.src); | ||
| if (matchData) { | ||
| await createFunctionDirWithCustomConfig( | ||
| funcDir, | ||
| nitro.options.output.serverDir, | ||
| baseFunctionConfig, | ||
| matchData, | ||
| route.dest | ||
| ); | ||
| } else { | ||
| await fsp.mkdir(dirname(funcPrefix), { recursive: true }); | ||
| await fsp.symlink( | ||
| "./" + relative(dirname(funcPrefix), nitro.options.output.serverDir), | ||
| funcDir, | ||
| "junction" | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -273,6 +359,13 @@ function generateBuildConfig(nitro: Nitro, o11Routes?: ObservabilityRoute[]) { | |
| ), | ||
| }; | ||
| }), | ||
| // Route function config routes | ||
| ...(nitro.options.vercel?.functionRules | ||
| ? Object.keys(nitro.options.vercel.functionRules).map((pattern) => ({ | ||
| src: joinURL(nitro.options.baseURL, normalizeRouteSrc(pattern)), | ||
| dest: withLeadingSlash(normalizeRouteDest(pattern)), | ||
| })) | ||
| : []), | ||
| // Observability routes | ||
| ...(o11Routes || []).map((route) => ({ | ||
| src: joinURL(nitro.options.baseURL, route.src), | ||
|
|
@@ -512,6 +605,61 @@ function normalizeRouteDest(route: string) { | |
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Encodes a function path into a consumer name for queue/v2beta triggers. | ||
| * Mirrors the encoding from @vercel/build-utils sanitizeConsumerName(). | ||
| * @see https://github.com/vercel/vercel/blob/main/packages/build-utils/src/lambda.ts | ||
| */ | ||
| function sanitizeConsumerName(functionPath: string): string { | ||
| let result = ""; | ||
| for (const char of functionPath) { | ||
| if (char === "_") { | ||
| result += "__"; | ||
| } else if (char === "/") { | ||
| result += "_S"; | ||
| } else if (char === ".") { | ||
| result += "_D"; | ||
| } else if (/[A-Za-z0-9-]/.test(char)) { | ||
| result += char; | ||
| } else { | ||
| result += "_" + char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0"); | ||
| } | ||
| } | ||
| return result; | ||
| } | ||
|
|
||
| async function createFunctionDirWithCustomConfig( | ||
|
Member
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. Have you tried hardlinks?
Member
Author
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. Briefly but had complexities with symlinks in node_modules etc. since only files can be hard linked it seems. I don't think many functions would have overrides and copying reduces a lot of complexity. I may revisit getting symlinks working in Vercel CI later again though. |
||
| funcDir: string, | ||
| serverDir: string, | ||
| baseFunctionConfig: VercelServerlessFunctionConfig, | ||
| overrides: VercelServerlessFunctionConfig, | ||
| functionPath: string | ||
| ) { | ||
| // Copy the entire server directory instead of symlinking individual | ||
| // entries. Vercel's build container preserves symlinks in the Lambda | ||
| // zip, but symlinks pointing outside the .func directory break at | ||
| // runtime because the target path doesn't exist on Lambda. | ||
| await fsp.cp(serverDir, funcDir, { recursive: true }); | ||
| const mergedConfig = defu(overrides, baseFunctionConfig); | ||
| for (const [key, value] of Object.entries(overrides)) { | ||
| if (Array.isArray(value)) { | ||
| (mergedConfig as Record<string, unknown>)[key] = value; | ||
| } | ||
| } | ||
|
|
||
| // Auto-derive consumer for queue/v2beta triggers | ||
| const triggers = mergedConfig.experimentalTriggers; | ||
| if (Array.isArray(triggers)) { | ||
| for (const trigger of triggers as Array<Record<string, unknown>>) { | ||
| if (trigger.type === "queue/v2beta" && !trigger.consumer) { | ||
| trigger.consumer = sanitizeConsumerName(functionPath); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| await writeFile(resolve(funcDir, ".vc-config.json"), JSON.stringify(mergedConfig, null, 2)); | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| async function writePrerenderConfig( | ||
| filename: string, | ||
| isrConfig: NitroRouteRules["isr"], | ||
|
|
||
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.
Route-function routes should be specificity-ordered and baseURL-aware.
Current emission order depends on object key insertion, so wildcard patterns can shadow specific ones. Also, these
srcroutes are not prefixed withnitro.options.baseURL, unlike observability routes.π‘ Proposed fix
π€ Prompt for AI Agents