Skip to content
Open
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
3 changes: 2 additions & 1 deletion docs/1.guide/10.tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ export default defineNuxtConfig({
### Platform support

- `dev`, `node-server`, `bun` and `deno-server` presets are supported with [croner](https://croner.56k.guru/) engine.
- `cloudflare_module` preset have native integration with [Cron Triggers](https://developers.cloudflare.com/workers/configuration/cron-triggers/). Make sure to configure wrangler to use exactly same patterns you define in `scheduledTasks` to be matched.
- `cloudflare_module` preset has native integration with [Cron Triggers](https://developers.cloudflare.com/workers/configuration/cron-triggers/). Make sure to configure wrangler to use the same patterns you define in `scheduledTasks` to be matched.
- `vercel` preset has native integration with [Vercel Cron Jobs](https://vercel.com/docs/cron-jobs). Nitro automatically generates the cron job configuration at build time β€” no manual `vercel.json` setup required.
- More presets (with native primitives support) are planned to be supported!

## Programmatically run tasks
Expand Down
26 changes: 26 additions & 0 deletions docs/2.deploy/20.providers/vercel.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,32 @@ Alternatively, Nitro also detects Bun automatically if you specify a `bunVersion
}
```

## Scheduled tasks (Cron Jobs)

:read-more{title="Vercel Cron Jobs" to="https://vercel.com/docs/cron-jobs"}

Nitro automatically converts your [`scheduledTasks`](/guide/tasks#scheduled-tasks) configuration into [Vercel Cron Jobs](https://vercel.com/docs/cron-jobs) at build time. Define your schedules in your Nitro config and deploy - no manual `vercel.json` cron configuration required.

```ts [nitro.config.ts]
export default defineNitroConfig({
experimental: {
tasks: true
},
scheduledTasks: {
// Run `cms:update` every hour
'0 * * * *': ['cms:update'],
// Run `db:cleanup` every day at midnight
'0 0 * * *': ['db:cleanup']
}
})
```

### Secure cron job endpoints

:read-more{title="Securing cron jobs" to="https://vercel.com/docs/cron-jobs/manage-cron-jobs#securing-cron-jobs"}

A `CRON_SECRET` environment variable is **required** to protect the cron handler. Set it in your Vercel project settings.

## Custom build output configuration

You can provide additional [build output configuration](https://vercel.com/docs/build-output-api/v3) using `vercel.config` key inside `nitro.config`. It will be merged with built-in auto-generated config.
Expand Down
20 changes: 20 additions & 0 deletions src/presets/vercel/preset.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { fileURLToPath } from "node:url";
import { defineNitroPreset } from "nitropack/kit";
import type { Nitro } from "nitropack/types";
import { normalize } from "pathe";
import { withLeadingSlash } from "ufo";
import {
deprecateSWR,
generateEdgeFunctionFiles,
Expand All @@ -18,6 +21,7 @@ const vercel = defineNitroPreset(
entry: "./runtime/vercel",
vercel: {
skewProtection: !!process.env.VERCEL_SKEW_PROTECTION_ENABLED,
cronHandlerRoute: "/_vercel/cron",
},
output: {
dir: "{{ rootDir }}/.vercel/output",
Expand All @@ -31,6 +35,22 @@ const vercel = defineNitroPreset(
hooks: {
"rollup:before": (nitro: Nitro) => {
deprecateSWR(nitro);

// Cron tasks handler
if (
nitro.options.experimental.tasks &&
Object.keys(nitro.options.scheduledTasks || {}).length > 0
) {
nitro.options.handlers.push({
route: withLeadingSlash(
nitro.options.vercel?.cronHandlerRoute || "/_vercel/cron"
),
lazy: true,
handler: normalize(
fileURLToPath(new URL("runtime/cron-handler", import.meta.url))
),
});
}
},
async compiled(nitro: Nitro) {
await generateFunctionFiles(nitro);
Expand Down
38 changes: 38 additions & 0 deletions src/presets/vercel/runtime/cron-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { timingSafeEqual } from "node:crypto";
import { createError, eventHandler, getHeader } from "h3";
import { runCronTasks } from "nitropack/runtime/internal";

export default eventHandler(async (event) => {
// Require and validate CRON_SECRET - https://vercel.com/docs/cron-jobs/manage-cron-jobs#securing-cron-jobs
const cronSecret = process.env.CRON_SECRET;
if (!cronSecret) {
throw createError({
statusCode: 500,
statusMessage: "CRON_SECRET environment variable is not set",
});
}
const authHeader = getHeader(event, "authorization") || "";
const expected = `Bearer ${cronSecret}`;
const a = Buffer.from(authHeader);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
}

const cron = getHeader(event, "x-vercel-cron-schedule");
if (!cron) {
throw createError({
statusCode: 400,
statusMessage: "Missing x-vercel-cron-schedule header",
});
}

await runCronTasks(cron, {
context: {},
payload: {
scheduledTime: Date.now(),
},
});

return { success: true };
});
12 changes: 12 additions & 0 deletions src/presets/vercel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,18 @@ export interface VercelOptions {
regions?: string[];

functions?: VercelServerlessFunctionConfig;

/**
* The route path for the Vercel cron handler endpoint.
*
* When `experimental.tasks` and `scheduledTasks` are configured,
* Nitro registers a cron handler at this path that Vercel invokes
* on each scheduled cron trigger.
*
* @default "/_vercel/cron"
* @see https://vercel.com/docs/cron-jobs
*/
cronHandlerRoute?: string;
Comment on lines +131 to +141

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor | ⚑ Quick win

Normalize or validate cronHandlerRoute at the API boundary.

This new public option is consumed verbatim in both preset.ts and utils.ts. A value like "api/cron" will generate an invalid Vercel cron path and a mismatched Nitro handler route instead of failing fast. Please normalize with withLeadingSlash() or throw an explicit config error when the value is not an absolute path.

As per coding guidelines: "Prefer explicit errors over silent failures in error handling" and "Include actionable context in error messages".

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/presets/vercel/types.ts` around lines 131 - 141, The cronHandlerRoute
option is consumed verbatim (used in preset.ts and utils.ts) which allows
invalid values like "api/cron"; normalize or validate it at the API boundary: in
the code that reads the option (the config parsing/merge path that references
cronHandlerRoute in preset.ts and utils.ts) call
withLeadingSlash(cronHandlerRoute) to ensure it starts with "/" or throw a clear
config error if the value is not an absolute path (include the user value in the
error message and mention expected format like "/_vercel/cron"); ensure
downstream uses use the normalized value so generated Vercel cron paths and
Nitro handler routes are always valid.

}

/**
Expand Down
20 changes: 20 additions & 0 deletions src/presets/vercel/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,26 @@ function generateBuildConfig(nitro: Nitro, o11Routes?: ObservabilityRoute[]) {
],
});

// Cron jobs from scheduledTasks
// Only the node `vercel` preset registers a cron handler (see preset.ts).
// `vercel-edge` cannot run the Node-based handler and `vercel-static` has no
// server function, so we skip emitting cron config for them.
if (
nitro.options.preset === "vercel" &&
nitro.options.experimental.tasks &&
Object.keys(nitro.options.scheduledTasks || {}).length > 0
) {
const cronPath = withLeadingSlash(
nitro.options.vercel?.cronHandlerRoute || "/_vercel/cron"
);
const existing = config.crons || [];
const seen = new Set(existing.map((c) => `${c.path}|${c.schedule}`));
const cronEntries = Object.keys(nitro.options.scheduledTasks)
.map((schedule) => ({ path: cronPath, schedule }))
.filter((c) => !seen.has(`${c.path}|${c.schedule}`));
config.crons = [...cronEntries, ...existing];
}

// Early return if we are building a static site
if (nitro.options.static) {
return config;
Expand Down
81 changes: 80 additions & 1 deletion test/presets/vercel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,19 @@ describe("nitro:preset:vercel", async () => {
return res;
};
},
() => {
(_ctx, callHandler) => {
it("should add route rules to config", async () => {
const config = await fsp
.readFile(resolve(ctx.outDir, "config.json"), "utf8")
.then((r) => JSON.parse(r));
expect(config).toMatchInlineSnapshot(`
{
"crons": [
{
"path": "/_vercel/cron",
"schedule": "* * * * *",
},
],
"overrides": {
"_scalar/index.html": {
"path": "_scalar",
Expand Down Expand Up @@ -352,6 +358,10 @@ describe("nitro:preset:vercel", async () => {
"dest": "/500",
"src": "/500",
},
{
"dest": "/_vercel/cron",
"src": "/_vercel/cron",
},
{
"dest": "/assets/[id]",
"src": "/assets/(?<id>[^/]+)",
Expand Down Expand Up @@ -476,6 +486,7 @@ describe("nitro:preset:vercel", async () => {
"functions/__fallback.func/node_modules",
"functions/__fallback.func/package.json",
"functions/__fallback.func/timing.js",
"functions/_vercel/cron.func (symlink)",
"functions/api/cached.func (symlink)",
"functions/api/db.func (symlink)",
"functions/api/echo.func (symlink)",
Expand Down Expand Up @@ -555,6 +566,74 @@ describe("nitro:preset:vercel", async () => {
]
`);
});

describe("cron handler", () => {
const cronUrl = "/_vercel/cron";
const schedule = "* * * * *"; // matches fixture scheduledTasks
const originalSecret = process.env.CRON_SECRET;

afterAll(() => {
if (originalSecret === undefined) {
delete process.env.CRON_SECRET;
} else {
process.env.CRON_SECRET = originalSecret;
}
});

it("returns 500 when CRON_SECRET is not set", async () => {
delete process.env.CRON_SECRET;
const res = await callHandler({
url: cronUrl,
ignoreResponseError: true,
headers: { "x-vercel-cron-schedule": schedule },
});
expect(res.status).toBe(500);
});

it("returns 401 with missing or wrong Authorization", async () => {
process.env.CRON_SECRET = "test-secret";
const noAuth = await callHandler({
url: cronUrl,
ignoreResponseError: true,
headers: { "x-vercel-cron-schedule": schedule },
});
expect(noAuth.status).toBe(401);

const wrongAuth = await callHandler({
url: cronUrl,
ignoreResponseError: true,
headers: {
authorization: "Bearer wrong-secret",
"x-vercel-cron-schedule": schedule,
},
});
expect(wrongAuth.status).toBe(401);
});

it("returns 400 when the cron schedule header is missing", async () => {
process.env.CRON_SECRET = "test-secret";
const res = await callHandler({
url: cronUrl,
ignoreResponseError: true,
headers: { authorization: "Bearer test-secret" },
});
expect(res.status).toBe(400);
});

it("runs tasks with valid CRON_SECRET and schedule header", async () => {
process.env.CRON_SECRET = "test-secret";
const res = await callHandler({
url: cronUrl,
ignoreResponseError: true,
headers: {
authorization: "Bearer test-secret",
"x-vercel-cron-schedule": schedule,
},
});
expect(res.status).toBe(200);
expect(res.data).toMatchObject({ success: true });
});
});
}
);
});
Expand Down
Loading