REST routes

defineRestRoute — public raw-HTTP endpoints (URL + JSON) for third parties that don't speak the rpc WebSocket. Schema-typed input/output, guards, deprecation/sunset, mounted on the same listener.

The five primitives above ride one WebSocket — great for your own UI, wrong for a third party calling your API with a plain URL + JSON body. defineRestRoute (from @voltro/protocol/rest) is the public raw-HTTP surface: a schema-typed request/response endpoint that desugars to exactly one HTTP route on the same listener the rpc server + plugin routes use. It is NOT a separate runtime.

// routes/v1/customers.list.route.tsx
import { defineRestRoute, requireScope } from '@voltro/protocol/rest'
import { Schema } from 'effect'

export default defineRestRoute({
  method: 'GET',
  path:   '/v1/customers',
  // Input shape is `{ query?, params?, body? }`. The desugar parses the
  // query string, path params, and JSON body into this, then decodes it.
  input:  Schema.Struct({
    query: Schema.Struct({
      limit:  Schema.optional(Schema.Number.pipe(Schema.between(1, 100))),
      cursor: Schema.optional(Schema.String),
    }),
  }),
  output: Schema.Struct({ data: Schema.Array(Customer), nextCursor: Schema.NullOr(Schema.String) }),
  summary: 'List customers',                  // OpenAPI metadata — inert until a generator consumes it
  guards:  [requireScope('customers:read')],  // → 403 when the subject lacks the scope
  handler: async ({ query }, ctx) => {
    const limit = query.limit ?? 20
    // ctx = { subject, headers, store }. The auth resolver populates
    // `subject`; `store` is the framework DataStore (cast at the call site).
    return { data, nextCursor }
  },
})

Registration

REST routes are NOT auto-discovered by file suffix — register them explicitly via restRoutes in app.config.ts (the *.route.tsx filename is convention, not magic):

import listCustomers from './routes/v1/customers.list.route'

export default {
  type: 'api' as const,
  name: 'publicApi',
  restRoutes: [listCustomers],
}

The module's default export is the descriptor — one descriptor per file, the same one-procedure-per-file discipline as *.query.ts / *.mutation.ts.

A REST path names the declared route, not every descendant. /me does not serve /me/season; that needs its own descriptor. Undeclared extra segments return 404 before input decoding, identity resolution or the handler runs. Named parameters still work, but matching is case-sensitive and includes the trailing slash: /me/ does not match a declaration of /me. This has applied since 0.70.0, including to hand-authored restRoutes. Use the declared URL in clients and ingress rules; do not assume the router creates alternate spellings. Raw plugin httpRoutes own subpaths by default, but a trailing slash alone is not a subpath. match: 'exact' restricts them to the declared path.

A path rejected by this dispatch boundary returns 404 with plain-text not found; it has not reached the REST descriptor's JSON error encoder. Clients must check the HTTP status and Content-Type before decoding JSON. Application errors from a matched REST route still use its declared format.

The request lifecycle (the desugar)

Each descriptor becomes one HTTP route. Per request, in order:

Step Outcome on failure
0. Declared path boundary 404 Not Found for undeclared descendants
1. Method gate 405 Method Not Allowed (Allow: header)
2. Sunset gate (past sunset date) 410 Gone + replacement pointer
3. Input decode ({ query, params, body } → schema) 400 Bad Request
4. Guards (after decode + ctx resolution) the guard's { status, message } (e.g. 403)
5. await handler(input, ctx) a thrown { status, message } → that status; anything else → 500
6. Output encode → JSON 200 OK

deprecated sets a Deprecation: true response header; sunset (an ISO date) sets a Sunset: header and, once past, flips the route to 410. Both are pure metadata otherwise — the route keeps working until the sunset date.

ctx — what the handler gets

