Understand

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/links/[code]/get.ts and the compiler stamps GET /links/:code, names the Flow links.get, and the client calls api.links.get({ code }).

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 pathTree 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 nameSame tree file — flow({ do }) stamps unit.exportBarrel (optional — export still stamps); stable name across moves; call-only Flow you fx.call by name

Consequence: create-oke shorter is this tree — http.get() + flow({ … }) with no string path and no string name, except where the public URL must not follow the folder (GET /:code).

Smallest Example

Place the file in the tree

oke dev / oke build regenerate src/flows/index.ts. Import it before oke() so pathless triggers receive their stamp:

src/app.ts
import "@/core";
import "@/flows";
import { oke } from "okengine/http";

export const app = oke({ name: "app" });

Use pathless http.get()

The tree stamps GET /links/:code. :code merges into in. The export name is the client method (api.links.get):

src/flows/links/[code]/get.ts
import { on, flow, http } from "okengine";
import { z } from "zod";

export const get = on(
  http.get({
    in: z.object({ code: z.string() }),
    out: z.object({ code: z.string(), url: z.string() }),
  }),
  flow({
    do: async ({ code }) => ({ code, url: "https://example.com" }),
  }),
);

Call the endpoint

curl -X GET http://localhost:6530/links/ok -H "accept: application/json"

Response:

