okengine 0.20.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/package.json +3 -7
  2. package/site/content/docs/client/calling.mdx +117 -29
  3. package/site/content/docs/client/index.mdx +3 -3
  4. package/site/content/docs/elements/flow/http.mdx +18 -15
  5. package/site/content/docs/elements/flow/index.mdx +26 -18
  6. package/site/content/docs/elements/gate/tenancy.mdx +1 -1
  7. package/site/content/docs/elements/store/files.mdx +11 -5
  8. package/site/content/docs/elements/store/index.mdx +2 -3
  9. package/site/content/docs/elements/store/kv.mdx +8 -2
  10. package/site/content/docs/elements/store/sql.mdx +9 -6
  11. package/site/content/docs/elements/vault/index.mdx +11 -8
  12. package/site/content/docs/elements/vault/secrets.mdx +6 -3
  13. package/site/content/docs/index.mdx +1 -1
  14. package/site/content/docs/recipes/rustfs.mdx +1 -1
  15. package/site/content/docs/reference/configuration.mdx +1 -1
  16. package/site/content/docs/reference/errors.mdx +199 -25
  17. package/site/content/docs/reference/fx.mdx +6 -1
  18. package/site/content/docs/understand/the-architecture.mdx +2 -2
  19. package/site/content/docs/understand/try-it.mdx +758 -25
  20. package/src/cli/dev-app-runner.ts +2 -1
  21. package/src/cli/dev.test.ts +105 -2
  22. package/src/cli/dev.ts +37 -2
  23. package/src/cli/start.ts +2 -1
  24. package/src/client/create.ts +14 -27
  25. package/src/client/explain.test.ts +252 -0
  26. package/src/client/explain.ts +272 -0
  27. package/src/client/live.test.ts +44 -0
  28. package/src/client/live.ts +44 -101
  29. package/src/client/notes-contract.test.ts +10 -0
  30. package/src/client/sse.ts +26 -68
  31. package/src/client/stream.ts +25 -67
  32. package/src/client/transport.test.ts +67 -0
  33. package/src/client/transport.ts +51 -112
  34. package/src/client/types.ts +17 -6
  35. package/src/client/wire.ts +119 -0
  36. package/src/client-react/live-resource.ts +6 -2
  37. package/src/compiler/aot.ts +3 -32
  38. package/src/compiler/dynamic.ts +13 -11
  39. package/src/compiler/interpret.ts +45 -0
  40. package/src/compiler/response.ts +17 -27
  41. package/src/console/server/invoke-user-flow.test.ts +8 -2
  42. package/src/console/server/invoke-user-flow.ts +12 -18
  43. package/src/console/server/security.gate.test.ts +1 -1
  44. package/src/console/ui-next/dist/assets/{access-page-DFLu0wTA.js → access-page-Bgt9bq2r.js} +1 -1
  45. package/src/console/ui-next/dist/assets/{agent-disclosure-DGscxaF5.js → agent-disclosure-B43CXZZR.js} +1 -1
  46. package/src/console/ui-next/dist/assets/{cache-glyph-BGmRZk7d.js → cache-glyph-92uM5MO7.js} +1 -1
  47. package/src/console/ui-next/dist/assets/{call-pii-button--feUYxvG.js → call-pii-button-DtGVPtcs.js} +1 -1
  48. package/src/console/ui-next/dist/assets/{collapsible-JWvpaiGY.js → collapsible-DN7l6zmC.js} +1 -1
  49. package/src/console/ui-next/dist/assets/{duration-tone-D9yCJG4n.js → duration-tone-BmIR9FV8.js} +1 -1
  50. package/src/console/ui-next/dist/assets/{flows-page-Bs6MD9GB.js → flows-page-BMHs-IzK.js} +1 -1
  51. package/src/console/ui-next/dist/assets/{highlighted-json-xH8MrEnv.js → highlighted-json-BlAEVgNW.js} +1 -1
  52. package/src/console/ui-next/dist/assets/{http-method-C4vB6ZIw.js → http-method-BDf7OAHv.js} +1 -1
  53. package/src/console/ui-next/dist/assets/{index-yTCY4AcS.js → index-BKpaes3n.js} +3 -3
  54. package/src/console/ui-next/dist/assets/{observability-page-BxJ3R6dU.js → observability-page-WnVLI-0j.js} +1 -1
  55. package/src/console/ui-next/dist/assets/{replica-lag-QRKB_IE8.js → replica-lag-B3GLNVfF.js} +1 -1
  56. package/src/console/ui-next/dist/assets/{request-meta-DqZ-fMu5.js → request-meta-DMbnAe3f.js} +1 -1
  57. package/src/console/ui-next/dist/assets/{store-page-Dixb6L7a.js → store-page-KvFDingJ.js} +1 -1
  58. package/src/console/ui-next/dist/assets/{trace-detail-sheet-CazhjtiU.js → trace-detail-sheet-Htk8Cm9t.js} +1 -1
  59. package/src/console/ui-next/dist/assets/{tree-expand-toggle-DlnqYKfr.js → tree-expand-toggle-CFMPWX4f.js} +1 -1
  60. package/src/console/ui-next/dist/assets/{units-page-BXTLjU2-.js → units-page-C4NdNuxP.js} +1 -1
  61. package/src/console/ui-next/dist/assets/{vault-page-39KR__bc.js → vault-page-CBYl0LW_.js} +1 -1
  62. package/src/console/ui-next/dist/index.html +1 -1
  63. package/src/docker/docker.test.ts +3 -3
  64. package/src/docker/images-config.test.ts +4 -4
  65. package/src/docker/stack-id.test.ts +1 -1
  66. package/src/elements/store/files-errors.test.ts +149 -0
  67. package/src/elements/store/files-errors.ts +189 -0
  68. package/src/elements/store/kv-errors.test.ts +98 -0
  69. package/src/elements/store/kv-errors.ts +139 -0
  70. package/src/elements/store/resource.ts +11 -7
  71. package/src/elements/store/runtime.ts +18 -13
  72. package/src/elements/store/sql-errors.test.ts +197 -0
  73. package/src/elements/store/sql-errors.ts +294 -0
  74. package/src/elements/store/sql-session.test.ts +52 -0
  75. package/src/elements/store/sql-session.ts +26 -4
  76. package/src/elements/store/store-errors.ts +47 -0
  77. package/src/http.ts +9 -1
  78. package/src/i18n/catalogs/ar.ts +18 -0
  79. package/src/i18n/catalogs/en.ts +18 -0
  80. package/src/index.ts +9 -1
  81. package/src/kernel/app.ts +23 -4
  82. package/src/kernel/builtin-errors.test.ts +117 -0
  83. package/src/kernel/builtin-errors.ts +129 -0
  84. package/src/kernel/call.test.ts +182 -0
  85. package/src/kernel/client-descriptor.test.ts +78 -0
  86. package/src/kernel/client-descriptor.ts +23 -0
  87. package/src/kernel/errors-text.ts +99 -0
  88. package/src/kernel/errors-vault.ts +16 -0
  89. package/src/kernel/errors.registry.test.ts +7 -0
  90. package/src/kernel/errors.ts +184 -159
  91. package/src/kernel/fail-helpers.ts +34 -0
  92. package/src/kernel/fx-sql-handle.ts +305 -0
  93. package/src/kernel/fx.test.ts +8 -0
  94. package/src/kernel/fx.ts +49 -335
  95. package/src/kernel/index.ts +12 -1
  96. package/src/kernel/json-result.ts +59 -0
  97. package/src/kernel/project-out.ts +6 -1
  98. package/src/okid-extended.ts +175 -0
  99. package/src/okid-shared.ts +103 -0
  100. package/src/okid.ts +30 -213
  101. package/src/release/build-lib.ts +9 -0
  102. package/src/runtime/dev-request-log.ts +29 -11
  103. package/src/term.test.ts +76 -0
  104. package/src/term.ts +166 -3