interface RestRouteContext {
  readonly subject: Subject                              // resolved by the auth resolver
  readonly headers: Readonly<Record<string, string>>     // request headers
  readonly method: RestMethod                            // GET | POST | PUT | PATCH | DELETE | HEAD | OPTIONS
  readonly path: string                                  // the matched request path
  readonly query: string                                 // the raw query string
  readonly rawBody: Uint8Array                           // byte-exact body, before any decode
  readonly store: unknown                                // framework DataStore — cast at the call site
  readonly app?: AppContext                              // cache, KV, workflows, webhooks, typed events
}

rawBody is the body exactly as the transport received it. A JSON route reads its decoded input.body as before; rawBody is for the cases where parsing first would destroy what the handler has to authenticate — an HMAC over the literal bytes, a multipart payload, a protocol that is not JSON at all.

app is the same per-request application context a query, mutation or action receives, bound identically in development, in production serve, and in makeTestApp. A REST webhook can therefore publish a typed event or reach cache/KV/workflows without leaving the framework to do it. It is optional only because a unit test may construct a context without one.

The same AuthMiddleware/ConnectionInfoMiddleware that gate the rpc surface run here too, so a forwarded session cookie resolves the same Subject + tenant as the WebSocket path.

Guards

A RestGuard runs after input decode, before the handler. Return undefined to proceed, or { status, message } to short-circuit:

import { requireAuthenticated, requireScope, type RestGuard } from '@voltro/protocol/rest'

// Built-in: rejects 401 unless the caller is authenticated at all.
guards: [requireAuthenticated()]

// Built-in: rejects 403 unless the subject carries the scope (admin '*' bypasses).
guards: [requireScope('customers:read')]

// Custom guard:
const requireApiKey: RestGuard = (ctx) =>
  ctx.subject.type === 'apiKey' ? undefined : { status: 401, message: 'API key required' }

The two built-ins answer different questions, and the status codes say so. requireAuthenticated() asks who are you and answers 401 when the request carries no identity — the client's move is to authenticate and retry. requireScope() asks may you and answers 403 — the caller is known and retrying changes nothing. Collapsing them onto one status makes a missing session indistinguishable from an insufficient one.

Choosing status and headers — response() and restError()

A handler that returns its output value gets 200 and the framework's headers, which is what most routes want. Return response(body, { status, headers }) when the route has to say something more specific — a 201 with a Location, a 202 for work you accepted but have not finished, a cache directive.

Headers accept an array as well as a string, and that is the point: a login emits several Set-Cookie lines, and a single-string map cannot express them. Anything you set merges over the framework's own headers.

For failures, restError(status, body, headers) throws with your body shape. The plain throw { status, message } form still works and produces the framework's { message } envelope; restError is for when an established client expects a particular error contract — a code field it switches on, a field a form highlights — and re-shaping it would break that client.

import { defineRestRoute, response, restError } from '@voltro/protocol/rest'

export default defineRestRoute({
  method: 'POST',
  path: '/sessions',
  input: Schema.Struct({ email: Schema.String, password: Schema.String }),
  output: Schema.Struct({ userId: Schema.String }),
  handler: async ({ input, ctx }) => {
    const user = await signIn(input.body)
    if (!user) {
      // Your own error contract, verbatim — not the framework's envelope.
      throw restError(422, { code: 'INVALID_CREDENTIALS', field: 'password' })
    }
    return response(
      { userId: user.id },
      {
        status: 201,
        headers: {
          // An array becomes several lines of the same header.
          'Set-Cookie': [sessionCookie(user), csrfCookie(user)],
          Location: `/users/${user.id}`,
        },
      },
    )
  },
})

response() is recognised by a marker property, so an ordinary object your handler returns as data is never mistaken for a response envelope.

Methods — PATCH, HEAD and OPTIONS are first-class

method: accepts GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS, on REST routes and plugin HTTP routes alike. Two details:

  • HEAD is admitted wherever GET is (RFC 9110): a HEAD request runs the GET route's whole pipeline — method gate, guards, handler — and the transport drops the body. You never declare a second route for it.
  • A wrong method is still a precise 405, with an Allow: header naming exactly the methods mounted on that path — including when several routes share one path.

