/** * Typed client contract surface — type-level only. * * Same-repo: pass `App` with `$routes` (or a bare route map) to {@link createClient}. * Local / separate-repo: augment {@link Register} so `createClient(url)` needs no import. */ import type { BuiltinErrorMap } from "../kernel/builtin-errors.ts"; /** * One flow's client contract. `in` / `out` / `errors` are phantom (type-only). * * @typeParam I - Request input * @typeParam O - Success data * @typeParam E - Map of error code → data shape */ export interface FlowContract< I = unknown, O = unknown, E extends Record = Record, > { /** Phantom input type. */ readonly in?: I; /** Phantom success type. */ readonly out?: O; /** Phantom error map (`FlightFull` → `{ seatsLeft: number }`). */ readonly errors?: E; /** Optional HTTP method when the wire uses REST instead of `/_oke` RPC. */ readonly method?: string; /** Optional HTTP path template (`/notes/:id`). */ readonly path?: string; /** Live signal name when this flow is an SSE exposure. */ readonly live?: string; /** Auto-match path-param names (empty = firehose). */ readonly matchKey?: readonly string[]; /** Flattened gate names on the HTTP trigger. */ readonly gates?: readonly string[]; /** True when this flow returns `text/event-stream`. */ readonly stream?: true; } /** * Unit → flow → contract. Drives `api.notes.get(...)`. */ export type ClientRouteMap = { readonly [unit: string]: { readonly [flow: string]: FlowContract; }; }; /** * App shape accepted by {@link createClient}. Either a `$routes` carrier * (future `typeof app`) or a bare {@link ClientRouteMap}. */ export type ClientApp = { readonly $routes: ClientRouteMap; }; /** * Module-augmentation slot for modes that skip `import type { App }`. * * @example * ```ts * declare module "okengine/client" { * interface Register { * app: { $routes: { notes: { get: { in: { id: string }; out: Note; errors: { NotFound: {} } } } } }; * } * } * ``` */ export interface Register { // intentionally empty — augmented by `oke dev` / `oke client add` } /** * Resolve the App type: explicit type argument wins; else {@link Register}. * * @typeParam App - Explicit app / route map */ export type ResolveApp = [App] extends [never] ? Register extends { readonly app: infer A } ? A : { readonly $routes: {} } : App; /** * Extract a {@link ClientRouteMap} from an App type. * * Accepts `typeof app` (OkeApp with accumulated `$routes`), {@link ClientApp}, * or a bare route map. * * @typeParam App - {@link ClientApp}, `typeof app`, or bare route map */ export type RoutesOf = App extends { readonly $routes: infer R } ? R extends ClientRouteMap ? R : R extends Record> ? R : { [U in keyof R]: { [F in keyof R[U]]: R[U][F] extends FlowContract ? R[U][F] : FlowContract; }; } : App extends ClientRouteMap ? App : {}; /** * Discriminated flow-boundary error. Narrow with `error.code === "FlightFull"`. * * @typeParam E - Error code → data map */ export type ClientError = Record> = { [K in keyof E & string]: { readonly code: K; readonly data: E[K]; readonly message?: string; }; }[keyof E & string]; /** * Transport / protocol failure (not a declared flow error). */ export interface TransportError { readonly code: "TransportError"; readonly data: { readonly message: string; readonly status?: number; }; /** Same string as {@link TransportError.data.message}. */ readonly message: string; } /** Next / previous list request — TanStack `pageParam` / URL bag. */ export type ClientPagerLink = { readonly cursor: string; }; /** * Optional list pager on success `meta`. `next` / `prev` are request bags. */ export type ClientListMeta = { readonly mode?: "offset" | "cursor"; readonly limit?: number; readonly next?: ClientPagerLink | null; readonly prev?: ClientPagerLink | null; readonly total?: number; readonly offset?: number; /** Set when the server replayed a completed idempotent call. */ readonly idempotentReplayed?: true; }; /** * Wire envelope — `{ data, error, meta? }` before pager methods attach. * * @typeParam O - Success data * @typeParam E - Declared error map */ export type ClientEnvelope< O = unknown, E extends Record = Record, > = | { readonly data: O; readonly error: null; readonly meta?: ClientListMeta; } | { readonly data: null; readonly error: ClientError | TransportError; readonly meta?: ClientListMeta; }; /** * Client call result — envelope plus always-callable `next` / `prev`. * Async-iterable: this page, then `next()`, stop when `meta.next` is null. * * @typeParam O - Success data * @typeParam E - Declared error map */ export type ClientResult< O = unknown, E extends Record = Record, > = ClientEnvelope & { /** Next list page, or an empty success when there isn't one. */ readonly next: () => Promise>; /** Previous list page, or an empty success when there isn't one. */ readonly prev: () => Promise>; } & AsyncIterable>; /** Minimal fetch signature (avoids DOM `HeadersInit` / `preconnect` coupling). */ export type ClientFetch = (input: string | URL | Request, init?: RequestInit) => Promise; /** Header bag accepted by the client. */ export type ClientHeaders = Record | [string, string][]; /** * Fetch credentials mode (avoids DOM `RequestCredentials` coupling). * Use `"include"` for cookie sessions. */ export type ClientCredentials = "include" | "omit" | "same-origin"; /** * Raw request body accepted by the transport (avoids DOM `BodyInit` coupling). * Mirrors what {@link createTransport} treats as non-JSON via `instanceof` checks. */ export type ClientBodyInit = | Blob | FormData | ArrayBuffer | ArrayBufferView | ReadableStream | string | URLSearchParams; /** * Bare transport Bearer hooks (no {@link AuthClient}). * Prefer session options (`mode` / `persist` / `csrfConfigured`) so * `createClient` attaches `api.auth`. */ export interface ClientTransportAuth { /** Current access token (Bearer), or null. */ readonly getToken: () => string | null | undefined | Promise; /** Obtain a new access token after 401. */ readonly refresh: () => Promise; } /** * Session orchestration options for `createClient({ auth })` → `api.auth`. * Mirrors {@link CreateAuthClientOptions} plus optional SSR token overrides. */ export interface ClientSessionAuthOptions { readonly mode?: "bearer" | "cookie"; readonly persist?: "memory" | "sessionStorage" | "localStorage"; readonly storageKey?: string; readonly tenantHeader?: string; readonly csrfConfigured?: boolean; readonly session?: import("./auth/session.ts").MemorySession; readonly env?: { readonly localStorage?: Storage; readonly sessionStorage?: Storage; readonly warn?: (message: string) => void; }; /** SSR / custom: override Bearer getToken (cookie mode still uses credentials). */ readonly getToken?: ClientTransportAuth["getToken"]; /** SSR / custom: override refresh after 401. */ readonly refresh?: ClientTransportAuth["refresh"]; } /** * Options for {@link createClient}. */ export interface ClientOptions { /** Override `globalThis.fetch`. */ readonly fetch?: ClientFetch; /** Static headers, or a getter invoked per request. */ readonly headers?: ClientHeaders | (() => ClientHeaders | Promise); /** Abort the request after this many milliseconds. */ readonly timeout?: number; /** * Retry transient failures (network errors, client timeouts, and * non-envelope 5xx). * * A call retries when it is `GET` or `QUERY`, or when it carries an * idempotency key. Keys are sent automatically on every non-GET call. * `idempotencyKey: false` runs once unless that call passes `{ retry: true }`. * `409 IdempotencyInProgress` and `409 JournalLeaseBusy` wait for `Retry-After` and count as an attempt. */ readonly retry?: { /** Extra attempts after the first (default 0). */ readonly retries?: number; /** Initial delay in ms (default 50). */ readonly delay?: number; /** Multiplier applied after each retry (default 2). */ readonly backoff?: number; }; /** * Session orchestration (`mode` / `csrfConfigured` / …) → attaches `api.auth`, * or bare `{ getToken, refresh }` transport hooks only. */ readonly auth?: ClientTransportAuth | ClientSessionAuthOptions; /** * Fetch credentials mode. Use `"include"` for cookie sessions * (`gate.auth.cookies` + `createClient({ auth: { mode: "cookie" } })`). */ readonly credentials?: ClientCredentials; /** * Optional AbortSignal for all calls from this client (per-call override * not yet exposed on the proxy). */ readonly signal?: AbortSignal; /** * Runtime REST table (optional). Keys are `unit.flow`. * When absent, the client uses `POST /_oke/{unit}/{flow}` unless * {@link ClientOptions.$routes} supplies HTTP method/path from adopt. */ readonly routes?: Readonly>; /** * Runtime route map from `app.$routes` (typed adopt). HTTP triggers become * REST; flows without method/path fall back to RPC. */ readonly $routes?: ClientRouteMap; } /** * `await list()` is one page; `for await (const page of list())` walks. * * @typeParam T - Page result */ export type ClientThenableIterable = PromiseLike & AsyncIterable; /** * Per-call options on a Flow invoke (binary decode, abort, retry opt-in). */ export interface ClientCallOpts { readonly response?: "json" | "blob" | "arrayBuffer"; readonly signal?: AbortSignal; /** * Repeat this call under the client `retry` policy even when it sends * no idempotency key. */ readonly retry?: boolean; /** * Stable `Idempotency-Key` for this logical call. A string is sent as-is * (one key per form render dedupes double submits). `false` sends no key. * Omitted on a non-GET call generates one key and reuses it across retries. */ readonly idempotencyKey?: string | false; } /** * Call signature for one flow — thenable (one page) and async-iterable (walk). * * @typeParam I - Input * @typeParam O - Output * @typeParam E - Errors */ type IsAnyInput = 0 extends 1 & T ? true : false; type ClientCallFn> = IsAnyInput extends true ? { (input?: I, opts?: ClientCallOpts): ClientThenableIterable>; } : [I] extends [void] ? { (): ClientThenableIterable>; (opts: ClientCallOpts): ClientThenableIterable>; } : Partial extends I ? { (input?: I, opts?: ClientCallOpts): ClientThenableIterable>; } : { (input: I, opts?: ClientCallOpts): ClientThenableIterable>; }; /** * Call signature for one flow. No `.pages()` — iterate the call or the page. * * @typeParam I - Input * @typeParam O - Output * @typeParam E - Errors */ export type ClientCall> = ClientCallFn; /** Unsubscribe a live SSE subscription. */ export type LiveUnsubscribe = () => void; /** * Callbacks for {@link ClientLive} / live-exposing flow calls. * * @typeParam T - Signal payload */ export interface LiveHandlers { readonly onEvent: (event: T) => void; readonly onError?: (error: unknown) => void; /** Fired after a successful SSE open (HTTP 200), including reconnects. */ readonly onOpen?: () => void; /** * Re-open the SSE request after a drop. Default false. * Reconnects send `Last-Event-ID` when a cursor was received. * A 410 LiveResumeGap clears the cursor and replays the remaining tape. * Backoff starts at 500ms and doubles to 30s so a closed stream cannot * tight-loop reconnects. */ readonly autoResubscribe?: boolean; /** Disambiguate when two exposures share a match shape. `unit.flow`. */ readonly via?: string; readonly signal?: AbortSignal; } /** Named live signal handle (`signal()`). */ export type LiveSignalHandle = { readonly name: string; readonly _payload?: T; }; /** Filter fields for a live subscribe (path params / payload keys). */ export type LiveInput = T extends object ? Partial & Record : Record; /** * Root `api.live(signal, …)` — callback subscribe, not `for await`. * * @typeParam App - App brand (reserved; runtime uses `$routes`) */ export type ClientLive = [App] extends [never] ? ClientLiveOverloads : ClientLiveOverloads; type ClientLiveOverloads = { (signal: LiveSignalHandle, input: LiveInput, handlers: LiveHandlers): LiveUnsubscribe; (signal: LiveSignalHandle, handlers: LiveHandlers): LiveUnsubscribe; (name: string, input: unknown, handlers: LiveHandlers): LiveUnsubscribe; (name: string, handlers: LiveHandlers): LiveUnsubscribe; }; type IsLiveContract = C extends { readonly live: string } ? true : false; type IsStreamOnlyContract = C extends { readonly stream: true } ? IsLiveContract extends true ? false : true : false; type ClientLiveFlowCall = [I] extends [void] ? (handlers: LiveHandlers) => LiveUnsubscribe : Partial extends I ? { (handlers: LiveHandlers): LiveUnsubscribe; (input?: I, handlers?: LiveHandlers): LiveUnsubscribe; } : (input: I, handlers: LiveHandlers) => LiveUnsubscribe; type ClientStreamCall = [I] extends [void] ? (opts?: { readonly signal?: AbortSignal }) => AsyncIterable : Partial extends I ? (input?: I, opts?: { readonly signal?: AbortSignal }) => AsyncIterable : (input: I, opts?: { readonly signal?: AbortSignal }) => AsyncIterable; type ClientCallFor = IsLiveContract extends true ? ClientLiveFlowCall, ContractOut> : IsStreamOnlyContract extends true ? ClientStreamCall, ContractOut> : ClientCall, ContractOut, ContractErrors>; /** * Pull input from a contract shape (supports required or phantom-optional `in`). */ type ContractIn = "in" extends keyof C ? [NonNullable] extends [never] ? void : NonNullable : void; /** Pull output from a contract shape. */ type ContractOut = "out" extends keyof C ? NonNullable : unknown; /** * Pull error map from a contract shape. Built-in codes are always on; declared * keys win. A missing `errors` bag (phantom `Record`) is * treated as unspecified — not as an index signature that wipes the builtins. */ type ContractErrors = "errors" extends keyof C ? NonNullable extends infer E ? E extends Record ? string extends keyof E ? BuiltinErrorMap : Omit & E : BuiltinErrorMap : BuiltinErrorMap : BuiltinErrorMap; /** * Typed client proxy derived from a route map. * * @typeParam R - Route map */ export type ClientFromRoutes = { readonly [U in keyof R]: { readonly [F in keyof R[U]]: ClientCallFor; }; }; /** * Fully typed client for an App. * * `live` is always on the instance. Pass `typeof app` / `$routes` so unit * bags exist on the type — an empty-`$routes` client cannot be asserted onto * `{ console: … }` (TS2352). * * @typeParam App - App or route map */ export type Client = ClientFromRoutes>> & { readonly live: ClientLive>; /** * Present when `createClient` was constructed with session `auth` options * (`mode` / `persist` / `csrfConfigured` / …). Merged over the `auth` unit * Flows at runtime (`api.auth.me` still works). */ readonly auth?: import("./auth/create-auth-client.ts").AuthClient; }; /** * Convenience: brand a route map as an App (`export type App = AppOf<…>`). * * @typeParam R - Route map */ export type AppOf = { readonly $routes: R }; /** * JSON shape served at `GET /_oke/client.json` for {@link clientAdd}. */ export interface ClientDescriptor { /** Route contracts with TypeScript type strings for emission. */ readonly routes: { readonly [unit: string]: { readonly [flow: string]: { readonly in?: string; readonly out?: string; readonly errors?: Readonly>; readonly method?: string; readonly path?: string; readonly live?: string; readonly stream?: true; readonly matchKey?: readonly string[]; readonly gates?: readonly string[]; readonly idempotency?: { readonly mode: "off" | "auto" | "required"; readonly ttl: string }; }; }; }; }