ElementsFlow

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

src/flows/main/ping.ts
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:

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

export const health = on(
  http.get().public(),
  flow({
    do: () => ({ ok: true }),
  }),
);

Method Reference

MethodSignaturePurposeBody AllowedIdempotent
http.gethttp.get(path?)Fetch a resource or listNoYes
http.posthttp.post(path?)Create resource / commandYesNo
http.puthttp.put(path?)Replace entire resourceYesYes
http.patchhttp.patch(path?)Partial resource updateYesNo
http.deletehttp.delete(path?)Remove a resourceOptionalYes
http.queryhttp.query(path?)Safe read with JSON bodyYes (RFC 10008)Yes
http.headhttp.head(path?)Retrieve response headersNoYes
http.optionshttp.options(path?)Discover allowed methodsNoYes
http.resourcehttp.resource(path, ops)Five CRUD verbs; live when onVerb-dependentVerb-dependent
http.livehttp.live(signal)Firehose SSE on GET /_oke/live/{name}NoYes

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:

src/flows/users/[id]/get.ts
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:

  1. Path parameters:param segments (e.g. { id: "123" }).
  2. Query string?sort=desc keys at the root.
  3. JSON body — object fields merged into the same root.
  4. Headers & cookies — bags under headers / cookie when do reads them (declare the same keys in in).
src/flows/items/[id]/update.ts
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):

src/flows/uploads/create.ts
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:

src/flows/notes/[id]/get.ts
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.

src/flows/notes/resource.ts
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.

OpMethodPathTypical status
listGET/notes200 + { data, error, meta }
createPOST/notes201 Created (fx.json.create)
getGET/notes/:id200, or NotFound
updatePATCH/notes/:id200, or NotFound
removeDELETE/notes/:id204 No Content (fx.json.empty)
liveGET/notes/liveSSE — only when live is on

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.

src/flows/orders/firehose.ts
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].

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 statusfx.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 failuresfx.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:

  • ValidationError422 Unprocessable Entity
  • Unauthorized401 Unauthorized
  • Forbidden403 Forbidden
  • RateLimited429 Too Many Requests
  • Custom error codes → 400 Bad Request

Troubleshooting

Learn more

  • Storestore.resource, list query grammar, SQL facet
  • Signal · Livesignal.live tapes
  • Clientapi.live, useLive, useLiveQuery
  • fxfx.live, fx.json.stream, fx.json.create
  • Gate.gate(...) / .public() on triggers
  • Errors — OKE1041 · OKE1050 · OKE1210

Next

On this page