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