Client
Typed caller for your flows — createClient from okengine/client, zero codegen, errors as values.
okengine/client is how a browser, CLI, or another service calls your app's flows. Adopt the unit, take typeof app, and api.notes.get({ id }) is fully typed — same contracts the server already has, no separate schema project.
The one rule
Treat every call as a result envelope: { data, error }. Flow failures are values you switch on
(error.code); they are never thrown. Only transport / protocol problems use
code: "TransportError".
Quick start
Adopt flows and export App
import { oke } from "okengine";
import * as main from "./flows/main";
export const app = oke({ name: "standard" }).adopt({ main });
export type App = typeof app;Create the client
Same repo — pass the app value so HTTP triggers hit REST (method + path from adopt):
import { createClient } from "okengine/client";
import { app } from "./app";
const api = createClient(app, "http://localhost:6530");Or type-only with createClient<App>(url) and pass $routes: app.$routes when you want REST
instead of RPC.
Call a flow and narrow the result
const { data, error } = await api.main.health();
if (error) {
// TransportError or a declared flow code
return;
}
// data inferred from the flow's `out`
console.log(data.ok);With the starter, that is GET /health on port 6530 when $routes are wired.
createClient forms
| Form | Types from | Wire |
|---|---|---|
createClient(app, url, opts?) | typeof app | REST from app.$routes; untriggered flows → RPC |
createClient<App>(url, opts?) | Explicit App type | RPC unless opts.$routes or opts.routes |
createClient(url, opts?) | Ambient Register (oke-client.d.ts) | Same — pass routes for REST |
oke dev regenerates oke-client.d.ts from GET /_oke/client.json. A separate frontend repo
runs oke client add <url> (default out: oke-client.d.ts).
oke client add http://localhost:6530
oke client add https://api.example.com --out ./types/oke-client.d.tsOptions
| Option | Type | Default | Meaning |
|---|---|---|---|
fetch | (input, init?) => Promise<Response> | globalThis.fetch | Inject a fetch implementation |
headers | Record<string, string> | pairs | () => … | — | Static headers, or a getter per request |
timeout | number (ms) | — | Abort after this many milliseconds |
retry.retries | number | 0 | Extra attempts after the first (network / 5xx) |
retry.delay | number (ms) | 50 | Initial backoff delay |
retry.backoff | number | 2 | Multiplier after each retry |
auth.getToken | () => string | null | … | — | Bearer access token (or null) |
auth.refresh | () => Promise<string | null | …> | — | Runs once on HTTP 401, then the request retries |
$routes | ClientRouteMap | — | Runtime map from app.$routes (REST when method+path present) |
routes | Record<"unit.flow", { method, path }> | — | Flat REST table; wins over flattening $routes |
Consequence: createClient<App>(url) alone types the proxy but still posts
POST /_oke/{unit}/{flow} until you pass the app value, $routes, or routes.
REST vs RPC
| Situation | Request |
|---|---|
HTTP trigger with method + path on $routes | That method and path (:id filled from input; leftover fields → query on GET/HEAD, JSON body otherwise) |
| Adopted flow with no HTTP trigger | POST {base}/_oke/{unit}/{flow} with JSON body |
Incomplete proxy path (api.notes() with no flow) | Result error: Incomplete path: api.notes(…) |
// REST — createClient(app, url) saw method/path from on(http.get("/notes/:id"), …)
await api.notes.get({ id: "n_1" }); // GET /notes/n_1
// RPC — untriggered flow named notes.stats
await api.notes.stats({ id: "n_1" }); // POST /_oke/notes/statsResult envelope and helpers
Success may include optional top-level meta (for example pagination). Declared flow errors and
transport failures share the failure arm:
import { isOk, isFail, isErrorCode, isTransportError } from "okengine/client";
const result = await api.bookings.create({ flightId: "SK1", seats: 9 });
if (isOk(result)) {
result.data.id;
} else if (result.error.code === "FlightFull") {
result.error.data.seatsLeft; // narrowed
} else if (isTransportError(result.error)) {
result.error.data.message;
}
isFail(result); // true when error !== null
isErrorCode(result.error, "FlightFull"); // type predicate helperPrefer error?.code === "FlightFull" for inference; use isErrorCode in shared helpers.
Auth on the client
One client: createClient. With gate.auth, the app exposes /auth/* Flows
(sign-in, refresh, me). Helpers under okengine/client/auth store tokens — they are
not a second factory.
import { createClient } from "okengine/client";
import { memorySession } from "okengine/client/auth";
import { app } from "./app";
const session = memorySession();
const api = createClient(app, "http://localhost:6530", {
auth: {
getToken: () => session.getToken(),
refresh: () => session.refresh(api),
},
});
const { data } = await api.auth.signInEmail({ email, password });
if (data) session.set(data);React: useSession(api, session) from okengine/client-react.
| Step | What happens |
|---|---|
| Every request | getToken() → Authorization: Bearer … when a token is present |
| HTTP 401 | refresh() runs once, then the same call retries |
| HTTP 403 / 429 | No refresh — decode the failure envelope as usual |
Consequence: refresh must mutate whatever getToken reads. Returning a new string alone does
nothing if storage was not updated.
After a gated call, switch on the denial codes (values, not throws):
| Code | HTTP | error.data | Typical fix |
|---|---|---|---|
Unauthorized | 401 | {} | Sign in, or let auth.refresh run; re-login if still denied |
Forbidden | 403 | { gate, reason } | Wrong scopes / policy — show denied |
RateLimited | 429 | { retryAfterMs } | Wait retryAfterMs before retrying |
These gate codes are not listed in each Flow’s errors map — they can appear on any gated
route.
A 401 with no { data, error } body becomes TransportError with data.status: 401.
| Helper | Package | Role |
|---|---|---|
memorySession | okengine/client/auth | In-memory access/refresh bag for auth.getToken |
AUTH_ERROR_CODES | okengine/client/auth | Common auth Flow / gate codes |
useSession(api, session?) | okengine/client-react | React status + auth.me |
Core okengine/client stays under the size budget — helpers are separate exports. Not in
core today: cookie jars or plugin .client() decorations. Browser apps: also see
CORS and CSRF.
Elements from the client
Flows only
The client calls Flows. Every other element runs on the server through fx. You reach its
outcome by calling a Flow that uses it — or by handling a gate denial on that call.
| Element | On the client | How |
|---|---|---|
| Flow | Direct | api.unit.flow(input) — the only public surface |
| Gate | Indirect | Bearer via auth; denials as Unauthorized / Forbidden / RateLimited |
| Store | Via Flows | fx.store inside Flows; store.resource + on(http.resource…) → five Flows you adopt |
| Signal | Via Flows | Emit/consume server-side; no subscribe API on okengine/client yet |
| Clock | Via Flows | Schedules fire on the server — the client never ticks a clock |
| Vault | Via Flows | Secrets stay server-side; never ship them to the browser package |
| Channel | Via Flows | fx.send in a Flow — the client does not send email/SMS/push |
| AI | Via Flows | fx.ask / fx.run inside a Flow; the client gets that Flow’s out |
Store resources
Mount a resource, adopt the returned ops, then call the five Flows like any other:
const notesR = store.resource(db, notes, {/* in, out, list, unit: "notes" */});
const mounted = on(http.resource("/notes", notesR.all()));
// .adopt({ notes: mounted }) →
await api.notes.list({ limit: 20 }); // GET /notes?limit=20 — meta may carry nextCursor
await api.notes.get({ id }); // GET /notes/:id — NotFound when missing
await api.notes.remove({ id }); // DELETE → 204, data undefinedSee Store for the list query language and schemas. Auth posture for HTTP triggers is covered under Gate.
Signal and live queries
delivery: "live" and http.get(…).live() are Manifest / driver flags today. okengine/client
does not expose WebSocket, SSE, or api.*.subscribe. Until that ships, poll or call an HTTP
Flow that returns the current state.
Exports
| Export | Kind | Role |
|---|---|---|
createClient | function | Typed proxy api.unit.flow(input?) |
flattenRoutes | function | $routes → flat unit.flow REST table |
createTransport | function | Low-level HTTP transport (timeout / retry / auth) |
isOk / isFail | function | Envelope predicates |
isErrorCode / isTransportError | function | Error narrowing |
Client, ClientOptions, ClientResult, … | types | Contracts and options |
Register | interface | Module-augmentation slot for ambient App types |
AppOf | type | Brand a bare route map as an App |
Budget: the ./client export stays under the measured client-runtime cap (hard gate in CI).
Troubleshooting
Confirm the flow is exported from the module you .adopt({ main }), and that createClient is
typed with that App (or ambient Register after oke-client.d.ts regenerates). Restart
oke dev after renaming exports.
Types alone do not choose REST. Pass createClient(app, url), or
createClient(url, { $routes: app.$routes }), or an explicit routes map.
Network failure, abort (timeout), non-JSON body, empty error response, or HTTP status without a
{ data, error } envelope. Declared flow codes (NotFound, FlightFull, …) never use this code.
Message text lives in error.data.message; HTTP status may appear as error.data.status.
auth.refresh runs once per call on HTTP 401. It must update the store getToken reads — the
return value is ignored. With gate.auth, POST /auth/refresh is built in; memorySession.refresh(api)
calls api.auth.refresh({ refreshToken }). Re-login when rotation fails or no refresh token remains.
oke client add needs a running app that serves the descriptor. Start the app (oke dev /
oke start), check the URL, then retry. Usage when the URL is missing:
Usage: oke client add <url> [--out oke-client.d.ts].
Learn more
- Basic usage — adopt → client → test loop
- Gate — policies,
gate.public, denials - Store —
store.resourceand list query language - Flow —
in/out/errorsandfx.fail - Errors — framework codes vs failure values
- CORS · CSRF — browser callers
- CLI Reference —
oke client add,oke dev