okengine 0.21.0 → 0.22.0

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 (160) hide show
  1. package/AGENTS.md +4 -4
  2. package/LICENSE +202 -0
  3. package/NOTICE +2 -0
  4. package/README.md +15 -9
  5. package/manifest.v1.schema.json +10 -0
  6. package/package.json +22 -25
  7. package/site/content/docs/ai/skills.mdx +1 -1
  8. package/site/content/docs/client/calling.mdx +27 -19
  9. package/site/content/docs/client/index.mdx +1 -1
  10. package/site/content/docs/elements/channel/email.mdx +1 -1
  11. package/site/content/docs/elements/channel/index.mdx +1 -1
  12. package/site/content/docs/elements/flow/http.mdx +4 -4
  13. package/site/content/docs/elements/flow/index.mdx +3 -3
  14. package/site/content/docs/elements/flow/meta.json +1 -1
  15. package/site/content/docs/elements/gate/tenancy.mdx +1 -1
  16. package/site/content/docs/elements/vault/rotation.mdx +15 -0
  17. package/site/content/docs/plugins/otp.mdx +31 -31
  18. package/site/content/docs/recipes/mailpit.mdx +1 -1
  19. package/site/content/docs/recipes/meilisearch.mdx +1 -1
  20. package/site/content/docs/recipes/pgdog.mdx +1 -1
  21. package/site/content/docs/reference/configuration.mdx +2 -2
  22. package/site/content/docs/reference/errors.mdx +12 -2
  23. package/site/content/docs/reference/fx.mdx +28 -15
  24. package/site/content/docs/reference/idempotency.mdx +192 -0
  25. package/site/content/docs/reference/index.mdx +6 -1
  26. package/site/content/docs/reference/meta.json +2 -1
  27. package/site/content/docs/understand/meta.json +1 -1
  28. package/site/content/docs/{elements/flow → understand}/routing.mdx +11 -10
  29. package/site/content/docs/understand/the-architecture.mdx +14 -12
  30. package/site/content/docs/understand/try-it.mdx +3 -3
  31. package/src/cli/competitor-mention-removal.test.ts +3 -3
  32. package/src/cli/dev.test.ts +105 -2
  33. package/src/cli/dev.ts +37 -2
  34. package/src/cli/docker-cli.test.ts +1 -1
  35. package/src/cli/index.ts +9 -1
  36. package/src/cli/load-config.images.test.ts +10 -10
  37. package/src/cli/load-config.ts +1 -1
  38. package/src/cli/vault-cmd.test.ts +23 -0
  39. package/src/cli/vault-cmd.ts +7 -0
  40. package/src/client/create.ts +31 -27
  41. package/src/client/live.ts +44 -101
  42. package/src/client/sse.ts +20 -66
  43. package/src/client/stream.ts +25 -67
  44. package/src/client/transport.test.ts +225 -0
  45. package/src/client/transport.ts +158 -90
  46. package/src/client/types.ts +29 -3
  47. package/src/client/wire.ts +119 -0
  48. package/src/compiler/aot.ts +3 -32
  49. package/src/compiler/dynamic.ts +13 -11
  50. package/src/compiler/effects-infer.ts +726 -16
  51. package/src/compiler/extract.test.ts +37 -0
  52. package/src/compiler/extract.ts +113 -12
  53. package/src/compiler/fixtures/skyport.expected.json +24 -0
  54. package/src/compiler/fx-follow.test.ts +164 -0
  55. package/src/compiler/fx-index.ts +399 -0
  56. package/src/compiler/interpret.ts +45 -0
  57. package/src/console/server/flows-invoke.test.ts +2 -2
  58. package/src/console/ui-next/dist/assets/{access-page-tIbsiphz.js → access-page-Ze6ckjdd.js} +2 -2
  59. package/src/console/ui-next/dist/assets/agent-disclosure-Bkohc_8X.js +1 -0
  60. package/src/console/ui-next/dist/assets/{cache-glyph-BCC-DxKT.js → cache-glyph-rL0lHxat.js} +1 -1
  61. package/src/console/ui-next/dist/assets/{call-pii-button-jOmYISdc.js → call-pii-button-Deo4PP18.js} +1 -1
  62. package/src/console/ui-next/dist/assets/{collapsible-DC2xNaAb.js → collapsible-CoJ6amHf.js} +1 -1
  63. package/src/console/ui-next/dist/assets/{copy-inline-button-CAYD18cr.js → copy-inline-button-CWG5_ZOh.js} +1 -1
  64. package/src/console/ui-next/dist/assets/{detail-header-DVWNjWjg.js → detail-header-b_ZzIsmU.js} +1 -1
  65. package/src/console/ui-next/dist/assets/{dropdown-menu-4h2LOVXM.js → dropdown-menu-nwXEO1Ac.js} +1 -1
  66. package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +9 -0
  67. package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +1 -0
  68. package/src/console/ui-next/dist/assets/{explorer-empty-CJs5A-wm.js → explorer-empty-C8sSCqYT.js} +1 -1
  69. package/src/console/ui-next/dist/assets/{flows-page-gT1lsWQK.js → flows-page-D2E7BtKK.js} +1 -1
  70. package/src/console/ui-next/dist/assets/{highlighted-json-C2GZEJNI.js → highlighted-json-CPYBzDh0.js} +1 -1
  71. package/src/console/ui-next/dist/assets/{http-method-BdYjcIrD.js → http-method-DTPevKmM.js} +1 -1
  72. package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +58 -0
  73. package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +4 -0
  74. package/src/console/ui-next/dist/assets/{replica-lag-C4QdAF7J.js → replica-lag-BM27lXld.js} +3 -3
  75. package/src/console/ui-next/dist/assets/{request-meta-C43DHyld.js → request-meta-BjFT7DNl.js} +1 -1
  76. package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +1 -0
  77. package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +41 -0
  78. package/src/console/ui-next/dist/assets/{trace-detail-sheet-Dcpo4Us_.js → trace-detail-sheet-Cmitq3da.js} +2 -2
  79. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CKJTuv43.js → tree-expand-toggle-CbDIB-7x.js} +2 -2
  80. package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +1 -0
  81. package/src/console/ui-next/dist/assets/{use-vault-list-CT4-gajj.js → use-vault-list-Cs45Czkt.js} +1 -1
  82. package/src/console/ui-next/dist/assets/{vault-page-BwZ9YjTW.js → vault-page-BOMr0go9.js} +2 -2
  83. package/src/console/ui-next/dist/index.html +2 -2
  84. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +9 -0
  85. package/src/docker/docker.test.ts +13 -13
  86. package/src/docker/images-config.test.ts +12 -12
  87. package/src/docker/stack-id.test.ts +1 -1
  88. package/src/drivers/journal-postgres.ts +168 -3
  89. package/src/drivers/meilisearch.ts +2 -0
  90. package/src/drivers/vault-builtin.test.ts +10 -0
  91. package/src/drivers/vault-builtin.ts +13 -1
  92. package/src/drivers/vault-remote-bag.ts +5 -1
  93. package/src/drivers/vault-types.ts +6 -1
  94. package/src/elements/store/emit-drizzle.ts +2 -2
  95. package/src/elements/store/live-default.test.ts +2 -0
  96. package/src/elements/store/live-http.test.ts +1 -0
  97. package/src/elements/store/resource-list-docs.test.ts +2 -2
  98. package/src/elements/store/resource.test.ts +3 -3
  99. package/src/elements/store/schema-decl.test.ts +1 -0
  100. package/src/elements/store/sql-condition.ts +24 -7
  101. package/src/elements/store/sql-session.ts +20 -4
  102. package/src/elements/store/upsert-app.test.ts +1 -1
  103. package/src/elements/vault/audit.test.ts +137 -0
  104. package/src/elements/vault/audit.ts +106 -13
  105. package/src/elements/vault/boot-chain.ts +9 -1
  106. package/src/elements/vault/builtin-adapter.ts +33 -3
  107. package/src/i18n/catalogs/ar.ts +4 -0
  108. package/src/i18n/catalogs/en.ts +4 -0
  109. package/src/kernel/abort-scope.ts +33 -2
  110. package/src/kernel/app.ts +200 -20
  111. package/src/kernel/auto-cache.test.ts +6 -6
  112. package/src/kernel/boot-bind/vault.ts +1 -0
  113. package/src/kernel/boot.ts +19 -1
  114. package/src/kernel/builtin-errors.ts +8 -0
  115. package/src/kernel/client-descriptor.test.ts +78 -0
  116. package/src/kernel/client-descriptor.ts +24 -0
  117. package/src/kernel/concurrency.test.ts +33 -0
  118. package/src/kernel/effects-stamping.test.ts +2 -2
  119. package/src/kernel/errors-compiler.ts +20 -0
  120. package/src/kernel/errors-text.ts +99 -0
  121. package/src/kernel/errors.ts +90 -138
  122. package/src/kernel/external-effects.test.ts +36 -0
  123. package/src/kernel/flow.ts +24 -0
  124. package/src/kernel/fx-fetch.ts +5 -1
  125. package/src/kernel/fx-sql-handle.ts +305 -0
  126. package/src/kernel/fx.ts +42 -330
  127. package/src/kernel/idempotency-store.ts +578 -0
  128. package/src/kernel/idempotency.test.ts +556 -0
  129. package/src/kernel/idempotency.ts +304 -0
  130. package/src/kernel/index.ts +1 -0
  131. package/src/kernel/journal.ts +56 -0
  132. package/src/kernel/json-result.ts +59 -0
  133. package/src/kernel/project-out.ts +6 -1
  134. package/src/manifest/types.ts +8 -0
  135. package/src/okid-extended.ts +175 -0
  136. package/src/okid-shared.ts +103 -0
  137. package/src/okid.ts +30 -213
  138. package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
  139. package/src/plugins/otp.test.ts +65 -1
  140. package/src/plugins/otp.ts +59 -7
  141. package/src/release/absolute-regression.test.ts +63 -0
  142. package/src/release/build-lib.ts +9 -0
  143. package/src/release/http-graph.test.ts +25 -0
  144. package/src/release/http-graph.ts +90 -0
  145. package/src/release/index.ts +3 -0
  146. package/src/release/limits.ts +17 -2
  147. package/src/release/measure.ts +105 -14
  148. package/src/test/create-test-app.test.ts +3 -1
  149. package/src/test/create-test-app.ts +40 -0
  150. package/src/test/live-signals.test.ts +1 -0
  151. package/src/test/provisions.integration.test.ts +1 -0
  152. package/src/test/tenant-isolation.test.ts +1 -0
  153. package/src/console/ui-next/dist/assets/agent-disclosure-CSKumwS2.js +0 -1
  154. package/src/console/ui-next/dist/assets/duration-tone-CwoV56jn.js +0 -9
  155. package/src/console/ui-next/dist/assets/element-icons-BI8cJgdh.js +0 -1
  156. package/src/console/ui-next/dist/assets/index-Cul17AcV.js +0 -63
  157. package/src/console/ui-next/dist/assets/observability-page-oY9vdYBk.js +0 -4
  158. package/src/console/ui-next/dist/assets/shortcut-keys-3ILd8oGn.js +0 -1
  159. package/src/console/ui-next/dist/assets/store-page-CL0D9dOq.js +0 -41
  160. package/src/console/ui-next/dist/assets/units-page-1PlT19ft.js +0 -1
