Skip to content
Draft
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
18 changes: 18 additions & 0 deletions src/presets/vercel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,24 @@ export interface VercelOptions {
regions?: string[];

functions?: VercelServerlessFunctionConfig;

/**
* Per-route function configuration overrides.
*
* Keys are route patterns (e.g., `/api/queues/*`, `/api/slow-routes/**`).
* Values are partial {@link VercelServerlessFunctionConfig} objects.
*
* @example
* ```ts
* functionRules: {
* '/api/my-slow-routes/**': { maxDuration: 3600 },
* '/api/queues/fulfill-order': {
* experimentalTriggers: [{ type: 'queue/v2beta', topic: 'orders' }],
* },
* }
* ```
*/
functionRules?: Record<string, VercelServerlessFunctionConfig>;
}

/**
Expand Down
231 changes: 212 additions & 19 deletions src/presets/vercel/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,21 +60,56 @@ export async function generateFunctionFiles(nitro: Nitro) {
}
}

const functionConfigPath = resolve(
nitro.options.output.serverDir,
".vc-config.json"
);
const functionConfig: VercelServerlessFunctionConfig = {
const baseFunctionConfig: VercelServerlessFunctionConfig = {
runtime,
...nitro.options.vercel?.functions,
handler: "index.mjs",
launcherType: "Nodejs",
shouldAddHelpers: false,
supportsResponseStreaming: true,
};
await writeFile(functionConfigPath, JSON.stringify(functionConfig, null, 2));

if (
Array.isArray(baseFunctionConfig.experimentalTriggers) &&
(baseFunctionConfig.experimentalTriggers as unknown[]).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 accessible 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
? Object.fromEntries(
Object.entries(nitro.options.vercel.functionRules).map(([k, v]) => [
withLeadingSlash(k),
v,
])
)
: undefined;
const hasFunctionRules =
functionRules && Object.keys(functionRules).length > 0;
let routeFuncMatcher: ReturnType<typeof toRouteMatcher> | undefined;
if (hasFunctionRules) {
routeFuncMatcher = toRouteMatcher(
createRadixRouter({ routes: functionRules })
);
}

// Write ISR functions
// Tracks base (non-ISR-suffixed) func paths for routes that have ISR,
// so functionRules loop can skip patterns already handled here.
const isrBasePaths = new Set<string>();
for (const [key, value] of Object.entries(nitro.options.routeRules)) {
if (!value.isr) {
continue;
Expand All @@ -86,18 +121,67 @@ 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 matchedRules = routeFuncMatcher
? (defu(
{},
...routeFuncMatcher.matchAll(key).reverse()
) as VercelServerlessFunctionConfig)
: undefined;
if (matchedRules && Object.keys(matchedRules).length > 0) {
isrBasePaths.add(
resolve(
nitro.options.output.serverDir,
"..",
normalizeRouteDest(key) + ".func"
)
);
await createFunctionDirWithCustomConfig(
funcPrefix + ".func",
nitro.options.output.serverDir,
baseFunctionConfig,
matchedRules,
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 (isrBasePaths.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;
Expand All @@ -108,7 +192,7 @@ export async function generateFunctionFiles(nitro: Nitro) {
const _getRouteRules = (path: string) =>
defu({}, ..._routeRulesMatcher.matchAll(path).reverse()) as NitroRouteRules;
for (const route of o11Routes) {
const routeRules = _getRouteRules(route.src);
const routeRules = _getRouteRules(route.pattern);
if (routeRules.isr) {
continue; // #3563
}
Expand All @@ -117,12 +201,35 @@ export async function generateFunctionFiles(nitro: Nitro) {
"..",
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 matchedRules = routeFuncMatcher
? (defu(
{},
...routeFuncMatcher.matchAll(route.pattern).reverse()
) as VercelServerlessFunctionConfig)
: undefined;
if (matchedRules && Object.keys(matchedRules).length > 0) {
await createFunctionDirWithCustomConfig(
funcDir,
nitro.options.output.serverDir,
baseFunctionConfig,
matchedRules,
route.dest
);
} else {
await fsp.mkdir(dirname(funcPrefix), { recursive: true });
await fsp.symlink(
"./" + relative(dirname(funcPrefix), nitro.options.output.serverDir),
funcDir,
"junction"
);
}
}
}

Expand Down Expand Up @@ -263,6 +370,22 @@ function generateBuildConfig(nitro: Nitro, o11Routes?: ObservabilityRoute[]) {
),
};
}),
// Route function config routes (skip patterns already handled by ISR or observability)
...(nitro.options.vercel?.functionRules
? Object.keys(nitro.options.vercel.functionRules)
.map((p) => withLeadingSlash(p))
.filter(
(pattern) =>
!rules.some(([key, value]) => value.isr && key === pattern) &&
!(o11Routes || []).some(
(r) => r.dest === normalizeRouteDest(pattern)
)
)
.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),
Expand Down Expand Up @@ -335,8 +458,9 @@ function _hasProp(obj: any, prop: string) {
// --- utils for observability ---

type ObservabilityRoute = {
src: string; // route pattern
src: string; // PCRE-compatible route pattern for config.json
dest: string; // function name
pattern: string; // original radix3-compatible route pattern
};

function getObservabilityRoutes(nitro: Nitro): ObservabilityRoute[] {
Expand Down Expand Up @@ -390,6 +514,7 @@ function normalizeRoutes(routes: string[]) {
.map((route) => ({
src: normalizeRouteSrc(route),
dest: normalizeRouteDest(route),
pattern: route,
}));
}

Expand Down Expand Up @@ -454,6 +579,74 @@ 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) {
switch (char) {
case "_": {
result += "__";
break;
}
case "/": {
result += "_S";
break;
}
case ".": {
result += "_D";
break;
}
default: {
result += /[A-Za-z0-9-]/.test(char)
? char
: "_" +
char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0");
}
}
}
return result;
}

async function createFunctionDirWithCustomConfig(
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 });
// defu merges arrays, but for function config we want overrides to replace arrays entirely
const mergedConfig = {
...defu(overrides, baseFunctionConfig),
...Object.fromEntries(
Object.entries(overrides).filter(([, v]) => Array.isArray(v))
),
};

// 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)
);
}

async function writePrerenderConfig(
filename: string,
isrConfig: NitroRouteRules["isr"],
Expand Down
16 changes: 16 additions & 0 deletions test/fixture/nitro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@ import { defineNitroConfig } from "nitropack/config";
import { dirname, resolve } from "node:path";

export default defineNitroConfig({
vercel: {
functionRules: {
"/api/hello": {
maxDuration: 100,
},
"/api/echo": {
experimentalTriggers: [{ type: "queue/v2beta", topic: "orders" }],
},
"/rules/isr/**": {
regions: ["lhr1", "cdg1"],
},
"/api/storage/**": {
maxDuration: 60,
},
},
},
compressPublicAssets: true,
compatibilityDate: "latest",
framework: {
Expand Down
Loading
Loading