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 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.)

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 — 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-Key HTTP header. The WS rpc transport (useMutation from 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 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: for GET the descriptor's input schema 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 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.
  • Also available per endpoint: scopes (extra API-key scopes), rateLimit, idempotent.
  • 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 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.