Custom Adapter
Wrap any scheduler in VitNode's one-method CronAdapter interface - a plain setInterval, a cron library, or a platform trigger.
A cron adapter is the thinnest interface in VitNode: one method, no return value,
called once while the API boots. Its whole job is to call handleCronJobs() on a
clock. Everything else - which jobs exist, which are due, what gets logged - is
the cron endpoint's business, not the adapter's.
Write one when the Node CRON adapter is not the scheduler you want: you already run a job runner, you need the tick to carry a trace header, or the platform hands you a trigger of its own.
Quick start
The shortest adapter that works is a timer:
import { type CronAdapter, handleCronJobs } from '@vitnode/core/api/lib/cron'
export const IntervalCronAdapter = (): CronAdapter => ({
schedule() {
setInterval(() => {
handleCronJobs().catch((error: unknown) => {
console.error('[cron] tick failed', error)
})
}, 60_000)
},
})import { buildApiConfig } from '@vitnode/core/vitnode.config'
import { IntervalCronAdapter } from './adapters/interval-cron.adapter'
export const vitNodeApiConfig = buildApiConfig({
cron: IntervalCronAdapter(),
// ...
})That is a complete, working adapter. The rest of this page is about doing it with a real scheduler, and about the ways it can go quietly wrong.
The interface
export interface CronAdapter {
schedule: () => void
}Prop
Type
handleCronJobs() comes from the same module and takes no arguments. It posts to
/api/@vitnode/core/cron on the origin in NEXT_PUBLIC_API_URL (falling back to
http://localhost:3000) with Content-Type: application/json and
Authorization: Bearer $CRON_SECRET. It resolves as soon as the response
arrives and never looks at it, so a 403 or a 500 resolves exactly like a
200 - only a network-level failure rejects.
You do not have to use handleCronJobs
It is a convenience, not a contract. An adapter whose scheduler lives outside
the process - a platform trigger, a separate worker - can leave schedule()
empty and let that scheduler call the endpoint
directly. The adapter still earns its place: setting
cron is what makes the AdminCP report a scheduler as configured.
Build one with a library
Write the adapter
Keep it in your app rather than in a plugin - a cron adapter is a deployment decision, and it is registered from the API config, which only the host has.
The official package is the reference implementation, and it is short enough to read in one go:
import { type CronAdapter, handleCronJobs } from '@vitnode/core/api/lib/cron'
import { schedule } from 'node-cron'
export const NodeCronAdapter = (): CronAdapter => {
return {
schedule() {
schedule('*/1 * * * *', async () => {
await handleCronJobs()
})
},
}
}Give the factory options if your scheduler has any worth exposing - a tick
interval, a timezone, a logger. CronAdapter says nothing about the factory, so
its signature is yours.
Register it in the API config
import { buildApiConfig } from '@vitnode/core/vitnode.config'
import { NodeCronAdapter } from './adapters/node-cron.adapter'
export const vitNodeApiConfig = buildApiConfig({
cron: NodeCronAdapter(),
// ...
})Restart and verify the first tick
schedule() runs at boot, so a running process never picks up the change.
bun devpnpm devnpm run devOpen /admin/core/advanced/cron and watch the process-queue row - it is
scheduled * * * * *, so a healthy adapter keeps its Last Run under a minute
old. If it stays on Never, your schedule() ran but your tick is not landing:
check the origin, then the secret, then whether the API can reach itself.
Gotchas
A throw in schedule() takes the API down
VitNode calls schedule() synchronously while building the application and
does not wrap it. An adapter that validates its own options by throwing turns
a bad cron expression into a server that will not start. Validate, log, and
return instead.
schedule() can be called more than once per process
There is no stop and no teardown hook, and a dev server that re-evaluates
its server modules can build the API again. VitNode's TanStack Start app parks
the API instance on globalThis for exactly this reason - if yours does not,
an adapter that keeps a timer should be able to notice it already has one
instead of stacking a second.
Ticking faster does not make a job faster
The endpoint runs what is due when it is called, so the tick interval is the
floor on every job's resolution - but it is not a speed control. A tick every
ten seconds on jobs that are all */5 * * * * is five wasted requests a
minute, and a tick every five minutes silently downgrades * * * * * jobs,
queue draining included.
Overlapping ticks are your problem, not the adapter's
Nothing locks a job while it runs. If your scheduler can fire again before the previous tick's handlers have finished, the same handler can be running twice. The operational rules cover what to do about it.
Next
Declare a cron job
buildCron, where jobs are registered, and the operational rules that come with a scheduler.
Node CRON adapter
The official in-process adapter, if a one-minute timer is all you needed.
Cron over REST
The endpoint every adapter posts: its auth, its responses and a curl call.
Queue tasks
One-off background work with retries, drained by the process-queue job.