/** * `oke client add ` — ambient client types for a separate frontend repo. * * Fetches `GET {url}/_oke/client.json` and writes: * - `oke-client.d.ts` — Register augmentation * - `oke-client.routes.ts` — value `$routes` for REST / live / stream wire */ import type { ClientDescriptor, ClientFetch } from "../client/types.ts"; /** Options for {@link clientAdd}. */ export interface ClientAddOptions { /** App base URL (required unless `descriptor` is provided). */ readonly url?: string; /** Output path for ambient types (default: `oke-client.d.ts`). */ readonly out?: string; /** Override fetch (tests). */ readonly fetch?: ClientFetch; /** Inject a descriptor instead of fetching (tests). */ readonly descriptor?: ClientDescriptor; /** Skip writing `oke-client.routes.ts` (tests). */ readonly skipRoutes?: boolean; } /** Result of a successful {@link clientAdd}. */ export interface ClientAddResult { /** Absolute or relative path written for ambient types. */ readonly out: string; /** Emitted TypeScript declaration source. */ readonly source: string; /** Routes module path when written. */ readonly routesOut?: string; /** Routes module source when written. */ readonly routesSource?: string; } /** * Fetch (or accept) a client descriptor and write ambient + routes modules. * * @param options - URL / out / test overrides */ export async function clientAdd(options: ClientAddOptions): Promise { const out = options.out ?? "oke-client.d.ts"; const descriptor = options.descriptor ?? (await fetchDescriptor(options.url, options.fetch)); const source = emitAmbient(descriptor); await Bun.write(out, source); let routesOut: string | undefined; let routesSource: string | undefined; if (!options.skipRoutes) { routesOut = routesPathFor(out); routesSource = emitRoutes(descriptor); await Bun.write(routesOut, routesSource); } return { out, source, routesOut, routesSource }; } /** * CLI entry: parse `oke client add [--out path]`. * * @param args - Args after `client add` */ export async function clientAddCli(args: readonly string[]): Promise { const url = args.find((a) => !a.startsWith("-")); if (!url) { console.error("Usage: oke client add [--out oke-client.d.ts]"); return 1; } let out = "oke-client.d.ts"; const outIdx = args.findIndex((a) => a === "--out" || a === "-o"); if (outIdx !== -1) { const next = args[outIdx + 1]; if (!next) { console.error("--out|-o requires a path"); return 1; } out = next; } try { const result = await clientAdd({ url, out }); console.log(`Wrote ${result.out}`); if (result.routesOut) console.log(`Wrote ${result.routesOut}`); return 0; } catch (err) { console.error(err instanceof Error ? err.message : String(err)); return 1; } } /** * Emit a compiling ambient module augmentation from a descriptor. * * @param descriptor - Routes with TS type strings */ export function emitAmbient(descriptor: ClientDescriptor): string { const units = Object.entries(descriptor.routes); const body = units.length === 0 ? " // no HTTP flows" : units .map(([unit, flows]) => { const flowLines = Object.entries(flows) .map(([flow, c]) => emitFlowContract(flow, c, "ambient")) .join(";\n"); return ` ${JSON.stringify(unit)}: {\n${flowLines};\n }`; }) .join(";\n"); return `/* eslint-disable */ /** * Generated by \`oke client add\` — do not edit by hand. * Ambient types for \`createClient(url)\` without importing App. * Pair with \`oke-client.routes.ts\`: createClient(url, { $routes: routes }). */ declare module "okengine/client" { interface Register { app: { $routes: { ${body} }; }; } } export {}; `; } /** * Emit a value `$routes` module for wire REST / live / stream. * * @param descriptor - Client descriptor */ export function emitRoutes(descriptor: ClientDescriptor): string { const units = Object.entries(descriptor.routes); const body = units.length === 0 ? " // no HTTP flows" : units .map(([unit, flows]) => { const flowLines = Object.entries(flows) .map(([flow, c]) => emitFlowContract(flow, c, "value")) .join(",\n"); return ` ${JSON.stringify(unit)}: {\n${flowLines},\n }`; }) .join(",\n"); return `/* eslint-disable */ /** * Generated by \`oke client add\` — do not edit by hand. * Runtime route stamps for createClient(url, { $routes: routes }). */ export const routes = { ${body}, } as const; export default routes; `; } function emitFlowContract( flow: string, c: ClientDescriptor["routes"][string][string], mode: "ambient" | "value", ): string { if (mode === "value") { const fields = [ c.method !== undefined ? ` method: ${JSON.stringify(c.method)}` : undefined, c.path !== undefined ? ` path: ${JSON.stringify(c.path)}` : undefined, c.live !== undefined ? ` live: ${JSON.stringify(c.live)}` : undefined, c.stream === true ? ` stream: true as const` : undefined, c.matchKey !== undefined ? ` matchKey: ${JSON.stringify(c.matchKey)} as const` : undefined, c.gates !== undefined && c.gates.length > 0 ? ` gates: ${JSON.stringify(c.gates)} as const` : undefined, ].filter((line): line is string => line !== undefined); if (fields.length === 0) return ` ${JSON.stringify(flow)}: {}`; return ` ${JSON.stringify(flow)}: {\n${fields.join(",\n")},\n }`; } const errors = c.errors ? Object.entries(c.errors) .map(([code, ty]) => ` ${JSON.stringify(code)}: ${ty}`) .join(";\n") : ""; const errorBlock = errors ? ` errors: {\n${errors};\n };` : " errors: {};"; return [ ` ${JSON.stringify(flow)}: {`, ` in: ${c.in ?? "unknown"};`, ` out: ${c.out ?? "unknown"};`, errorBlock, c.method ? ` method: ${JSON.stringify(c.method)};` : undefined, c.path ? ` path: ${JSON.stringify(c.path)};` : undefined, c.live ? ` live: ${JSON.stringify(c.live)};` : undefined, c.stream === true ? ` stream: true;` : undefined, c.matchKey ? ` matchKey: ${JSON.stringify(c.matchKey)};` : undefined, c.gates && c.gates.length > 0 ? ` gates: ${JSON.stringify(c.gates)};` : undefined, ` }`, ] .filter((line): line is string => line !== undefined) .join("\n"); } function routesPathFor(dtsOut: string): string { if (dtsOut.endsWith(".d.ts")) return `${dtsOut.slice(0, -5)}.routes.ts`; if (dtsOut.endsWith(".ts")) return dtsOut.replace(/\.ts$/, ".routes.ts"); return `${dtsOut}.routes.ts`; } async function fetchDescriptor( url: string | undefined, fetchFn: ClientFetch = globalThis.fetch.bind(globalThis), ): Promise { if (!url) { throw new Error("client add requires "); } const base = url.replace(/\/+$/, ""); const res = await fetchFn(`${base}/_oke/client.json`); if (!res.ok) { throw new Error(`Failed to fetch ${base}/_oke/client.json (${res.status})`); } const json: unknown = await res.json(); if ( json === null || typeof json !== "object" || !("routes" in json) || typeof (json as ClientDescriptor).routes !== "object" ) { throw new Error("Invalid client descriptor: missing routes"); } return json as ClientDescriptor; }