package/src/client/sse.ts CHANGED
@@ -4,33 +4,30 @@
4
4
  * @module
5
5
  */
6
6
 
7
- /** Callback for one parsed SSE data frame. */
8
- export type SseFrameHandler = (event: unknown, id: string | undefined) => void;
7
+ /** One parsed SSE data frame. */
8
+ export interface ParsedSseFrame {
9
+ readonly event: unknown;
10
+ readonly id: string | undefined;
11
+ }
9
12
 
10
13
  /**
11
- * Read an SSE response body until `[DONE]`, abort, or EOF.
14
+ * Shared frame pump. `[DONE]` and abort end the iterator without throwing.
12
15
  *
13
- * @param res - Fetch response (`text/event-stream`)
14
- * @param onEvent - Frame handler
16
+ * @param res - Fetch response
15
17
  * @param signal - Abort signal
16
18
  * @param onOpen - Called after content-type validation
17
19
  */
18
- export async function readSse(
20
+ export async function* iterateSseFrames(
19
21
  res: Response,
20
- onEvent: SseFrameHandler,
21
22
  signal: AbortSignal,
22
23
  onOpen?: () => void,
23
- ): Promise<void> {
24
+ ): AsyncGenerator<ParsedSseFrame> {
24
25
  if (signal.aborted) return;
25
26
  const ct = res.headers.get("content-type") ?? "";
26
- if (!res.ok || !res.body) {
27
+ if (!res.ok || !res.body || !ct.includes("text/event-stream")) {
27
28
  const text = await res.text().catch(() => "");
28
29
  throw sseError(res.status, text);
29
30
  }
30
- if (!ct.includes("text/event-stream")) {
31
- const text = await res.text().catch(() => "");
32
- throw sseError(res.status, text || `Expected text/event-stream, got ${ct || "none"}`);
33
- }
34
31
  onOpen?.();
35
32
  const reader = res.body.getReader();
36
33
  const dec = new TextDecoder();
@@ -39,14 +36,15 @@ export async function readSse(
39
36
  for (;;) {
40
37
  if (signal.aborted) return;
41
38
  const { done, value } = await reader.read();
42
- if (done) break;
39
+ if (done) return;
43
40
  buf += dec.decode(value, { stream: true });
44
41
  let sep = buf.indexOf("\n\n");
45
42
  while (sep >= 0) {
46
43
  const raw = buf.slice(0, sep);
47
44
  buf = buf.slice(sep + 2);
48
- const stop = dispatchFrame(raw, onEvent, signal);
49
- if (stop || signal.aborted) return;
45
+ const frame = parseFrame(raw);
46
+ if (frame === "done" || signal.aborted) return;
47
+ if (frame) yield frame;
50
48
  sep = buf.indexOf("\n\n");
51
49
  }
52
50
  }
@@ -56,65 +54,21 @@ export async function readSse(
56
54
  }
57
55
 
58
56
  /**
59
- * Yield SSE JSON frames as an async iterable until `[DONE]`.
57
+ * Parse one SSE block. `"done"` stops the pump; `null` skips empty blocks.
60
58
  *
61
- * @param res - Fetch response
62
- * @param signal - Abort signal
59
+ * @param raw - Text between blank lines
63
60
  */
64
- export async function* iterateSse(res: Response, signal: AbortSignal): AsyncGenerator<unknown> {
65
- const queue: unknown[] = [];
66
- let wake: (() => void) | undefined;
67
- let done = false;
68
- let err: unknown;
69
-
70
- const pump = readSse(
71
- res,
72
- (event) => {
73
- queue.push(event);
74
- wake?.();
75
- },
76
- signal,
77
- )
78
- .then(() => {
79
- done = true;
80
- wake?.();
81
- })
82
- .catch((e) => {
83
- err = e;
84
- done = true;
85
- wake?.();
86
- });
87
-
88
- try {
89
- for (;;) {
90
- while (queue.length > 0) {
91
- yield queue.shift();
92
- }
93
- if (done) break;
94
- await new Promise<void>((r) => {
95
- wake = r;
96
- });
97
- wake = undefined;
98
- }
99
- if (err) throw err;
100
- } finally {
101
- await pump.catch(() => {});
102
- }
103
- }
104
-
105
- function dispatchFrame(raw: string, onEvent: SseFrameHandler, signal: AbortSignal): boolean {
61
+ function parseFrame(raw: string): ParsedSseFrame | "done" | null {
106
62
  const dataLines: string[] = [];
107
63
  let id: string | undefined;
108
64
  for (const line of raw.split("\n")) {
109
65
  if (line.startsWith("id:")) id = line.slice(3).replace(/^ /, "");
110
66
  if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
111
67
  }
112
- if (dataLines.length === 0) return false;
68
+ if (dataLines.length === 0) return null;
113
69
  const data = dataLines.join("\n");
114
- if (data === "[DONE]") return true;
115
- if (signal.aborted) return true;
116
- onEvent(JSON.parse(data) as unknown, id);
117
- return false;
70
+ if (data === "[DONE]") return "done";
71
+ return { event: JSON.parse(data) as unknown, id };
118
72
  }
119
73
 
120
74
  /** Build an Error with optional HTTP status. */
@@ -124,8 +78,12 @@ export function sseError(status: number, body: string): Error {
124
78
  try {
125
79
  const json: unknown = JSON.parse(body);
126
80
  if (json !== null && typeof json === "object" && "error" in json) {
127
- const err = (json as { error?: { code?: string; data?: { message?: string } } }).error;
128
- message = err?.data?.message ?? err?.code ?? message;
81
+ const err = (
82
+ json as {
83
+ error?: { code?: string; message?: string; data?: { message?: string } };
84
+ }
85
+ ).error;
86
+ message = err?.message ?? err?.data?.message ?? err?.code ?? message;
129
87
  }
130
88
  } catch {
131
89
  message = body.slice(0, 200);
@@ -4,8 +4,17 @@
4
4
  * @module
5
5
  */
6
6
 
7
- import { iterateSse, sseError } from "./sse.ts";
8
- import type { ClientFetch, ClientHeaders, ClientOptions, ClientRouteMap } from "./types.ts";
7
+ import { iterateSseFrames, sseError } from "./sse.ts";
8
+ import type { ClientFetch, ClientOptions, ClientRouteMap } from "./types.ts";
9
+ import {
10
+ applyAuthHeader,
11
+ applyHeaderBag,
12
+ interpolatePath,
13
+ methodAndPath,
14
+ resolveHeaders,
15
+ toQuery,
16
+ walkContracts,
17
+ } from "./wire.ts";
9
18
 
10
19
  /** Flow id → REST route for stream-only (non-live) SSE. */
11
20
  export type StreamByFlow = Readonly<
@@ -19,20 +28,12 @@ export type StreamByFlow = Readonly<
19
28
  */
20
29
  export function flattenStreamRoutes($routes: ClientRouteMap | undefined): StreamByFlow {
21
30
  const out: Record<string, { readonly method: string; readonly path: string }> = {};
22
- if (!$routes) return out;
23
- for (const [unit, flows] of Object.entries($routes)) {
24
- if (!flows || typeof flows !== "object") continue;
25
- for (const [flow, contract] of Object.entries(flows)) {
26
- if (!contract || typeof contract !== "object") continue;
27
- if (!("stream" in contract) || contract.stream !== true) continue;
28
- if ("live" in contract && typeof contract.live === "string") continue;
29
- const method = "method" in contract ? contract.method : undefined;
30
- const path = "path" in contract ? contract.path : undefined;
31
- if (typeof method === "string" && typeof path === "string") {
32
- out[`${unit}.${flow}`] = { method, path };
33
- }
34
- }
35
- }
31
+ walkContracts($routes, (unit, flow, contract) => {
32
+ if (!("stream" in contract) || contract.stream !== true) return;
33
+ if ("live" in contract && typeof contract.live === "string") return;
34
+ const route = methodAndPath(contract);
35
+ if (route) out[`${unit}.${flow}`] = route;
36
+ });
36
37
  return out;
37
38
  }
38
39
 
@@ -51,36 +52,21 @@ export async function* openStream(
51
52
  opts: ClientOptions,
52
53
  ): AsyncGenerator<unknown> {
53
54
  const fetchFn: ClientFetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
55
+ const signal = opts.signal ?? new AbortController().signal;
54
56
  let refreshed = false;
55
57
  for (;;) {
56
- const ctrl = new AbortController();
57
- const parent = opts.signal;
58
- if (parent) {
59
- if (parent.aborted) {
60
- ctrl.abort();
61
- } else {
62
- parent.addEventListener("abort", () => ctrl.abort(), { once: true });
63
- }
64
- }
65
58
  const { url, method, body } = streamRequest(base, route.method, route.path, input);
66
59
  const headers = new Headers({ accept: "text/event-stream" });
67
- const extra = typeof opts.headers === "function" ? await opts.headers() : opts.headers;
68
- applyHeaders(headers, extra);
60
+ applyHeaderBag(headers, await resolveHeaders(opts));
69
61
  if (body !== undefined && !headers.has("content-type")) {
70
62
  headers.set("content-type", "application/json");
71
63
  }
72
- const token =
73
- opts.auth && "getToken" in opts.auth && typeof opts.auth.getToken === "function"
74
- ? await opts.auth.getToken()
75
- : undefined;
76
- if (token && !headers.has("authorization")) {
77
- headers.set("authorization", `Bearer ${token}`);
78
- }
64
+ await applyAuthHeader(headers, opts);
79
65
  const res = await fetchFn(url, {
80
66
  method,
81
67
  headers,
82
68
  body,
83
- signal: ctrl.signal,
69
+ signal,
84
70
  ...(opts.credentials !== undefined ? { credentials: opts.credentials } : {}),
85
71
  });
86
72
  if (
@@ -98,7 +84,7 @@ export async function* openStream(
98
84
  const text = await res.text().catch(() => "");
99
85
  throw sseError(res.status, text);
100
86
  }
101
- yield* iterateSse(res, ctrl.signal);
87
+ for await (const frame of iterateSseFrames(res, signal)) yield frame.event;
102
88
  return;
103
89
  }
104
90
  }
@@ -109,39 +95,11 @@ function streamRequest(
109
95
  path: string,
110
96
  input: unknown,
111
97
  ): { url: string; method: string; body: string | undefined } {
112
- const params =
113
- input !== null && typeof input === "object" ? (input as Record<string, unknown>) : {};
114
- let pathOut = path;
115
- const rest: Record<string, unknown> = {};
116
- for (const [k, v] of Object.entries(params)) {
117
- const token = `:${k}`;
118
- if (pathOut.includes(token)) {
119
- pathOut = pathOut.replaceAll(token, encodeURIComponent(String(v)));
120
- } else {
121
- rest[k] = v;
122
- }
123
- }
98
+ const { path: pathOut, rest } = interpolatePath(path, input);
124
99
  const upper = method.toUpperCase();
125
- let body: string | undefined;
126
100
  if (upper === "GET" || upper === "HEAD") {
127
- const query: string[] = [];
128
- for (const [k, v] of Object.entries(rest)) {
129
- if (v !== undefined) {
130
- query.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
131
- }
132
- }
133
- const qs = query.length ? `?${query.join("&")}` : "";
134
- return { url: `${base}${pathOut}${qs}`, method: upper, body: undefined };
101
+ return { url: `${base}${pathOut}${toQuery(rest)}`, method: upper, body: undefined };
135
102
  }
136
- body = JSON.stringify(Object.keys(rest).length > 0 ? rest : (input ?? {}));
103
+ const body = JSON.stringify(Object.keys(rest).length > 0 ? rest : (input ?? {}));
137
104
  return { url: `${base}${pathOut}`, method: upper, body };
138
105
  }
139
-
140
- function applyHeaders(headers: Headers, extra: ClientHeaders | undefined): void {
141
- if (!extra) return;
142
- if (Array.isArray(extra)) {
143
- for (const [k, v] of extra) headers.set(k, v);
144
- } else {
145
- for (const [k, v] of Object.entries(extra)) headers.set(k, v);
146
- }
147
- }
@@ -46,6 +46,11 @@ describe("transport — retry", () => {
46
46
  const { error } = await api.sys.ping();
47
47
  expect(error?.code).toBe("TransportError");
48
48
  expect(n).toBe(2);
49
+ if (error?.code === "TransportError") {
50
+ expect(typeof error.message).toBe("string");
51
+ expect(error.message.length).toBeGreaterThan(0);
52
+ expect(error.message).toBe(error.data.message);
53
+ }
49
54
  });
50
55
 
51
56
  test("structured 5xx envelope is returned (not TransportError)", async () => {
@@ -139,6 +144,68 @@ describe("transport — auth refresh", () => {
139
144
  expect(error?.code).toBe("TransportError");
140
145
  if (error?.code === "TransportError") {
141
146
  expect(error.data.status).toBe(401);
147
+ expect(error.message).toBe(error.data.message);
148
+ expect(error.message).toBe("Invalid JSON (401)");
149
+ }
150
+ });
151
+ });
152
+
153
+ describe("transport — TransportError message contract", () => {
154
+ test("empty error body populates matching message fields", async () => {
155
+ const api = createClient<PingApp>("http://app.test", {
156
+ fetch: async () => new Response("", { status: 404 }),
157
+ });
158
+ const { error } = await api.sys.ping();
159
+ expect(error?.code).toBe("TransportError");
160
+ if (error?.code === "TransportError") {
161
+ expect(error.message).toBe("HTTP 404");
162
+ expect(error.message).toBe(error.data.message);
163
+ expect(error.data.status).toBe(404);
164
+ }
165
+ });
166
+
167
+ test("malformed JSON populates matching message fields", async () => {
168
+ const api = createClient<PingApp>("http://app.test", {
169
+ fetch: async () =>
170
+ new Response("not-json", {
171
+ status: 400,
172
+ headers: { "content-type": "application/json" },
173
+ }),
174
+ });
175
+ const { error } = await api.sys.ping();
176
+ expect(error?.code).toBe("TransportError");
177
+ if (error?.code === "TransportError") {
178
+ expect(error.message).toBe("Invalid JSON (400)");
179
+ expect(error.message).toBe(error.data.message);
180
+ expect(error.data.status).toBe(400);
181
+ }
182
+ });
183
+
184
+ test("incomplete proxy path populates matching message fields", async () => {
185
+ const api = createClient<PingApp>("http://app.test", {
186
+ fetch: async () => Response.json({ data: { ok: true }, error: null }),
187
+ });
188
+ // path stops at unit — api.sys() is incomplete
189
+ const result = await (
190
+ api.sys as unknown as () => Promise<{
191
+ error: { code: string; message: string; data: { message: string } };
192
+ }>
193
+ )();
194
+ expect(result.error.code).toBe("TransportError");
195
+ expect(result.error.message).toMatch(/Incomplete path/);
196
+ expect(result.error.message).toBe(result.error.data.message);
197
+ });
198
+
199
+ test("binary error path populates matching message fields", async () => {
200
+ const api = createClient<PingApp>("http://app.test", {
201
+ fetch: async () => new Response("nope", { status: 403 }),
202
+ });
203
+ const { error } = await api.sys.ping({ response: "blob" });
204
+ expect(error?.code).toBe("TransportError");
205
+ if (error?.code === "TransportError") {
206
+ expect(error.message).toBe("HTTP 403");
207
+ expect(error.message).toBe(error.data.message);
208
+ expect(error.data.status).toBe(403);
142
209
  }
143
210
  });
144
211
  });
@@ -12,6 +12,13 @@ import type {
12
12
  ClientHeaders,
13
13
  ClientOptions,
14
14
  } from "./types.ts";
15
+ import {
16
+ applyAuthHeader,
17
+ applyHeaderBag,
18
+ interpolatePath,
19
+ resolveHeaders,
20
+ toQuery,
21
+ } from "./wire.ts";
15
22
 
16
23
  /** Per-call transport options (binary decode, abort). */
17
24
  export interface TransportCallOptions {
@@ -83,15 +90,7 @@ export function createTransport(base: string, opts: ClientOptions = {}): Transpo
83
90
  } catch (err) {
84
91
  const transient = isTransient(err);
85
92
  if (!transient || attempt >= retries) {
86
- return {
87
- data: null,
88
- error: {
89
- code: "TransportError",
90
- data: {
91
- message: err instanceof Error ? err.message : String(err),
92
- },
93
- },
94
- };
93
+ return transportEnvelope(err instanceof Error ? err.message : String(err));
95
94
  }
96
95
  await sleep(delay);
97
96
  delay *= backoff;
@@ -102,6 +101,25 @@ export function createTransport(base: string, opts: ClientOptions = {}): Transpo
102
101
  };
103
102
  }
104
103
 
104
+ /**
105
+ * Build a {@link ClientEnvelope} for a transport / protocol failure.
106
+ *
107
+ * Guarantees `error.message === error.data.message`.
108
+ *
109
+ * @param message - Human-readable failure text
110
+ * @param status - Optional HTTP status
111
+ */
112
+ export function transportEnvelope(message: string, status?: number): ClientEnvelope {
113
+ return {
114
+ data: null,
115
+ error: {
116
+ code: "TransportError",
117
+ message,
118
+ data: status !== undefined ? { message, status } : { message },
119
+ },
120
+ };
121
+ }
122
+
105
123
  function normalizeCallOpts(
106
124
  headersOrOpts: ClientHeaders | TransportCallOptions | undefined,
107
125
  ): TransportCallOptions {
@@ -143,37 +161,16 @@ async function once(
143
161
  : rpcRequest(base, key, input);
144
162
 
145
163
  const headers = new Headers();
146
- const extra = typeof opts.headers === "function" ? await opts.headers() : opts.headers;
147
- if (Array.isArray(extra)) {
148
- for (const [k, v] of extra) headers.set(k, v);
149
- } else if (extra) {
150
- for (const [k, v] of Object.entries(extra)) headers.set(k, v);
151
- }
152
- if (Array.isArray(callHeaders)) {
153
- for (const [k, v] of callHeaders) headers.set(k, v);
154
- } else if (callHeaders) {
155
- for (const [k, v] of Object.entries(callHeaders)) headers.set(k, v);
156
- }
164
+ applyHeaderBag(headers, await resolveHeaders(opts));
165
+ applyHeaderBag(headers, callHeaders);
157
166
  if (body !== undefined && !headers.has("content-type") && typeof body === "string") {
158
167
  headers.set("content-type", "application/json");
159
168
  }
169
+ await applyAuthHeader(headers, opts);
160
170
 
161
- const token =
162
- opts.auth && "getToken" in opts.auth && typeof opts.auth.getToken === "function"
163
- ? await opts.auth.getToken()
164
- : undefined;
165
- if (token && !headers.has("authorization")) {
166
- headers.set("authorization", `Bearer ${token}`);
167
- }
168
-
169
- const signals: AbortSignal[] = [];
170
- if (opts.signal) signals.push(opts.signal);
171
- if (opts.timeout !== undefined) {
172
- const t = AbortSignal.timeout(opts.timeout);
173
- signals.push(t);
174
- }
171
+ const timeout = opts.timeout !== undefined ? AbortSignal.timeout(opts.timeout) : undefined;
175
172
  const signal =
176
- signals.length === 0 ? undefined : signals.length === 1 ? signals[0] : AbortSignal.any(signals);
173
+ opts.signal && timeout ? AbortSignal.any([opts.signal, timeout]) : (opts.signal ?? timeout);
177
174
 
178
175
  return await fetchFn(url, {
179
176
  method,
@@ -225,47 +222,32 @@ function restRequest(
225
222
  if (isRawBody(input)) {
226
223
  return { url: `${base}${path}`, method: method.toUpperCase(), body: input };
227
224
  }
228
- const params =
229
- input !== null && typeof input === "object" ? (input as Record<string, unknown>) : {};
230
- let pathOut = path;
231
- const query: string[] = [];
232
- const rest: Record<string, unknown> = {};
233
-
234
- for (const [k, v] of Object.entries(params)) {
235
- const token = `:${k}`;
236
- if (pathOut.includes(token)) {
237
- pathOut = pathOut.replaceAll(token, encodeURIComponent(String(v)));
238
- } else {
239
- rest[k] = v;
240
- }
241
- }
225
+ const interpolated = interpolatePath(path, input);
226
+ const pathOut = interpolated.path;
227
+ const rest = interpolated.rest;
242
228
 
243
229
  const upper = method.toUpperCase();
230
+ const hasRest = Object.keys(rest).length > 0;
244
231
  let body: ClientBodyInit | undefined;
232
+ let qs = "";
245
233
  if (upper === "GET" || upper === "HEAD") {
246
- for (const [k, v] of Object.entries(rest)) {
247
- if (v !== undefined) {
248
- query.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
249
- }
250
- }
234
+ qs = toQuery(rest);
251
235
  } else if (upper === "QUERY") {
252
236
  // RFC 10008 QUERY always carries JSON content (empty object when only path params).
253
- body = JSON.stringify(Object.keys(rest).length > 0 ? rest : {});
254
- } else if (Object.keys(rest).length > 0 || path === pathOut) {
255
- body = JSON.stringify(Object.keys(rest).length > 0 ? rest : (input ?? {}));
237
+ body = JSON.stringify(hasRest ? rest : {});
238
+ } else if (hasRest || path === pathOut) {
239
+ body = JSON.stringify(hasRest ? rest : (input ?? {}));
256
240
  }
257
241
 
258
- const qs = query.length ? `?${query.join("&")}` : "";
259
242
  return { url: `${base}${pathOut}${qs}`, method: upper, body };
260
243
  }
261
244
 
262
245
  function isRawBody(input: unknown): input is ClientBodyInit {
263
- if (input === null || input === undefined) return false;
264
- if (typeof Blob !== "undefined" && input instanceof Blob) return true;
265
- if (typeof FormData !== "undefined" && input instanceof FormData) return true;
266
- if (typeof ArrayBuffer !== "undefined" && input instanceof ArrayBuffer) return true;
267
- if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(input)) return true;
268
- if (typeof ReadableStream !== "undefined" && input instanceof ReadableStream) return true;
246
+ if (input instanceof Blob) return true;
247
+ if (input instanceof FormData) return true;
248
+ if (input instanceof ArrayBuffer) return true;
249
+ if (ArrayBuffer.isView(input)) return true;
250
+ if (input instanceof ReadableStream) return true;
269
251
  return false;
270
252
  }
271
253
 
@@ -277,29 +259,14 @@ async function decode(res: Response): Promise<ClientEnvelope> {
277
259
  const text = await res.text();
278
260
  if (!text) {
279
261
  if (res.ok) return { data: undefined, error: null };
280
- return {
281
- data: null,
282
- error: {
283
- code: "TransportError",
284
- data: { message: `HTTP ${res.status}`, status: res.status },
285
- },
286
- };
262
+ return transportEnvelope(`HTTP ${res.status}`, res.status);
287
263
  }
288
264
 
289
265
  let json: unknown;
290
266
  try {
291
267
  json = JSON.parse(text);
292
268
  } catch {
293
- return {
294
- data: null,
295
- error: {
296
- code: "TransportError",
297
- data: {
298
- message: `Invalid JSON (${res.status})`,
299
- status: res.status,
300
- },
301
- },
302
- };
269
+ return transportEnvelope(`Invalid JSON (${res.status})`, res.status);
303
270
  }
304
271
 
305
272
  if (json !== null && typeof json === "object" && "data" in json && "error" in json) {
@@ -310,47 +277,19 @@ async function decode(res: Response): Promise<ClientEnvelope> {
310
277
  return { data: json, error: null };
311
278
  }
312
279
 
313
- return {
314
- data: null,
315
- error: {
316
- code: "TransportError",
317
- data: { message: `HTTP ${res.status}`, status: res.status },
318
- },
319
- };
280
+ return transportEnvelope(`HTTP ${res.status}`, res.status);
320
281
  }
321
282
 
322
283
  async function decodeBinary(res: Response, mode: "blob" | "arrayBuffer"): Promise<ClientEnvelope> {
323
284
  if (!res.ok) {
324
- const structured = await decodeIfEnvelopeClone(res);
285
+ const structured = await decodeIfEnvelope(res);
325
286
  if (structured) return structured;
326
- return {
327
- data: null,
328
- error: {
329
- code: "TransportError",
330
- data: { message: `HTTP ${res.status}`, status: res.status },
331
- },
332
- };
287
+ return transportEnvelope(`HTTP ${res.status}`, res.status);
333
288
  }
334
289
  const data = mode === "blob" ? await res.blob() : await res.arrayBuffer();
335
290
  return { data, error: null };
336
291
  }
337
292
 
338
- /** Try JSON envelope from an error response without assuming the body is reusable. */
339
- async function decodeIfEnvelopeClone(res: Response): Promise<ClientEnvelope | null> {
340
- const text = await res.text();
341
- if (!text) return null;
342
- let json: unknown;
343
- try {
344
- json = JSON.parse(text);
345
- } catch {
346
- return null;
347
- }
348
- if (json !== null && typeof json === "object" && "data" in json && "error" in json) {
349
- return json as ClientEnvelope;
350
- }
351
- return null;
352
- }
353
-
354
293
  function isTransient(err: unknown): boolean {
355
294
  if (!(err instanceof Error)) return false;
356
295
  if (err.name === "AbortError") return false;
@@ -5,6 +5,8 @@
5
5
  * Local / separate-repo: augment {@link Register} so `createClient(url)` needs no import.
6
6
  */
7
7
 
8
+ import type { BuiltinErrorMap } from "../kernel/builtin-errors.ts";
9
+
8
10
  /**
9
11
  * One flow's client contract. `in` / `out` / `errors` are phantom (type-only).
10
12
  *
@@ -125,7 +127,8 @@ export interface TransportError {
125
127
  readonly message: string;
126
128
  readonly status?: number;
127
129
  };
128
- readonly message?: string;
130
+ /** Same string as {@link TransportError.data.message}. */
131
+ readonly message: string;
129
132
  }
130
133
 
131
134
  /** Next / previous list request — TanStack `pageParam` / URL bag. */
@@ -431,12 +434,20 @@ type ContractIn<C> = "in" extends keyof C
431
434
  /** Pull output from a contract shape. */
432
435
  type ContractOut<C> = "out" extends keyof C ? NonNullable<C["out"]> : unknown;
433
436
 
434
- /** Pull error map from a contract shape. */
437
+ /**
438
+ * Pull error map from a contract shape. Built-in codes are always on; declared
439
+ * keys win. A missing `errors` bag (phantom `Record<string, unknown>`) is
440
+ * treated as unspecified — not as an index signature that wipes the builtins.
441
+ */
435
442
  type ContractErrors<C> = "errors" extends keyof C
436
- ? NonNullable<C["errors"]> extends Record<string, unknown>
437
- ? NonNullable<C["errors"]>
438
- : Record<string, never>
439
- : Record<string, never>;
443
+ ? NonNullable<C["errors"]> extends infer E
444
+ ? E extends Record<string, unknown>
445
+ ? string extends keyof E
446
+ ? BuiltinErrorMap
447
+ : Omit<BuiltinErrorMap, keyof E> & E
448
+ : BuiltinErrorMap
449
+ : BuiltinErrorMap
450
+ : BuiltinErrorMap;
440
451
 
441
452
  /**
442
453
  * Typed client proxy derived from a route map.