Documentation

Tasks

A task is a route that runs off the request path — on a schedule, on demand, or enqueued from your own code. Tasks get typed inputs and automatic retries with backoff. ctx.step supports replay from recorded outputs; restart-durable delivery and waits currently require the managed broker. Standalone Node runs use in-process state and timers.

This is the API reference. For a step-by-step walkthrough, start with the Background task rung.

// src/api/cleanup.ts
export const route = { kind: 'task', retries: 2, timeout: 60_000 };
export const schedule = '0 3 * * *'; // nightly at 03:00 UTC

export async function POST(ctx: { attempt: number; input: unknown }) {
  const deleted = await db.deleteExpiredSessions();
  return { deleted };
}

A task is a file in src/api/ with kind: 'task'. It is not an HTTP endpoint — it's invoked through the task surface below.


Route config

Set the kind (and optional retry/timeout) with an exported route object. Use the kind shorthand when you have no other config.

export const route = { kind: 'task', retries: 2, timeout: 60_000 };
// or, with no config:
export const kind = 'task';
Field Type Default Effect
retries number 0 Extra attempts after the first failure. retries: 2 → up to 3 attempts.
timeout number (ms) 30000 Per-attempt timeout before the attempt fails (and retries, if any remain).

Between attempts Vura applies exponential backoff: 100 × 2^(attempt−1) ms, capped at 30 seconds.


Schedule

Export a schedule — a standard five-field cron expression — to run the task automatically. It can be a top-level export; it does not need to live inside route.

export const schedule = '*/15 * * * *'; // every 15 minutes

When a task has a schedule, the cron engine starts with the server. Scheduled runs receive { _cron: true } as ctx.input and skip input validation. Set VURA_DISABLE_IN_PROCESS_CRON=1 to turn off the in-process scheduler (e.g. when an external scheduler drives runs).


Typed input

Export an input schema to validate the payload before the handler runs. Use defineSchema or any Zod-compatible schema.

import { z } from 'zod';

export const input = z.object({
  olderThanDays: z.number().int().positive(),
  dryRun: z.boolean().default(false),
});

Validation runs on every payload-bearing path — the CLI, the /__tasks/<name> trigger, and the dev server — before the handler. A failure responds 400 with { error, code: "VALIDATION_FAILED", details }, never invokes the handler, and consumes no retry attempt. Scheduled (cron) runs use synthetic input and are exempt.


The handler context

export async function POST(ctx) {
  ctx.attempt; // 1-based attempt number
  ctx.input;   // validated payload (or { _cron: true } for scheduled runs)
  ctx.step;    // durable-execution API — see below
}

enqueue()

Fire a task from anywhere in your app — a request handler, another task, a hook.

import { enqueue } from '@celsian/vura-core';

await enqueue('cleanup', { olderThanDays: 30 }, {
  delaySeconds: 60,
  idempotencyKey: 'nightly',
});
enqueue(taskName: string, payload?: unknown, opts?: EnqueueOptions): Promise<EnqueueResult>
Option Type Effect
delaySeconds number Delay before the task runs. Durable only on the platform.
idempotencyKey string Key the platform uses to de-duplicate enqueues.

enqueue() returns the run record ({ runId, status }; extra fields preserved) and throws on a network or non-2xx failure — it never swallows errors.


Durable steps — ctx.step

Long tasks can use step memoization to reuse recorded outputs on replay. With the managed broker persisting completed steps and re-dispatching runs, waits can suspend without keeping a process alive. Standalone Node does not provide that persistence or re-dispatch loop. Memoization is not an exactly-once guarantee for external side effects: an effect may succeed before its checkpoint is persisted. Use provider idempotency keys or transactional deduplication for payments, emails, and other non-idempotent writes.

export async function POST({ input, step }) {
  const profile = await step.run('load', () => db.users.find(input.userId));
  const enriched = await step.waitForTask('enrich', 'tasks.enrich', profile);
  await step.sleep('cooldown', 24 * 60 * 60);
  await step.run('welcome', () => sendEmail(profile.email));
  return { enriched };
}

The one rule: side effects go inside step.run

