Routing
File-tree stamps for HTTP paths, Flow names, and client units from on(http.*, flow) under src/flows.
HTTP routes are inferred from where a Flow file lives. Put http.get() in
src/flows/notes/[id]/get.ts and the compiler stamps GET /notes/:id, names the
Flow notes.get, and the client calls api.notes.get({ id }).
The one rule
On a tree file, omit path and name: on(http.get(), flow({ do })). The file
tree stamps both. Pass either only for control — barrels, a URL that must not
follow the folder, or a stable name for fx.call. Explicit always wins.
When to omit · when to pass
| Omit (default) | Pass explicitly | |
|---|---|---|
| HTTP path | Tree file under src/flows/<unit>/ — http.get(), http.post(), … | Barrel index.ts; a public URL that must not match the folder; http.resource(path, ops); custom live SSE path |
| Flow name | Same tree file — flow({ do }) stamps unit.export | Barrel (optional — export still stamps); stable name across moves; call-only Flow you fx.call by name |
Consequence: most app code looks like the create-oke template —
http.get().public() + flow({ … }) — with no string path and no string name.
Smallest Example
Place the file in the tree
oke dev / oke build regenerate src/flows/generated.ts. Import it before
oke() so pathless triggers receive their stamp:
import "@/flows/generated";
import { oke } from "okengine";
export const app = oke({ name: "notes" });Use pathless http.get()
The tree stamps GET /notes/:id. :id merges into in. The export name is the
client method (api.notes.get):
import { on, flow, http } from "okengine";
import { z } from "zod";
export const get = on(
http.get({
in: z.object({ id: z.string() }),
out: z.object({ id: z.string() }),
}),
flow({
do: async ({ id }) => ({ id }),
}),
);Call the endpoint
curl -X GET http://localhost:6530/notes/n_1 -H "accept: application/json"Response:
{
"data": { "id": "n_1" },
"error": null
}Method is not the filename
list.ts does not become GET by itself. Reserved leaves only omit a URL segment. Bind
http.get(), http.post(), http.patch(), or http.delete() yourself — see Reserved
Leaves.
Progressive Patterns
From a pathless tree file to an explicit barrel, an action leaf, and a catch-all:
One file per route. Skip the path argument. The generated barrel calls
stampHttpPath / stampFlowName after import:
import { on, flow, http } from "okengine";
export const list = on(
http.get(),
flow({
do: () => [],
}),
);Stamped to GET /notes, Flow notes.list, client api.notes.list().
Convention Reference
| Convention | File | Stamped route | Client |
|---|---|---|---|
| Dynamic param | notes/[id]/get.ts + http.get() | GET /notes/:id | api.notes.get({ id }) |
| Reserved leaf | notes/list.ts + http.get() | GET /notes | api.notes.list() |
| Action leaf | notes/[id]/archive.ts + http.post() | POST /notes/:id/archive | api.notes.archive({ id }) |
| Catch-all | docs/[...slug]/get.ts + http.get() | GET /docs/* | api.docs.get({ "*": "a/b" }) |
| Route group | notes/(ops)/archive.ts | /notes/archive | api.notes.archive |
| Root unit | main/health.ts + http.get() | GET /health | api.main.health() |
| Folder root | main/route.ts + http.get() | GET / | api.main.root() |
Units
The first folder under src/flows/ is the client unit. Nested folders and
the leaf file build the URL. The export const name is the method on that unit.
src/flows/
├── notes/ # Unit: notes → api.notes.*
│ ├── list.ts # GET /notes (reserved leaf)
│ ├── create.ts # POST /notes (reserved leaf)
│ ├── shapes.ts # skipped (not a route)
│ └── [id]/
│ ├── get.ts # GET /notes/:id
│ └── archive.ts # POST /notes/:id/archive
├── billing/
│ └── (checkout)/ # omitted from the URL
│ └── charge.ts # POST /billing/charge (if http.post())
└── main/ # Unit: main — prefix omitted from the URL
├── health.ts # GET /health
└── route.ts # GET /A file sitting directly in src/flows/ (no unit folder) is not a route.
Unit folder names must be valid JS identifiers (notes, main, _ allowed
inside; my-notes is skipped). Folders starting with _, [, or ( are not
units.
Consequence: export const getNote from get.ts is api.notes.getNote, not
api.notes.get. Match the export to the name you want on the client.
Path Conventions
Default — pathless. Omit the path so generated.ts stamps the URL from disk:
http.get(); // pending until stampHttpPath runsControl — explicit path. Pass the URL template when the folder should not own the route (barrel, public API shape, resource mount). The tree never overwrites an explicit path:
http.get("/organizations/:orgId/members/:memberId");Path params, query string, and JSON body still merge into one object checked by
in — same order as HTTP · Request Parsing.
Dynamic parameters
[id] → :id. The folder name is the param key. Declare the same key on in:
import { on, flow, http } from "okengine";
import { z } from "zod";
export const get = on(
http.get({ in: z.object({ orgId: z.string(), memberId: z.string() }) }),
flow({
do: async ({ orgId, memberId }) => ({ orgId, memberId }),
}),
);Stamped to GET /orgs/:orgId/members/:memberId.
Catch-alls
[...slug] → * (greedy rest). Optional [[...slug]] is not supported —
generate fails with Optional catch-all "[[...slug]]" is not supported — use [...slug] (param is "*").
A route that includes * selects the Trie matcher automatically (the compiled
RegExp matcher cannot express wildcards).
Route groups
A folder (ops) is omitted from the URL. Use it to group files without adding a
segment:
src/flows/notes/(ops)/archive.ts → /notes/archive(ops) never enters the Flow name either (notes.archive).
The main unit
main is omitted from the URL prefix. Flow names still use main.:
| File | URL | Flow name |
|---|---|---|
main/health.ts | /health | main.health |
main/route.ts | / | main.root (from export const root) |
main/index.ts | / (extract) | barrel — pass http.get("/") |
Skip list
These files are never routes (walk skips them; path inference returns nothing):
| Pattern | Why |
|---|---|
generated.ts | Adopt barrel (oke dev / oke build writes it) |
shapes.ts | Shared Zod / contracts |
signals.ts | Signal declarations |
*.test.ts / *.test.tsx | Tests |
_ prefix (file or folder) | Private helpers (notes/_lib/util.ts) |
Skip-list files may sit next to tree routes. They do not turn a tree into a barrel.
Reserved Leaves
To avoid /notes/get and /orders/list, these filenames add no URL
segment — the same five CRUD names as http.resource, plus folder roots:
| Leaf | Typical trigger | Example file | Stamped path |
|---|---|---|---|
list | http.get() | notes/list.ts | /notes |
create | http.post() | notes/create.ts | /notes |
get | http.get() | notes/[id]/get.ts | /notes/:id |
update | http.patch() | notes/[id]/update.ts | /notes/:id |
remove | http.delete() | notes/[id]/remove.ts | /notes/:id |
index | (barrel only) | notes/index.ts | /notes |
route | any | notes/route.ts or main/route.ts | /notes or / |
Any other leaf is a segment: query.ts → /notes/query.
In a tree unit, do not add index.ts beside other route files — that is a
generate error. Use route.ts (or list.ts / create.ts) for the collection
root.
Barrel vs Tree
oke dev / oke build scans each src/flows/<unit>/ folder and writes
generated.ts. Two shapes, never mixed:
[param] folders, (group) folders, or extra route files. The barrel imports
each file and stamps path + name:
const notes = {
get: stampHttpPath(stampFlowName(notes_$id$_get.get, "notes.get"), "/notes/:id"),
list: stampHttpPath(stampFlowName(notes_list.list, "notes.list"), "/notes"),
};
export { notes };
registerFlowUnits({ notes });oke() drains registerFlowUnits into $routes. .adopt({ notes }) is
optional and additive.
index.ts plus [id]/get.ts (or any other route file) throws at generate:
Unit "notes" mixes a barrel index.ts with tree route files. Use only index.ts (barrel), or move the collection path to route.ts and keep [id]/ beside it.Fix: delete index.ts and use list.ts / route.ts, or fold every route into
index.ts with explicit paths.
Two files in the same unit cannot share an export const name:
Unit "notes" exports "get" from both list.ts and route.ts.Rename one export. The client method is the export name, not the filename.
flow("tasks.get", {…}) living under src/flows/notes/ throws:
flow("tasks.…") in notes/get.ts does not match the folder "notes".Use flow("notes.get", {…}), a nameless flow({ do }) (stamped notes.get
from the export), or move the file.
Names
Three names, one file:
| Surface | Source | Example |
|---|---|---|
| HTTP path | File tree (default) or explicit http.get("/x") | /notes/:id |
| Flow name | unit.export from flow({ do }) (default), or flow("notes.get") | notes.get |
| Client | Unit folder + export const | api.notes.get({ id }) |
Nameless flow({ do }) is the tree default — same rule as pathless HTTP. Pass
flow("notes.get") only for control (stable name, barrel, or matching unit
prefix). Wrong-unit prefixes fail generate.
Non-HTTP files still join the unit. A signal consumer in notes/on-created.ts
is api.notes.onCreated over RPC (POST /_oke/notes/onCreated), not HTTP.
Signal / Clock consumers pass an explicit flow("…") name (OKE1072 if nameless
outside src/flows/<unit>/; OKE1070 on collision). Clock may write clock.every(…)
inside on() — Clock · Inline or named export.
Runtime Matching
All adopted HTTP bindings go into one matcher. Wrong method on a known path is
405 with Allow. No match is a bare 404 Not Found.
oke({ router }) | Default | What it does |
|---|---|---|
"default" | yes | Compiled RegExp (O(1) static map + per-bucket regex for :id). Falls back to Trie when a path includes * |
"edge" | Linear scan, then Trie. No RegExp compile — cold-start / isolates |
p99 match stays under 1 ms on the compiled matcher. You do not pick buckets by hand — a catch-all in the table selects Trie for the whole app.
Duplicate METHOD + path fails boot (OKE1041), including a resource mount
plus a handwritten http.get("/notes").
Troubleshooting
No Flow is bound to that method + path. Check the explicit path, or for pathless routes the
file-tree stamp (notes/[id]/get.ts → GET /notes/: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 verb. Allow lists the methods that are.
list.ts with http.get() is GET-only — POST that URL is 405, not a missing file.
Cause: Flow "{flow}" bound {method} with no path — the file-tree stamp never ran. Import
@/flows/generated, run oke dev / oke build, or pass http.get("/…"). Barrels do not stamp —
they need the explicit path.
Cause: src/flows/{unit} exists on disk but adopted no flows — the .adopt() barrel is stale. Run
oke dev or oke build after adding a unit folder. Prod and Compose oke dev refuse to boot
this way.
Cause: {method} {path} is bound twice (flow "{flow}"). Two tree files stamped the same verb +
path (list.ts and route.ts both http.get()), or a resource mount plus a handwritten route.
Drop one binding.
Cause: An HTTP flow on {method} {path} has no name.
Use flow("unit.export", {…}) or export const from a src/flows/<unit>/
file so the tree can stamp unit.export. export default is not picked up.
Cause: Flow "{flow}" is defined twice. Two explicit flow("…") calls collide, or two tree
exports stamp the same unit.export. Give at least one a distinct name or tree export.
Cause: A {kind} flow on "{trigger}" has no name.
Fix: pass an explicit name — on(handle, flow("unit.export", { do })).
[id] stamps :id. in must declare id (same key). A schema that expects userId while the
path is :id fails validation before do.
[...slug] does not bind slug. The router param is "*". Declare
in: z.object({ "*": z.string() }) and read input["*"]. Optional
[[...slug]] is rejected at generate.
Generate: Unit "…" mixes a barrel index.ts with tree route files. Use only index.ts (explicit
paths), or move the collection path to route.ts and keep [id]/ beside it.
Learn more
- HTTP — verbs, envelopes,
http.resource, live SSE - Client —
api.notes.get, REST vs RPC,$routes - Errors — OKE1040 · OKE1030 · OKE1041 · OKE1045 · OKE1070 · OKE1072
- The Architecture — derived routes, no hand-written table
- Gate —
.gate(...)/.public()on the same trigger