HTTP
Synchronous REST endpoints, RFC 10008 QUERY, CRUD mounts, live SSE streams, and gate chains on Flow.
HTTP triggers bind web requests directly to Flows. Every standard REST verb is available alongside RFC 10008 QUERY for safe body reads, multi-verb CRUD mounts, and live Server-Sent Events (SSE).
For developers building APIs on okengine — declare the route, attach gates, return typed data.
The one rule
An HTTP trigger parses the request, checks attached gates, and invokes flow({ do }).
All business logic runs inside the Flow via fx. Declare in, out, and errors on
the HTTP bag when the route has a body, path params, or domain failures.
Smallest Example
Define the route
import { on, flow, http } from "okengine";
export const ping = on(
http.get().public(),
flow({
do: () => ({ status: "ok" }),
}),
);Call the endpoint
curl -X GET http://localhost:6530/ping -H "accept: application/json"Response:
{
"data": { "status": "ok" },
"error": null
}Omit path and name
Tree default: http.get() and flow({ do }) — no path or name strings. The
file stamps both. Pass either only for
control.
Progressive Patterns
Explore HTTP flow patterns from minimal handlers to schema-validated, error-handling, and gate-protected endpoints:
Return data directly with automatic JSON response enveloping and zero boilerplate:
import { on, flow, http } from "okengine";
export const health = on(
http.get().public(),
flow({
do: () => ({ ok: true }),
}),
);Method Reference
| Method | Signature | Purpose | Body Allowed | Idempotent |
|---|---|---|---|---|
http.get | http.get(path?) | Fetch a resource or list | No | Yes |
http.post | http.post(path?) | Create resource / command | Yes | No |
http.put | http.put(path?) | Replace entire resource | Yes | Yes |
http.patch | http.patch(path?) | Partial resource update | Yes | No |
http.delete | http.delete(path?) | Remove a resource | Optional | Yes |
http.query | http.query(path?) | Safe read with JSON body | Yes (RFC 10008) | Yes |
http.head | http.head(path?) | Retrieve response headers | No | Yes |
http.options | http.options(path?) | Discover allowed methods | No | Yes |
http.resource | http.resource(path, ops) | Five CRUD verbs; live when on | Verb-dependent | Verb-dependent |
http.live | http.live(signal) | Firehose SSE on GET /_oke/live/{name} | No | Yes |
Path Conventions
Default — omit the path. Tree files stamp the URL from disk (http.get()).
Pass a path only for control.
Control — explicit path — pass the URL template when the folder should not own the route:
http.get("/organizations/:orgId/members/:memberId");Pathless — omit so the compiler stamps from disk location:
import { on, flow, http } from "okengine";
// Stamped automatically to GET /users/:id · flow users.get
export const get = on(http.get(), flow({ do: async ({ id }) => ({ id }) }));Full file-tree rules: Routing.
Request Parsing
Before flow({ do }), the HTTP engine merges request parts into one object checked against
the trigger's in:
- Path parameters —
:paramsegments (e.g.{ id: "123" }). - Query string —
?sort=desckeys at the root. - JSON body — object fields merged into the same root.
- Headers & cookies — bags under
headers/cookiewhendoreads them (declare the same keys inin).
import { on, flow, http } from "okengine";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db, items } from "@/schema";
export const update = on(
http.patch({
// :id from the path merges with JSON body { title }
in: z.object({
id: z.string(),
title: z.string().min(1),
}),
}),
flow({
do: async ({ id, title }, fx) => {
await fx.store(db).update(items).set({ title }).where(eq(items.id, id));
return { id, title };
},
}),
);Read request metadata by naming headers / cookie in in and destructuring them in do
(header names are lower-cased):
import { on, flow, http } from "okengine";
import { z } from "zod";
export const create = on(
http.post({
in: z.object({
name: z.string().min(1),
headers: z.object({
"content-type": z.string().optional(),
"x-request-id": z.string().optional(),
}),
cookie: z.object({
sid: z.string().optional(),
}),
}),
}),
flow({
do: async ({ name, headers, cookie }, fx) => {
return {
id: fx.id(),
name,
contentType: headers["content-type"] ?? "application/octet-stream",
session: cookie.sid ?? null,
};
},
}),
);HTTP Methods
Each verb binds with on(http.<method>(), flow({…})). Omit the path on tree
files; pass one only for control.
Fetch a resource or collection:
import { on, flow, http } from "okengine";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db, notes } from "@/schema";
export const get = on(
http.get({
in: z.object({ id: z.string() }),
out: z.object({ id: z.string(), title: z.string() }),
errors: { NotFound: z.object({ id: z.string() }) },
}),
flow({
do: async ({ id }, fx) => {
const [note] = await fx.store(db).select().from(notes).where(eq(notes.id, id));
if (!note) return fx.fail("NotFound", { id });
return note;
},
}),
);Resources
Detailed section
If you only need the basic mount, jump to the example below. on(http.resource(path, ops)) takes
no Flow as a second argument — .all() is the bag; options live on store.resource. A second
argument throws on(http.resource(...)) takes no second argument.
http.resource(path, ops) mounts five CRUD Flows in one on() call. Pass
store.resource(…).all() or any bag with list · create · get · update · remove.
Chain .gate(...) / .public() once — every verb (and live, when present) gets the same gates.
store.resource builds the five Flows. The factory registers no routes.
import { store } from "okengine";
import { z } from "zod";
import { db, notesTable } from "@/schema";
export const notesResource = store.resource(db, notesTable, {
in: z.object({ title: z.string().min(1) }),
out: z.object({ id: z.string(), title: z.string() }),
});The URL id segment is always :id. Update is PATCH, not PUT. There is no
pathless http.resource() — pass an explicit base path.
| Op | Method | Path | Typical status |
|---|---|---|---|
list | GET | /notes | 200 + { data, error, meta } |
create | POST | /notes | 201 Created (fx.json.create) |
get | GET | /notes/:id | 200, or NotFound |
update | PATCH | /notes/:id | 200, or NotFound |
remove | DELETE | /notes/:id | 204 No Content (fx.json.empty) |
live | GET | /notes/live | SSE — only when live is on |
Third argument to store.resource(db, table, options):
| Option | Type | Default | Meaning |
|---|---|---|---|
in | Schema | (required) | Create body (POST) |
out | Schema | (required) | Item shape (get / list / update return) |
update | Schema | in | Patch fields. Wire body is { id, ...patch } |
idSchema | Schema | update/in + { id: string } | Replaces the update Flow in when set (include the id key) |
errors | error map | { NotFound } | Typed failures on get / update / remove |
id | column | table PK | Column bound to :id |
list | object | see List Options | List query grammar (GET /notes) |
breaking | boolean | false | Marks the five Flows breaking: true (handwritten → resource migration) |
live | boolean | omitted | Live query surface; see Resource Live |
Nested on store.resource(…, { list: { … } }). Search / filter / order / select
use a column scope: "all" · column array · "none".
| Option | Type | Default | Meaning |
|---|---|---|---|
mode | "cursor" | "offset" | "cursor" when cursor is set, else "offset" | Pagination |
cursor | columns | [] | Keyset columns |
direction | "asc" | "desc" | "desc" | Default sort when no ?order= |
limit | number | 20 | Default page size |
maxLimit | number | 100 | Cap on ?limit= |
count | "exact" | "none" | "exact" | Offset-only COUNT(*) |
search | column scope | "none" | ?search= / ?q= |
filter | column scope | "none" | ?col=eq.x grammar |
order | column scope | cursor columns, else "all" | ?order= |
select | column scope | "all" | ?select= projection |
| Member | Kind | Meaning |
|---|---|---|
all() | method, no args | Bag for http.resource(path, notesResource.all()) — five Flows, plus live when enabled |
list · create · get · update · remove | Flow | One verb. Bind with http.get / http.post / http.patch / http.delete |
page(input) | method | Compile list-query input for a handwritten fx.store(db).page |
live | { signal, flow }? | Live surface when live: true (or the project default drained on) |
A sixth route appears only when the resource is live. It is not a signal firehose — each subscriber gets classified row events (RLS + list filters).
live on the resource | Result |
|---|---|
{ live: true } | Mount GET <path>/live now |
| omitted | Mount only if oke({ store: { live: true } }) |
{ live: false } | Never mount live for this resource |
table store.schema.live(false) | Opts that table out of the project default. { live: true } on the resource still wins. |
const notesResource = store.resource(db, notesTable, {
in: z.object({ title: z.string().min(1) }),
out: z.object({ id: z.string(), title: z.string() }),
live: true,
});
export const notes = on(http.resource("/notes", notesResource.all()).gate(member));Consequence: GET /notes/live rides the same .gate(...) chain as list/get.
Query-string filters use the resource list grammar; pagination cursors do not gate membership — a row enters or leaves the window when filters / RLS change.
Wire events (consumed with useLiveQuery on the typed client):
kind | Meaning |
|---|---|
upsert | Row visible under stamp + query — merge by primary key |
revoked | Row left visibility (reason: "rls" or "query") — remove |
delete | Row deleted — remove |
Live queries need an RLS-capable SQL driver (postgres / pglite) and a gated
identity on the request. Extract fails without a primary key:
extract: live: true on table "notes" requires a primary key column (upsert/revoked/delete address rows by PK)Missing updatedAt / updated_at, or no RLS policies, warn at extract — they
do not fail the build.
http.resource always mounts all five CRUD keys. To expose only some verbs,
bind those Flows on individual triggers. To replace one verb, spread .all()
and override that key — the other four stay:
import { on, http, store } from "okengine";
import { z } from "zod";
import { member } from "@/core/gate";
import { db, notesTable } from "@/schema";
const notesResource = store.resource(db, notesTable, {
in: z.object({ title: z.string().min(1) }),
out: z.object({ id: z.string(), title: z.string() }),
});
export const list = on(http.get().gate(member), notesResource.list);export const get = on(http.get().gate(member), notesResource.get);Override one verb on a resource mount — path is required on http.resource:
import { on, flow, http } from "okengine";
import { eq } from "drizzle-orm";
export const notes = on(
http
.resource("/notes", {
...notesResource.all(),
remove: flow({
do: async ({ id }, fx) => {
await fx
.store(db)
.update(notesTable)
.set({ archived: true })
.where(eq(notesTable.id, id));
return fx.json.empty();
},
}),
})
.gate(member),
);A handwritten bag works the same way — each value must be a flow(…):
on(
http.resource("/notes", {
list: flow("notes.list", { do: () => [] }),
create: flow("notes.create", { do: () => ({ id: "n1" }) }),
get: flow("notes.get", { do: () => ({ id: "n1" }) }),
update: flow("notes.update", { do: () => ({ id: "n1" }) }),
remove: flow("notes.remove", { do: (_, fx) => fx.json.empty() }),
}),
);Missing or non-Flow keys throw on(http.resource(...)) expects the five CRUD FlowDefs.
A GET /notes you also declared by hand collides at boot (OKE1041).
Live Streams
Detailed section
If you only need the basic firehose, jump to the example below. .live(…) is GET-only —
on(http.post("/x").live(signal)) throws on(http.*.live(signal)): live exposure must be GET.
http.live(signal) is one-arg on() — the engine synthesizes the stream Flow
(fx.live + effects.reads: ["signal:<name>"]). Chain .gate(...) like any GET.
import { on, http, signal } from "okengine";
import { z } from "zod";
import { member } from "@/core/gate";
export const orderStatus = signal.live("order-status", {
optional: true,
schema: z.object({
orderId: z.string(),
status: z.enum(["placed", "fulfilling", "shipped"]),
}),
});
export const firehose = on(http.live(orderStatus).gate(member));curl -N http://localhost:6530/_oke/live/order-status \
-H "accept: text/event-stream" \
-H "authorization: Bearer …"Response Content-Type is text/event-stream. Frames are JSON data: lines
(optional id: for resume), then data: [DONE].
Three GET shapes expose a live SSE body. Pick the physics first, then the path.
| Declaration | Path | Physics |
|---|---|---|
on(http.live(signal)) | GET /_oke/live/{name} | Signal tape — every event |
on(http.get(path).live(signal)) | Your path | Signal tape — auto-match on :params |
on(http.get(path).live(table), flow) | Your path | Live query — liveQuery(fx, table, input) |
store.resource({ live: true }) + http.resource | GET <path>/live | Same live-query physics as .live(table) |
Signal names in the default firehose path are encodeURIComponent'd
(chat.message stays readable; slashes in internal names are escaped).
Path params become a filter: an event is forwarded when each :param that
exists on the payload equals the request value. Params missing from the
payload are skipped (the event still flows). No params = firehose.
import { on, http } from "okengine";
import { member } from "@/core/gate";
import { orderStatus } from "@/signals/orders";
export const events = on(http.get("/orders/:orderId/events").gate(member).live(orderStatus));GET /orders/ord_1/events receives { orderId: "ord_1", status: "shipped" }
and drops events for other orders.
Pass your own Flow as the second argument to on() when auto-match is not
enough. Return fx.live(signal, { match }) from do — do not wrap it with
fx.json.stream.
import { on, flow, http } from "okengine";
import { member } from "@/core/gate";
import { orderStatus } from "@/signals/orders";
export const vipFeed = on(
http.get("/orders/vip/events").gate(member).live(orderStatus),
flow("orders.vipFeed", {
do: (_input, fx) =>
fx.live(orderStatus, {
match: (payload) => payload.status === "shipped",
}),
}),
);Consequence: a custom Flow stamps a distinct match key, so it can coexist with the auto-match route for the same signal (different path). Two synthesized firehoses that share signal and gates fail uniqueness — see Uniqueness.
For a handwritten list that should stream the same classified CDC as
store.resource({ live: true }), bind the table on GET and open the window
with liveQuery:
import { on, flow, http, liveQuery } from "okengine";
import { member } from "@/core/gate";
import { tasks } from "@/schema";
export const tasksLive = on(
http.get("/tasks/live").gate(member).live(tasks),
flow("tasks.live", {
do: async (input, fx) =>
liveQuery(fx, tasks, input, {
filter: [tasks.status],
search: [tasks.title],
order: "all",
}),
}),
);Same driver, identity, and extract guardrails as Resource Live. Prefer
http.resource + { live: true } when you already mount the five CRUD ops.
Boot keys each live HTTP route as (signal, gates, match). Match is the
sorted path-param names, or custom:<flow> when you passed a Flow, or
(firehose) when there are no params.
| Pair | Boots? |
|---|---|
Member :orderId + admin firehose | Yes — gates and match differ |
| Same params, different gates | Yes — the client disambiguates with via |
| Two member firehoses on different paths | No — OKE1050 |
| Same method + path twice | No — OKE1041 first |
OKE1050 cause: Live signal "{signal}" is exposed twice with the same gates ({gates}) and match ({match}).
Fix: a different gate, a path-param filter, or drop the extra route.
signal.live is HTTP SSE. for await stays on the server; the browser
uses a callback.
The client picks the unique exposure whose matchKey fields are a subset of
the input, preferring the largest match ({ orderId } beats firehose). A tie
needs via: "unit.flow".
const stop = api.live(
orderStatus,
{ orderId: "ord_1" },
{
onEvent: (event) => {
/* { orderId, status } */
},
onError: (err) => {
/* 4xx, envelope, or drop */
},
autoResubscribe: false,
},
);
stop();api.orders.events({ orderId }, { onEvent }) is the same shape on the exposing
Flow. Reconnects send Last-Event-ID from the last id: received.
A 410 LiveResumeGap (OKE1210) means that cursor is gone — drop it
and replay the remaining tape (autoResubscribe: true).
Resource live queries use useLiveQuery (snapshot + classified events), not
api.live. See Client · Live.
Trigger Modifiers
Every HTTP trigger supports fluent modifier chaining before binding to on().
Resource mounts accept .gate(...) and .public() only — live on a resource
comes from store.resource({ live: true }), not .live().
Gates — attach policy and rate handles. They evaluate in declaration order; first denial wins:
import { gate } from "okengine";
import { member } from "@/core/gate";
http.post().gate(member, gate.scope("editor"), gate.rate({ max: 100, per: "1m", keyBy: "user" }));Public — explicitly marks the endpoint as open without authentication:
http.get().public();Response Envelopes
Every HTTP flow returns the same envelope shape. You choose status and optional meta — not a
custom wrapper.
Envelope is fixed
Success and failure always use { data, error } (optional top-level meta). There is no API to
replace that shape. Use fx.json.* for status codes and meta; use fx.fail for typed errors.
Success — returning a value from do produces 200 OK:
{ "data": { "id": "123" }, "error": null }Returning undefined produces a 204 No Content response with an empty body.
Custom status — fx.json.create for 201 Created, or fx.json.ok with optional meta:
return fx.json.create({ id: "ord_1" });
// or
return fx.json.ok({ id: "ord_1" }, { meta: { traceId: fx.runId } });Typed failures — fx.fail(code, data) formats the error envelope and maps status:
return fx.fail("NotFound", { id: "123" });{
"data": null,
"error": {
"code": "NotFound",
"message": "Resource not found",
"data": { "id": "123" }
}
}Standard status code mappings:
ValidationError→422 Unprocessable EntityUnauthorized→401 UnauthorizedForbidden→403 ForbiddenRateLimited→429 Too Many Requests- Custom error codes →
400 Bad Request
Troubleshooting
No Flow is bound to that method + path. Check the explicit path, or for pathless routes the
file-tree stamp (src/flows/users/[id]/get.ts → GET /users/:id). A bare 404 with body Not Found means the router found no match.
The path exists but has not been bound to the requested HTTP verb. The response contains an
Allow header listing valid methods for that path.
RFC 10008 requires Content-Type: application/json for http.query requests. Ensure your client
sends this header with a valid JSON payload.
The merged input payload failed validation against the trigger's in schema. Check the
error.data.issues array for the specific field validation failure.
Cross-origin access is closed until you plug the cors plugin with an
explicit origin. Same-origin calls need no CORS headers. Preflight OPTIONS is answered by the
plugin even when the path is bound to other methods.
The ops bag already holds the five Flows. Call on(http.resource("/notes", notesResource.all()).gate(member)) — do not pass a flow(...) as the second argument.
The bag must include list, create, get, update, and remove, each a flow(...). To
expose fewer verbs, bind those Flows on http.get / http.post yourself instead of
http.resource.
Cause: {method} {path} is bound twice (flow "{flow}"). A resource mount plus a handwritten
http.get("/notes") (or two mounts on the same base path) collide. Drop one binding.
Live SSE feeds declared via .live(signal) can only be attached to GET triggers
(http.get(...) or http.live(...)). Other verbs reject live stream synthesis.
Cause: Live signal "{signal}" is exposed twice with the same gates ({gates}) and match ({match} ). Two firehoses (http.live or param-less .live) that share the signal and gates cannot boot.
Change the gate, add a path-param filter, or remove a route.
Cause: Cursor "{afterId}" missing on "{signal}". That Last-Event-ID is gone from the tape.
Reconnect without it; remaining events replay. autoResubscribe: true does this after backoff.
Extract: live: true on table "…" requires a primary key column. Runtime: live query for "…" requires an RLS-capable SQL driver (postgres / pglite) or requires a gated identity. Attach
.gate(...) and declare a PK.
Learn more
- Store —
store.resource, list query grammar, SQL facet - Signal · Live —
signal.livetapes - Client —
api.live,useLive,useLiveQuery - fx —
fx.live,fx.json.stream,fx.json.create - Gate —
.gate(...)/.public()on triggers - Errors — OKE1041 · OKE1050 · OKE1210