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.
The request lifecycle (the desugar)
Each descriptor becomes one HTTP route. Per request, in order:
| Step | Outcome on failure |
|---|---|
| 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 store: unknown // framework DataStore — cast at the call site
}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 { requireScope, type RestGuard } from '@voltro/protocol/rest'
// 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' }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
HEADrequest 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 anAllow: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
maxBodyBytesoverride in the group applies to the group. - An oversized body answers
413whether 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.)
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 afterttlMs(default 24h).
This is the Stripe-style contract — the client opts in by sending the header. It's the standard for external API clients. Scope + guarantee:
- REST/HTTP only. It rides the
Idempotency-KeyHTTP header. The WS rpc transport (useMutationfrom your own frontend) has no per-call header — guard double-submit there with optimistic UI + a disabled button, not server idempotency. - 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 tocompletedleaves the key in-flight (a retry409s 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 withmethod). - Path derives from the tag:
/<version>/<tag-as-path>(override withpath; setversion). - Input binding follows the method: for
GETthe descriptor'sinputschema is bound to the query string, otherwise to the JSON body. So filter and pagination fields are plain URL params — no separate input shape. - Relations need nothing extra: eager loading is resolved server-side by the executor, so
includeworks 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. - Also available per endpoint:
scopes(extra API-key scopes),rateLimit,idempotent. idempotency:covers these routes too, on the same terms as a hand-writtenrestRoutesentry above: one binding, oneIdempotency-Keyheader, one_voltro_idempotencytable, and identical behaviour undervoltro devandvoltro 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 the derived URL is fine; use defineRestRoute when you need a hand-shaped URL, path params, or a response that isn't the procedure's output.
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, and this list of alternatives 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) needs, today, either a small parser proxy in front of the endpoint or the provider's JSON delivery mode where it offers one.
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.