Body limits — maxBodyBytes

Every HTTP body read is capped at 8 MiB by default — POST /rpc (as it always was), plugin routes, REST routes and incoming webhooks. The app-wide cap is http.maxBodyBytes in app.config.ts (env override VOLTRO_MAX_BODY_BYTES); a route that legitimately takes more declares its own:

export default defineRestRoute({
  method: 'POST',
  path:   '/v1/import',
  maxBodyBytes: 64 * 1024 * 1024,   // this route only — the app cap stays 8 MiB
  // …
})

The same per-route override exists on an incoming webhook's handler (maxBodyBytes) — fat provider payloads are the normal case there, not the exception. Two details:

  • Routes that share one PATH share one body read. The body is read once for the whole group, so the widest maxBodyBytes override in the group applies to the group.
  • An oversized body answers 413 whether it announces itself (Content-Length) or arrives chunked — the counter cuts it at the cap and never buffers past it.

Conditional GET — etag: true

export default defineRestRoute({
  method: 'GET',
  path:   '/v1/customers',
  etag:   true,   // GET only — ignored elsewhere
  // …
})

The route stamps a weak, content-derived ETag (W/"<sha-1 of the encoded output>") on every 200, and answers a matching If-None-Match with 304 Not Modified — the tag, no body. Weak on purpose: the transport may vary the BYTES per content-encoding, but the representation is the same. (voltro start does the equivalent for the web app's HTML on its own — If-None-Match answers 304 for buffered 200s, with weak W/"md5" tags over the uncompressed body; no-store responses excepted.)

Caching at a CDN — cache

export default defineRestRoute({
  method: 'GET',
  path:   '/v1/pages/:slug',
  etag:   true,
  cache: {
    maxAge: 60,                       // seconds a CDN may serve it without asking
    staleWhileRevalidate: 300,        // …and serve it stale while it refetches
    tags: (output) => ['cms', `doc:${(output as { documentId: string }).documentId}`],
  },
  // …
})

A 200 to a request without a credential gets Cache-Control: public, max-age=60, s-maxage=60, stale-while-revalidate=300 and its tags as Cache-Tag (comma-separated, Cloudflare) and Surrogate-Key (space-separated, Fastly) — the headers a CDN purges by. tags receives the answer as it goes on the wire, and the input; a tag with whitespace or a comma is dropped. A request with Authorization or a cookie is answered private, no-cache and untagged, whatever the route declares: what a key or a session may see must never land in a shared cache. Every such answer says Vary: Authorization, Cookie; errors and other methods are untouched, and a 304 keeps the same headers. A publicApi projection declares the same thing as publicApi.cache, and @voltro/plugin-openapi documents the headers.

Idempotency (Idempotency-Key)

Set idempotency: true in app.config.ts and every mutating REST request (POST/PUT/PATCH/DELETE) that carries an Idempotency-Key header is deduplicated:

// app.config.ts
export default { type: 'api' as const, name: 'api', restRoutes: [chargeRoute], idempotency: true }
// or: idempotency: { header: 'Idempotency-Key', ttlMs: 86_400_000 }
# First call runs the handler + caches the response.
curl -XPOST https://api.example.com/v1/charge -H 'Idempotency-Key: pay-abc' -d '{"amount":99}'
#   → { "chargeId": "uuid-1", … }

# A retry with the SAME key replays the cached response — the handler does NOT run again.
curl -XPOST https://api.example.com/v1/charge -H 'Idempotency-Key: pay-abc' -d '{"amount":99}'
#   → { "chargeId": "uuid-1", … }   (+ response header `Idempotency-Replayed: true`)
  • Replay within the window returns the first response verbatim, with Idempotency-Replayed: true.
  • In-flight duplicate (the first call hasn't finished) gets 409.
  • Keys are scoped per (tenant, method, path), persisted in _voltro_idempotency, expiring after ttlMs (default 24h).

This is the Stripe-style contract — an external HTTP client opts in by sending the header. The same server-side idempotency store also covers WS mutations: useMutation supplies its own per-call key and may replay one interrupted call only after the runtime-capabilities endpoint has proved that idempotency is active. Scope + guarantee:

  • One server contract, two transports. REST clients send Idempotency-Key; useMutation carries the equivalent key in the RPC request. Actions are never replayed automatically. See Mutations: idempotency and safe replay.
  • Inbound webhooks already dedup via @voltro/plugin-webhooks (provider key + _voltro_webhook_*) — don't double-cover them.
  • Atomic claim, non-atomic completion. Two concurrent same-key requests resolve to exactly one execution (the UNIQUE(scope,key) insert is the arbiter). But the cached response isn't committed in the handler's own transaction — a crash between the handler committing and the record flipping to completed leaves the key in-flight (a retry 409s until the TTL lapses, then re-runs). REST handlers aren't auto-transactional, so this is the honest ceiling.

API versions — opt-in version: + the sunset flow

A route that will evolve declares its version instead of baking it into the path; version: 'v2' mounts under /v2/…:

// v2 — the current shape
export const listCustomers = defineRestRoute({
  method: 'GET',
  path: '/customers',
  version: 'v2',
  output: Schema.Struct({ data: Schema.Array(Customer), nextCursor: Schema.NullOr(Schema.String) }),
  handler: async (_i, ctx) => ({ data: await ctx.store.query(customers), nextCursor: null }),
})

// v1 — still mounted, deprecated, and gone on a date
export const listCustomersV1 = defineRestRoute({
  method: 'GET',
  path: '/customers',
  version: 'v1',
  deprecated: 'GET /v2/customers',   // Deprecation header + replacement pointer
  sunset: '2027-03-01',              // Sunset header; 410 Gone from this date
  output: Schema.Struct({ customers: Schema.Array(Customer) }),
  handler: async (_i, ctx) => ({ customers: await ctx.store.query(customers) }),
})

Two versions are two descriptors — the old one is ordinary code (visible, testable, deletable), not an entry in a transformation DSL. While it lives, responses carry Deprecation: true + Sunset:; past the date it answers 410 Gone with { version: 'v1', replacement: 'GET /v2/customers' }. Then you delete it. version is opt-in: a route without it keeps its literal path (no auto-prefix), and declaring version: on a path that already starts with /vN/ is refused at definition — both spellings at once is never intended. publicApi projections version the same way (spec.version, default v1), and the OpenAPI doc groups each version's operations under a version tag with x-voltro-api-version — one document, the /vN/ paths already separate them.

URI versioning only, on purpose. Header- and media-type-versioning (the NestJS options) are not supported: the OpenAPI document, cache keys and plain curl are all path-shaped, and a version a URL cannot express is a version a cached response cannot vary on. If an edge must accept Accept-Version: headers, rewrite them to the path prefix at the proxy.

And the rpc socket is deliberately outside this. The generated client is versioned with the server it was generated from — there is no /v2 for useMutation. Honest edge: a browser tab that stayed open across your deploy runs the PREVIOUS client until reload; that skew window exists, it is small, and URL versioning would not remove it.

Projecting an existing procedure — publicApi

You often want to offer an API you don't consume from your own frontend. When the procedure already exists as a query / mutation / action, you don't need to rewrite it as a REST route — annotate it with publicApi and the framework mounts ONE HTTP route that runs the same handler, under the same guards:

// queries/absenceRequests.list.query.ts
export default defineQuery({
  name:   'absenceRequests.list',
  input:  Schema.Struct({ status: Schema.optional(Schema.String), limit: Schema.optional(Schema.Number) }),
  output: Schema.Array(AbsenceRequest),
  guards: [requireScope('absences:read')],
  publicApi: {},   // → GET /v1/absenceRequests/list?status=open&limit=20
})
  • Method derives from the kind: query → GET, mutation / action → POST (override with method).

  • Path derives from the tag: /<version>/<tag-as-path> (override with path; set version).

  • Input binding follows the method: GET binds the descriptor's input to the query string; other methods bind remaining non-path fields to the body, decoded according to its Content-Type (JSON or URL-encoded form). Path-only inputs do not parse a body. Query strings and forms carry strings, so projections coerce them toward the input's encoded shape before decoding: limit=5 reaches a Schema.Number as 5, archived=true reaches a Schema.Boolean as true, and a repeated parameter (tag=a&tag=b) reaches a Schema.Array as two elements. JSON values are not coerced this way. A value that does not read as the target is left for the schema to refuse with its own 400.

  • Relations need nothing extra: eager loading is resolved server-side by the executor, so include works identically over HTTP.

  • Authorization is the same code as the WebSocket path — the declarative guards:, the row filter, and tenant scoping all run before the handler. A procedure that denies on the socket denies here. The row filter an app registered with setRowFilter is resolved for every REST request before the handler's context exists, projections and hand-written routes alike.

  • A presented and rejected credential is 401 on every projection, under openAccess too — the caller is unauthenticated, not anonymous; a caller who sent nothing reaches an open handler as anonymous.

  • Anonymous callers are refused with 401 unless the descriptor declares openAccess. A projection without an access decision used to let an anonymous request reach the handler, whose own refusal then surfaced as a 500.

  • A typed error is an answer, not a crash. A handler that fails with one of the descriptor's declared error: variants answers with the tag and its fields as the body — { "error": "AccessDeniedError", ...fields } — under the status errorStatus assigns that tag, 422 when it names none. The framework's ScopeError (a guard, requireScope, security.defaultDeny) answers 403, or 401 for an anonymous caller. An error the descriptor did not declare is still a 500.

    publicApi: { errorStatus: { AccessDeniedError: 403, NotFoundError: 404 } }
  • A plugin's cross-cutting error is an answer too. An error a mounted plugin merges into every procedure's wire union (errorSchemas — @voltro/plugin-ratelimit's RateLimited) answers with its tag and fields under the status the plugin declares for REST (429 with Retry-After and the RateLimit-* headers for a limited call), 422 when it declares none; a descriptor's own errorStatus entry for that tag wins. The projection used to encode through the descriptor's error: union alone, so the same rejection that decoded typed on the socket was a 500 here. A 500's reason is on the server log line (GET /v1/… → 500, with cause), never on the wire.

  • Path parameters bind to the input. A path with {teamId} (or :teamId) binds that segment to the input field of the same name — coerced toward the field's encoded shape like a query value — and the remaining fields come from the query string (GET) or the body (else). A segment that names no input field refuses the boot, naming the descriptor and the segment (path '/v1/bookings/:uuid' binds ':uuid', and the input declares no field of that name (fields: bookingId)). A bound field may repeat in the body or query string with the same value — a PATCH carrying the whole resource — but another value is a 400 naming the field, never a silent override. GET /v1/teams/{teamId}, the form every guideline writes first, is a projection like any other — and publicApi: { resource: 'teams' } derives it: a query whose input carries teamId (or id) → GET /v1/teams/{teamId}, a list → GET /v1/teams; a mutation whose target.op is insert → POST /v1/teams, update → PATCH /v1/teams/{teamId}, delete → DELETE /v1/teams/{teamId}; an action → POST /v1/teams/{teamId}/<verb>. An explicit method or path wins. Written out, the same projection:

export default defineQuery({
  name: 'teams.getById',
  input: Schema.Struct({ teamId: Schema.String, include: Schema.optional(Schema.Boolean) }),
  output: Team,
  error: Schema.Union(AccessDeniedError, NotFoundError),
  guards: [requireScope('teams:read')],
  publicApi: {
    path: '/v1/teams/{teamId}',                       // teamId from the path, include from the query string
    errorStatus: { AccessDeniedError: 403, NotFoundError: 404 },
    summary: 'One team by id',
    example: { response: { id: 'team_1', name: 'Core', createdAt: '2026-09-11T10:00:00.000Z' } },
  },
})
  • What the spec says about a projection (with @voltro/plugin-openapi): tags from the tag prefix (teams; override with publicApi.tags), an operationId from the tag (teamsGetById; override with publicApi.operationId), the bearer requirement on EVERY operation — security: 'required' unless the descriptor declares openAccess, then 'optional' (a bearer is used when sent, an anonymous caller is allowed), false to document none — one response per errorStatus entry with the error's own schema, 422 for the union's other tags, 429 for a mounted rate limiter, and publicApi.example: { request?, response? } with the same semantics as a defineRestRoute example.
  • Also available per endpoint: scopes (extra API-key scopes), rateLimit, idempotent, cache (see Caching at a CDN).
  • idempotency: covers these routes too, on the same terms as a hand-written restRoutes entry above: one binding, one Idempotency-Key header, one _voltro_idempotency table, and identical behaviour under voltro dev and voltro serve. Projected routes and hand-written ones go through the same single projection, so it is not possible for one to deduplicate and the other not to.

This pairs with crud.list: filter / paginate / sort / include on the executor plus publicApi: {} on the descriptor is a complete, filtered, paginated REST list endpoint in one declaration.

Use publicApi when the procedure already exists and its output is the response — path parameters and a hand-shaped URL are covered; use defineRestRoute when the response isn't the procedure's output, or the route has no procedure behind it.

Incoming webhook bodies. Non-GET projections decode application/json and application/*+json, or application/x-www-form-urlencoded. Form fields use the same schema-directed number/boolean/array coercion as query parameters; repeated keys remain arrays. JSON values retain their JSON types. No Content-Type defaults to JSON. Unsupported media types are ignored, not interpreted as JSON; missing required input fields still yield 400. If all declared input fields are bound by the path, the projection does not parse a body at all; GET likewise consumes only query and path. Consequently, an unrelated notification body cannot break a path-only callback. Path/body conflicts are checked on routes that actually consume a body. Malformed JSON, UTF-8 or form percent-encoding is 400 through the selected error profile (Problem Details under standard), without echoing the payload or parser exception. Hand-authored REST routes get the same parser refusal. For provider signature verification or custom media decoding, use defineRestRoute and ctx.rawBody; accepting a body is not proof that its sender is authorized. Existing transport body-size limits still apply.

Events are not inbound commands. publicApi projects queries, mutations and actions, not defineEvent. An event's guards decide who may listen; they do not authorize who may publish. Expose a separately guarded action that awaits ctx.events.publish(...) to accept an external trigger. Keep authorization, tenant selection and payload validation on that command. Its success confirms completion of the publisher call, not processing by every subscriber; do not describe it as a durable job acknowledgement. Use a hand-authored REST route if you need an explicitly shaped 202 response.

The wire format — one REST profile for the whole surface

What a projection puts on the wire is rpc's by default: epoch milliseconds for timestampMs, { "error": "AccessDeniedError", … } for a typed error, the bare value for success. Right for the app's own client; foreign to a consumer with a personal access token and a generated SDK. The api's app.config.ts chooses a profile for every projection, and a descriptor's publicApi.format overrides it for the one endpoint that must differ:

// apps/api/app.config.ts
export default {
  type: 'api' as const, name: 'api',
  publicApi: {
    profile: 'standard',          // 'rpc' (default — today's wire) | 'standard' | 'jsonapi' | a RestFormat object
    // building blocks a profile presets, each overridable:
    // dates: 'iso' | 'epoch-ms',  errors: 'problem-details' | 'tagged',
    // envelope: 'none' | 'data' | 'jsonapi',  pagination: 'link-header' | 'body-cursor',
    // naming: 'camelCase' | 'snake_case',  etag: boolean
  },
}
  • 'rpc' — the default, today's wire. Nothing changes for an app that says nothing.
  • 'standard' — the intersection of the Zalando, Google AIP and Microsoft guidelines: timestampMs fields as RFC 3339 strings (accepted on the way in too), every failure as RFC 9457 Problem Details (application/problem+json; type is urn:voltro:error:<Tag>, status from errorStatus, title from the tag, detail from the error's message, the error's other fields as extension members — the transport's own refusals, 400/401/403/404/410/500, included), no envelope, a cursor the handler returns in nextCursor as a Link: <…>; rel="next" header, a weak ETag on GET with If-None-Match → 304.
  • 'jsonapi' — { data, links } with application/vnd.api+json: type from the procedure's first source table, id from the row's id, the rest as attributes; errors as an errors array.
  • A RestFormat object is the custom case: encodeSuccess(value, ctx), encodeError(error, ctx) and describe(operation, ctx) for the spec, over the same building blocks. 'standard' and 'jsonapi' are such objects with a name.
  • Building blocks override whatever the profile preset: envelope: 'data' wraps success as { data, next? }; naming: 'snake_case' renames every struct key on the wire — input, output and path parameters (/v1/teams/{team_id}) — and back; pagination: 'body-cursor' leaves the cursor where the handler put it (cursorField, default nextCursor; cursorParam, default cursor).
  • The wire names its profile. Every projection answer — success and refusal — carries x-voltro-rest-profile: <name>, so a client generated for one profile can tell on its first call that it met another.
  • Examples in the schema's form. publicApi.example is written with a Date and a number where the schema has them; the projection encodes it through the same wire schema every response goes through, so it is right under every profile. An example already in wire form passes through; one that decodes against neither is a voltro doctor error.
  • Documentation as a policy. publicApi: { docs: 'required' } makes voltro doctor report a projection without publicApi.description and an input or output field without Schema.annotations({ description }) — the one text the spec, a collection, a generated client and the schema-driven forms all read.

The spec follows the profile without further declaration: format: date-time on the date fields, the Problem Details schema in the error responses with each typed error's type, Link only on outputs carrying the cursor field, and ETag, If-None-Match and a bodyless 304 on conditional GET operations. The snake-case names match the wire too. Date conversion is schema-driven and reaches everywhere the schema does — a field, a nested struct, an array, and the VALUES of a Record<string, timestampMs> alike. A plain Schema.String is not recognized or converted as a timestamp just because its value looks like ISO. Use timestampMs for conversion, or an explicitly annotated and validated string when the handler already returns ISO and you only need format: date-time in the spec. The profile does not validate an arbitrary string as a date.

Live updates over HTTP — stream: 'sse'

A third party that can't open your WebSocket can still follow changes: stream: 'sse' on a QUERY projects it as Server-Sent Events — the initial snapshot, then a delta per change, until the client disconnects.

export default defineQuery({
  name:   'orders.live',
  input:  Schema.Struct({ status: Schema.optional(Schema.String) }),
  output: Schema.Array(Order),
  guards: [requireScope('orders:read')],
  publicApi: { stream: 'sse' },   // → GET /v1/orders/live?status=open  (text/event-stream)
})
// any EventSource client — no Voltro SDK needed
const es = new EventSource('/v1/orders/live?status=open')
es.addEventListener('snapshot', (e) => setRows(JSON.parse(e.data).data))
es.addEventListener('delta',    (e) => applyDelta(JSON.parse(e.data)))

Each event's _tag becomes the SSE event: name, so a client listens per kind instead of switching on a payload field. The framing handles the details that bite otherwise: embedded newlines are split across data: lines (a raw \n would truncate the event), a retry: hint is sent, and a keep-alive comment goes out every 15s so proxies don't drop an idle stream.

Same guarantees as the WebSocket path, because it is the same code: the declarative guards:, the row filter and tenant scoping all run before anything is emitted, and the client's disconnect tears the subscription down (including a disconnect during setup). A guard denial arrives as one error event rather than an HTTP status — by then the response headers are already sent.

And they run before every event, not only before the first one: the guards are re-checked and the subject's row visibility is re-resolved from the unfiltered base descriptor per delivery, so a scope revoked while the EventSource is open ends the stream on the next event, and a membership that ends stops carrying those rows in the next delta. An open SSE stream is not a cheaper read path than a fresh GET.

stream: 'sse' on a mutation or action is ignored: there is nothing to subscribe to.

For a hand-written defineRestRoute, the same machinery is available directly — return sse((emit) => unsubscribe) from the handler and frame events with sseFrame(event, data) (both from @voltro/protocol/rest).

Binary downloads — bytes()

A handler that serves a file, an export or any non-JSON body returns bytes(stream, options) — imported beside defineRestRoute / sse:

import { defineRestRoute, bytes, requireScope } from '@voltro/protocol/rest'

export default defineRestRoute({
  method: 'GET',
  path:   '/v1/exports/:id',
  guards: [requireScope('exports:read')],
  handler: async ({ params }, ctx) => {
    const file = await locateExport(params.id)
    // Lazy thunk form — the source is opened only when the response streams.
    return bytes(() => openExportStream(file), {
      contentType: 'application/zip',
      contentLength: file.size,
      contentDisposition: `attachment; filename="${file.name}"`,
    })
  },
})
  • The first argument is a web ReadableStream<Uint8Array> — or the lazy thunk form () => ReadableStream, which defers opening the source until the response actually streams.
  • The server pipes without buffering — a body larger than the heap is fine (the guarantee is exercised with a 256-MiB stream), and byte streams are never compressed.
  • Everything before the handler still runs — method gate, sunset, input decode, guards — so a streaming route is exactly as gated as a buffered one.
  • On a plugin HTTP route the same shape is PluginHttpRouteResult.byteStream.

Idempotency × streams — decided

streaming: true on a method the idempotency binding claims (POST/PUT/PATCH/DELETE) is a mount error: a stream cannot cache a replayable body, so the idempotency claim could never complete — every retry would 409 until the TTL lapsed. The refusal names the two ways out: serve the stream on GET, or keep the idempotency binding away from the app's streaming routes. A handler that returns a stream without declaring streaming: true is caught at runtime instead — the claim is released so a retry re-processes.

No multipart parser — a declared boundary

There is no multipart parser on REST or webhook routes — multipart/form-data against /form/* answers 415, and a REST handler never sees parsed file parts. That boundary is deliberate — the framework parses no multipart for you. What the handler does get is the raw bytes, and this list of routes through is complete:

  • File uploads ride @voltro/plugin-storage's upload routes — a binary PUT plus a resumable, chunked upload with signed tickets. That is the sanctioned file path, not a workaround.
  • A provider that delivers webhooks as multipart (the Mailgun-inbound class) can be handled in the route itself: ctx.rawBody is the byte-exact body, so a handler may run a parser of its own choosing over it. The framework still ships none — picking a multipart parser means picking its limits and its CVE history, and that is your decision to make deliberately rather than one we make for every app. The provider's JSON delivery mode, where it offers one, remains the smaller move.

REST route vs Action

Both are unary request/response. Pick by transport + audience:

Action REST route
Transport rpc over WebSocket (+ POST /rpc) public raw HTTP at a URL you choose
Caller your own UI via useAction third parties with fetch / curl / SDKs
Wire shape the rpc JSON envelope plain JSON body + status codes
Typed errors Schema.TaggedError on the rpc channel HTTP status codes (throw { status, message })

Use an action when your own client calls it; use a REST route when an external system needs a stable, documented URL. For signed inbound webhooks (Stripe, GitHub, …) use @voltro/plugin-webhooks instead — it adds signature verification + idempotency on top.