@@ -4,7 +4,16 @@
4
4
 
5
5
  import type { ClientFetch, ClientOptions, ClientRouteMap, FlowContract } from "./types.ts";
6
6
  import type { LiveHandlers, LiveUnsubscribe } from "./types.ts";
7
- import { readSse, sseError } from "./sse.ts";
7
+ import { iterateSseFrames, sseError } from "./sse.ts";
8
+ import {
9
+ applyAuthHeader,
10
+ applyHeaderBag,
11
+ interpolatePath,
12
+ methodAndPath,
13
+ resolveHeaders,
14
+ toQuery,
15
+ walkContracts,
16
+ } from "./wire.ts";
8
17
 
9
18
  /** One HTTP exposure of a live signal. */
10
19
  export interface LiveExposure {
@@ -31,24 +40,16 @@ export function flattenLiveRoutes($routes: ClientRouteMap | undefined): {
31
40
  } {
32
41
  const bySignal: Record<string, LiveExposure[]> = {};
33
42
  const byFlow: Record<string, LiveExposure> = {};
34
- if (!$routes) return { bySignal, byFlow };
35
- for (const [unit, flows] of Object.entries($routes)) {
36
- if (!flows || typeof flows !== "object") continue;
37
- for (const [flow, contract] of Object.entries(flows)) {
38
- if (!contract || typeof contract !== "object") continue;
39
- const live = liveName(contract);
40
- const method = "method" in contract ? contract.method : undefined;
41
- const path = "path" in contract ? contract.path : undefined;
42
- if (typeof live !== "string" || typeof method !== "string" || typeof path !== "string") {
43
- continue;
44
- }
45
- const matchKey = matchKeyOf(contract);
46
- const id = `${unit}.${flow}`;
47
- const exposure: LiveExposure = { flow: id, method, path, matchKey };
48
- (bySignal[live] ??= []).push(exposure);
49
- byFlow[id] = exposure;
50
- }
51
- }
43
+ walkContracts($routes, (unit, flow, contract) => {
44
+ const live = liveName(contract);
45
+ const route = methodAndPath(contract);
46
+ if (typeof live !== "string" || !route) return;
47
+ const matchKey = matchKeyOf(contract);
48
+ const id = `${unit}.${flow}`;
49
+ const exposure: LiveExposure = { flow: id, method: route.method, path: route.path, matchKey };
50
+ (bySignal[live] ??= []).push(exposure);
51
+ byFlow[id] = exposure;
52
+ });
52
53
  return { bySignal, byFlow };
53
54
  }
54
55
 
@@ -87,11 +88,7 @@ export function pickLiveExposure(
87
88
  ): LiveExposure {
88
89
  if (via) {
89
90
  const hit = exposures.find((e) => e.flow === via);
90
- if (!hit) {
91
- throw new Error(
92
- `Unknown live via "${via}". Known: ${exposures.map((e) => e.flow).join(", ")}`,
93
- );
94
- }
91
+ if (!hit) throw new Error(`Unknown live via "${via}"`);
95
92
  return hit;
96
93
  }
97
94
  const keys =
@@ -100,19 +97,12 @@ export function pickLiveExposure(
100
97
  : [];
101
98
  const candidates = exposures.filter((e) => e.matchKey.every((k) => keys.includes(k)));
102
99
  if (candidates.length === 0) {
103
- throw new Error(
104
- `No live exposure matches input keys [${keys.join(", ")}]. Flows: ${exposures.map((e) => e.flow).join(", ")}`,
105
- );
106
- }
107
- let max = -1;
108
- for (const e of candidates) {
109
- if (e.matchKey.length > max) max = e.matchKey.length;
100
+ throw new Error(`No live exposure matches input keys [${keys.join(", ")}]`);
110
101
  }
102
+ const max = Math.max(...candidates.map((e) => e.matchKey.length));
111
103
  const top = candidates.filter((e) => e.matchKey.length === max);
112
104
  if (top.length !== 1) {
113
- throw new Error(
114
- `Multiple live exposures match: ${top.map((e) => e.flow).join(", ")}. Pass via: "unit.flow".`,
115
- );
105
+ throw new Error(`Multiple live exposures match. Pass via: "unit.flow".`);
116
106
  }
117
107
  return top[0]!;
118
108
  }
@@ -173,6 +163,11 @@ async function pump(
173
163
  let delayMs = LIVE_RESUBSCRIBE_INITIAL_MS;
174
164
  let attempt = 0;
175
165
  let lastSeenId: string | undefined;
166
+ /** Report `err`. Returns whether the loop should resubscribe. */
167
+ const note = (err: unknown): boolean => {
168
+ handlers.onError?.(err);
169
+ return auto;
170
+ };
176
171
  for (;;) {
177
172
  if (signal.aborted) return;
178
173
  if (attempt > 0 && auto) {
@@ -182,45 +177,26 @@ async function pump(
182
177
  }
183
178
  attempt += 1;
184
179
  try {
185
- const res = await openSse(base, exposure, input, opts, signal, lastSeenId);
180
+ let res = await openSse(base, exposure, input, opts, signal, lastSeenId);
186
181
  if (signal.aborted) return;
187
182
  if (res.status === 401 && opts.auth?.refresh) {
188
183
  await opts.auth.refresh();
189
- const retry = await openSse(base, exposure, input, opts, signal, lastSeenId);
190
- if (signal.aborted) return;
191
- lastSeenId = await consumeSse(retry, handlers, signal, lastSeenId);
184
+ res = await openSse(base, exposure, input, opts, signal, lastSeenId);
192
185
  if (signal.aborted) return;
193
- const closed = new Error("live connection closed");
194
- if (auto) {
195
- handlers.onError?.(closed);
196
- continue;
197
- }
198
- handlers.onError?.(closed);
199
- return;
200
186
  }
201
187
  if (res.status === 410) {
202
- const err = await liveResumeGapError(res);
203
188
  lastSeenId = undefined;
204
- handlers.onError?.(err);
205
- if (!auto) return;
206
- continue;
189
+ const text = await res.text().catch(() => "");
190
+ if (note(sseError(410, text))) continue;
191
+ return;
207
192
  }
208
193
  lastSeenId = await consumeSse(res, handlers, signal, lastSeenId);
209
194
  if (signal.aborted) return;
210
- const closed = new Error("live connection closed");
211
- if (auto) {
212
- handlers.onError?.(closed);
213
- continue;
214
- }
215
- handlers.onError?.(closed);
195
+ if (note(new Error("live connection closed"))) continue;
216
196
  return;
217
197
  } catch (err) {
218
198
  if (signal.aborted) return;
219
- if (auto) {
220
- handlers.onError?.(err);
221
- continue;
222
- }
223
- handlers.onError?.(err);
199
+ if (note(err)) continue;
224
200
  return;
225
201
  }
226
202
  }
@@ -233,23 +209,13 @@ async function consumeSse(
233
209
  lastSeenId: string | undefined,
234
210
  ): Promise<string | undefined> {
235
211
  let cursor = lastSeenId;
236
- await readSse(
237
- res,
238
- (event, id) => {
239
- handlers.onEvent(event);
240
- if (id !== undefined && id.length > 0) cursor = id;
241
- },
242
- signal,
243
- handlers.onOpen,
244
- );
212
+ for await (const frame of iterateSseFrames(res, signal, handlers.onOpen)) {
213
+ handlers.onEvent(frame.event);
214
+ if (frame.id !== undefined && frame.id.length > 0) cursor = frame.id;
215
+ }
245
216
  return cursor;
246
217
  }
247
218
 
248
- async function liveResumeGapError(res: Response): Promise<Error> {
249
- const text = await res.text().catch(() => "");
250
- return sseError(410, text);
251
- }
252
-
253
219
  /**
254
220
  * Abortable delay. Resolves immediately when `signal` is already aborted.
255
221
  *
@@ -280,19 +246,8 @@ async function openSse(
280
246
  ): Promise<Response> {
281
247
  const { url, method } = restGet(base, exposure.path, input);
282
248
  const headers = new Headers({ accept: "text/event-stream" });
283
- const extra = typeof opts.headers === "function" ? await opts.headers() : opts.headers;
284
- if (Array.isArray(extra)) {
285
- for (const [k, v] of extra) headers.set(k, v);
286
- } else if (extra) {
287
- for (const [k, v] of Object.entries(extra)) headers.set(k, v);
288
- }
289
- const token =
290
- opts.auth && "getToken" in opts.auth && typeof opts.auth.getToken === "function"
291
- ? await opts.auth.getToken()
292
- : undefined;
293
- if (token && !headers.has("authorization")) {
294
- headers.set("authorization", `Bearer ${token}`);
295
- }
249
+ applyHeaderBag(headers, await resolveHeaders(opts));
250
+ await applyAuthHeader(headers, opts);
296
251
  if (lastSeenId) headers.set("last-event-id", lastSeenId);
297
252
  const fetchFn: ClientFetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
298
253
  return fetchFn(url, {
@@ -304,18 +259,6 @@ async function openSse(
304
259
  }
305
260
 
306
261
  function restGet(base: string, path: string, input: unknown): { url: string; method: string } {
307
- const params =
308
- input !== null && typeof input === "object" ? (input as Record<string, unknown>) : {};
309
- let pathOut = path;
310
- const query: string[] = [];
311
- for (const [k, v] of Object.entries(params)) {
312
- const token = `:${k}`;
313
- if (pathOut.includes(token)) {
314
- pathOut = pathOut.replaceAll(token, encodeURIComponent(String(v)));
315
- } else if (v !== undefined) {
316
- query.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
317
- }
318
- }
319
- const qs = query.length ? `?${query.join("&")}` : "";
320
- return { url: `${base}${pathOut}${qs}`, method: "GET" };
262
+ const { path: pathOut, rest } = interpolatePath(path, input);
263
+ return { url: `${base}${pathOut}${toQuery(rest)}`, method: "GET" };
321
264
  }
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. */
@@ -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
- }