{
  "data": { "code": "ok", "url": "https://example.com" },
  "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 public URL that must not follow the folder:

One file per route. Skip the path argument. The generated barrel calls stampHttpPath / stampFlowName after import:

src/flows/links/list.ts
import { on, flow, http } from "okengine";

export const list = on(
  http.get(),
  flow({
    do: () => [],
  }),
);

Stamped to GET /links, Flow links.list, client api.links.list().

Catch-all [...slug] becomes * on the URL. The request param is always "*", never slug:

src/flows/docs/[...slug]/get.ts
import { on, flow, http } from "okengine";
import { z } from "zod";

export const get = on(
  http.get({ in: z.object({ "*": z.string() }) }).public(),
  flow({
    do: async (input) => ({ path: input["*"] }),
  }),
);
curl -X GET http://localhost:6530/docs/getting-started/install \
  -H "accept: application/json"

do receives { "*": "getting-started/install" }. Call api.docs.get({ "*": "a/b/c" }) — not { slug }.

Convention Reference

ConventionFileStamped routeClient
Dynamic paramlinks/[code]/get.ts + http.get()GET /links/:codeapi.links.get({ code })
Reserved leaflinks/list.ts + http.get()GET /linksapi.links.list()
Action leaflinks/[code]/archive.ts + http.post()POST /links/:code/archiveapi.links.archive({ code })
Explicit pathlinks/redirect.ts + http.get("/:code")GET /:codeapi.links.redirect({ code })
Catch-alldocs/[...slug]/get.ts + http.get()GET /docs/*api.docs.get({ "*": "a/b" })
Route grouplinks/(ops)/archive.ts/links/archiveapi.links.archive
Root unitmain/health.ts + http.get()GET /healthapi.main.health()
Folder rootmain/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.

create-oke -t shorter ships this tree (-t blank is main/ only):

src/flows/
├── links/                     # Unit: links → api.links.*
│   ├── list.ts                # GET /links          (reserved leaf)
│   ├── create.ts              # POST /links         (reserved leaf)
│   ├── redirect.ts            # explicit GET /:code
│   ├── expire.ts              # Clock — not HTTP
│   ├── reach.ts               # Clock — not HTTP
│   ├── shapes.ts              # skipped (contracts)
│   ├── signals.ts             # skipped as a route; on() still joins the unit
│   ├── _shared.ts             # skipped (_ prefix)
│   └── [code]/
│       ├── get.ts             # GET /links/:code
│       ├── archive.ts         # POST /links/:code/archive
│       └── report.ts          # GET /links/:code/report
└── main/                      # Unit: main — prefix omitted from the URL
    ├── health.ts              # GET /health
    ├── route.ts               # GET /
    └── shapes.ts

A file sitting directly in src/flows/ (no unit folder) is not a route.

Unit folder names must be valid JS identifiers (links, main, _ allowed inside; my-links is skipped). Folders starting with _, [, or ( are not units.

Consequence: export const getLink from get.ts is api.links.getLink, not api.links.get. Match the export to the name you want on the client.

Path Conventions

Default — pathless. Omit the path so src/flows/index.ts stamps the URL from disk:

http.get(); // pending until stampHttpPath runs

Control — 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("/:code");

Path params, query string, and JSON body still merge into one object checked by in — same order as HTTP · Request Parsing.

Dynamic parameters

[code]:code. The folder name is the param key. Declare the same key on in:

src/flows/orgs/[orgId]/members/[memberId]/get.ts
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/links/(ops)/archive.ts  →  /links/archive

(ops) never enters the Flow name either (links.archive).

The main unit

main is omitted from the URL prefix. Flow names still use main.:

FileURLFlow name
main/health.ts/healthmain.health
main/route.ts/main.root (from export const root)
main/index.ts/ (extract)barrel — pass http.get("/")

Skip list

These files are never HTTP routes (path inference returns nothing). Walk skips them except signals.ts, which still contributes on() consumers:

PatternWhy
src/flows/index.tsAdopt barrel (oke dev / oke build writes it)
shapes.tsShared Zod / contracts
signals.tsDeclarations. on() in the same file still join the unit
*.test.ts / *.test.tsxTests
_ prefix (file or folder)Private helpers (links/_shared.ts)

Skip-list files may sit next to tree routes. They do not turn a tree into a barrel. Clock files (expire.ts, reach.ts) still join the unit — they are not HTTP unless they bind http.*.

Reserved Leaves

To avoid /links/get and /orders/list, these filenames add no URL segment — the same five CRUD names as http.resource, plus folder roots:

LeafTypical triggerExample fileStamped path
listhttp.get()links/list.ts/links
createhttp.post()links/create.ts/links
gethttp.get()links/[code]/get.ts/links/:code
updatehttp.patch()links/[code]/update.ts/links/:code
removehttp.delete()links/[code]/remove.ts/links/:code
index(barrel only)links/index.ts/links
routeanylinks/route.ts or main/route.ts/links or /

Any other leaf is a segment: query.ts/links/query. redirect.ts with pathless http.get() would stamp /links/redirect — pass http.get("/:code") instead.

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 src/flows/index.ts. Two shapes, never mixed:

[param] folders, (group) folders, or extra route files. The barrel imports each file and stamps path + name:

const links = {
  get: stampHttpPath(stampFlowName(links_$code$_get.get, "links.get"), "/links/:code"),
  list: stampHttpPath(stampFlowName(links_list.list, "links.list"), "/links"),
};
export { links };
registerFlowUnits({ links });

oke() drains registerFlowUnits into $routes. .adopt({ links }) is optional and additive.

Names

Three names, one file:

SurfaceSourceExample
HTTP pathFile tree (default) or explicit http.get("/x")/links/:code
Flow nameunit.export from flow({ do }) (default), or flow("links.get")links.get
ClientUnit folder + export constapi.links.get({ code })

Nameless flow({ do }) is the tree default — same rule as pathless HTTP. Pass flow("links.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 links/signals.ts is api.links.onCreated over RPC (POST /_oke/links/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 })DefaultWhat it does
"default"yesCompiled 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

Static paths win over :param on the same method (GET /health beats GET /:code), including the edge linear scan. okengine/http defaults to "edge".

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("/links").

Troubleshooting

Learn more

  • The Architecture — derived routes, no hand-written table
  • Try it — scaffold, run, and hit a stamped path
  • HTTP — verbs, envelopes, http.resource, live SSE
  • Clientapi.links.get, REST vs RPC, $routes
  • Errors — OKE1040 · OKE1030 · OKE1041 · OKE1045 · OKE1070 · OKE1072
  • Gate.gate(...) / .public() on the same trigger

Next

On this page