The handler body re-runs from the top on every re-invocation. When a run resumes after a wait, Vura calls your handler again from the first line; each step.* call replays its recorded result until execution reaches the next unmet wait. So anything with a side effect — a write, an email, a charge — must live inside a step:

await chargeCard();                     // ❌ runs on every replay — double charge
await step.run('charge', chargeCard);   // reuses a recorded result; chargeCard must be idempotent

Reads and pure computation are safe to leave in the body.

Step API

Call Signature What it does
step.run run<T>(key, fn): Promise<T> Runs fn when no recorded result exists. Replays reuse the recorded output; external effects still need idempotency.
step.enqueue enqueue(key, task, payload?, opts?): Promise<{ runId }> Memoized enqueue. Fire-and-forget.
step.waitForTask waitForTask(key, task, payload?, opts?): Promise<ChildRunResult> Enqueues a child and waits for its { ok, result?, error? }. A child failure is returned, never thrown.
step.sleep sleep(key, seconds): Promise<void> Durable wait with the managed broker; in-process timer otherwise.
step.sleepUntil sleepUntil(key, date): Promise<void> Sleep until an absolute instant (Date or ISO string).
step.waitForToken waitForToken<T>(key, { timeoutSeconds? }): Promise<{ payload?: T } | { timedOut: true }> Wait for an externally-completed token.

Every key must be unique within one run — reusing a key throws a DuplicateStepKeyError. Keys are how replays line up recorded outputs with step.* calls.

Suspend / resume

When your handler hits a waitForTask / sleep / sleepUntil / waitForToken that isn't already satisfied, the step throws an internal SuspendSignal that unwinds the handler and suspends the run. This is not a failure and consumes no retry attempt — the run envelope reports ok: true with a suspended waitpoint and the steps completed so far. On the platform, when the waitpoint completes, Vura re-dispatches the same run with the accumulated steps map, and your handler replays to the next wait or to completion.

Local dev and standalone self-hosting

Under vura dev (and any self-hosted run with no platform), there's no durable queue, so waits resolve best-effort in-process:

Vura logs a one-time note the first time a durable wait runs locally. Deploy on Vura for real suspend/resume that survives restarts.


Running and inspecting tasks

CLI

vura tasks list                          # list task routes and schedules
vura tasks run cleanup                    # run once, synchronously, print the envelope
vura tasks run cleanup --input '{"dryRun":true}'

See the CLI reference for full flags.

HTTP surface

Task names derive from the file path: /api/reportreport, /api/jobs/notifyjobs.notify.

Request Effect
GET /__tasks List registered task routes.
POST /__tasks/<name> Enqueue a run; responds 202 { id, status: "running" } immediately (does not wait).
GET /__tasks/<id> Poll a run's job — includes ok, attempts, and any suspended waitpoint.

In production, /__tasks/* requires Authorization: Bearer $THEN_TASK_SECRET. In dev with no secret set, localhost requests are allowed without a token.

curl -s -X POST http://localhost:3000/__tasks/cleanup \
  -H "Authorization: Bearer $THEN_TASK_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"dryRun":true}'

Run envelope

Every execution produces an envelope carrying the full retry history:

{
  ok: boolean;          // true unless the run failed
  taskName: string;     // dot-form name, e.g. "cleanup"
  attempts: Array<{
    index: number;      // 1-based attempt number
    startedAt: string;  // ISO timestamp
    durationMs: number; // wall-clock duration
    error?: string;     // message-only error when the attempt failed
  }>;
  result?: unknown;     // present when ok is true
  suspended?: { stepKey: string; waitpoint: Waitpoint }; // present when a step suspended the run
  steps?: Record<string, { status: 'completed' | 'timed_out'; output?: unknown }>;
}

error is always the message only — never a stack trace.


Where tasks run

Target Behavior
Node / VPS, Docker, Fly, Railway Tasks run inside the same server process. The in-process cron engine fires schedules. No external queue or worker needed.
Cloudflare Workers Cron schedules are wired to the Worker's scheduled event via generated wrangler.toml triggers.
Vura Platform Broker integration for at-least-once delivery, durable delaySeconds, and ctx.step suspend/resume; requires managed-service access.

See Route kinds and the Adapters reference for the full per-target picture.