okengine 0.15.2 → 0.17.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 (256) hide show
  1. package/manifest.v1.schema.json +21 -2
  2. package/package.json +1 -1
  3. package/site/content/docs/elements/clock.mdx +7 -0
  4. package/site/content/docs/elements/flow.mdx +20 -12
  5. package/site/content/docs/elements/gate.mdx +108 -3
  6. package/site/content/docs/elements/signal.mdx +46 -17
  7. package/site/content/docs/elements/store.mdx +36 -32
  8. package/site/content/docs/elements/vault.mdx +26 -0
  9. package/site/content/docs/get-started/project-structure.mdx +4 -4
  10. package/site/content/docs/reference/cli.md +1 -1
  11. package/site/content/docs/reference/client.mdx +75 -17
  12. package/site/content/docs/reference/configuration.mdx +7 -4
  13. package/site/content/docs/reference/errors.mdx +24 -17
  14. package/site/content/docs/reference/fx.mdx +39 -22
  15. package/site/content/docs/reference/security.md +1 -1
  16. package/src/auth/api-key-sql.test.ts +47 -0
  17. package/src/auth/api-key-sql.ts +261 -0
  18. package/src/auth/api-keys.ts +276 -14
  19. package/src/auth/auth.test.ts +56 -0
  20. package/src/auth/config.ts +24 -5
  21. package/src/auth/index.ts +62 -1
  22. package/src/auth/plugin.ts +6 -1
  23. package/src/auth/schema.ts +8 -0
  24. package/src/auth/sessions.ts +18 -0
  25. package/src/auth/tables.ts +32 -0
  26. package/src/auth/tenant-config.ts +74 -0
  27. package/src/auth/tenant-tables.ts +11 -0
  28. package/src/auth/tenants.test.ts +63 -0
  29. package/src/auth/tenants.ts +360 -0
  30. package/src/cli/attach-host-console.test.ts +37 -0
  31. package/src/cli/attach-host-console.ts +6 -0
  32. package/src/cli/build.ts +2 -2
  33. package/src/client/budget.test.ts +1 -1
  34. package/src/client/create.ts +75 -5
  35. package/src/client/index.ts +13 -0
  36. package/src/client/live.test.ts +422 -0
  37. package/src/client/live.ts +389 -0
  38. package/src/client/notes-contract.test.ts +41 -0
  39. package/src/client/types.ts +85 -6
  40. package/src/client-react/index.ts +85 -1
  41. package/src/client-react/use-live.test.ts +129 -0
  42. package/src/compiler/effects-infer.ts +37 -2
  43. package/src/compiler/extract.test.ts +143 -11
  44. package/src/compiler/extract.ts +210 -30
  45. package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +1 -2
  46. package/src/compiler/fixtures/skyport.expected.json +2 -3
  47. package/src/compiler/response.ts +41 -11
  48. package/src/console/server/access.test.ts +116 -8
  49. package/src/console/server/access.ts +70 -23
  50. package/src/console/server/app.ts +45 -11
  51. package/src/console/server/console.test.ts +6 -8
  52. package/src/console/server/dev-identities.test.ts +127 -0
  53. package/src/console/server/dev-identities.ts +229 -0
  54. package/src/console/server/flows-invoke.test.ts +21 -4
  55. package/src/console/server/flows.ts +113 -58
  56. package/src/console/server/gates.test.ts +24 -0
  57. package/src/console/server/gates.ts +10 -30
  58. package/src/console/server/index.ts +1 -0
  59. package/src/console/server/live.test.ts +26 -1
  60. package/src/console/server/live.ts +4 -2
  61. package/src/console/server/operator-db.ts +2 -0
  62. package/src/console/server/state.ts +29 -55
  63. package/src/console/server/store.test.ts +17 -0
  64. package/src/console/server/store.ts +43 -1
  65. package/src/console/ui-next/dist/assets/access-page-CXVWWMmD.js +4 -0
  66. package/src/console/ui-next/dist/assets/{agent-disclosure-C0X1fbWF.js → agent-disclosure-BP0Y0Sux.js} +1 -1
  67. package/src/console/ui-next/dist/assets/cache-glyph-B5X-NM0-.js +1 -0
  68. package/src/console/ui-next/dist/assets/call-pii-button-ChAUiLo9.js +1 -0
  69. package/src/console/ui-next/dist/assets/collapsible-DGnOM2ph.js +1 -0
  70. package/src/console/ui-next/dist/assets/copy-inline-button-DBHhgHKP.js +1 -0
  71. package/src/console/ui-next/dist/assets/detail-header-DhHM1iaZ.js +1 -0
  72. package/src/console/ui-next/dist/assets/dropdown-menu-_NjTEo5_.js +1 -0
  73. package/src/console/ui-next/dist/assets/element-icons-BVXtRyd3.js +1 -0
  74. package/src/console/ui-next/dist/assets/explorer-empty-2uIhBu0_.js +1 -0
  75. package/src/console/ui-next/dist/assets/flows-page-XDpAJO8f.js +1 -0
  76. package/src/console/ui-next/dist/assets/{highlighted-json-CS_O8L-r.js → highlighted-json-Awq7gYdu.js} +1 -1
  77. package/src/console/ui-next/dist/assets/http-method-_UHM2ODJ.js +1 -0
  78. package/src/console/ui-next/dist/assets/index-BBj2QJCu.js +66 -0
  79. package/src/console/ui-next/dist/assets/index-Ck88Jmv8.css +2 -0
  80. package/src/console/ui-next/dist/assets/link-BX6Vqztd.js +1 -0
  81. package/src/console/ui-next/dist/assets/observability-page-BFaay44m.js +4 -0
  82. package/src/console/ui-next/dist/assets/replica-lag-DKRrbvdo.js +18 -0
  83. package/src/console/ui-next/dist/assets/request-meta-DV0ywz7t.js +1 -0
  84. package/src/console/ui-next/dist/assets/shortcut-JQIZlWfm.js +1 -0
  85. package/src/console/ui-next/dist/assets/shortcut-keys-DKxNTe_m.js +1 -0
  86. package/src/console/ui-next/dist/assets/skeleton-D-czQJT6.js +1 -0
  87. package/src/console/ui-next/dist/assets/store-page-CS5-aETQ.js +41 -0
  88. package/src/console/ui-next/dist/assets/trace-detail-sheet-B09O8rA6.js +2 -0
  89. package/src/console/ui-next/dist/assets/tree-expand-toggle-BtyhmWb4.js +55 -0
  90. package/src/console/ui-next/dist/assets/units-page-Ca2Z-E52.js +1 -0
  91. package/src/console/ui-next/dist/assets/{use-vault-list-BLbZhzyF.js → use-vault-list-uk4WVboC.js} +1 -1
  92. package/src/console/ui-next/dist/assets/useMutation-B8EO02Ej.js +1 -0
  93. package/src/console/ui-next/dist/assets/useRender-BE2A9BWC.js +1 -0
  94. package/src/console/ui-next/dist/assets/vault-page-BmOeAFwg.js +2 -0
  95. package/src/console/ui-next/dist/index.html +11 -9
  96. package/src/console/ui-next/index.html +3 -3
  97. package/src/console/ui-next/src/client.ts +167 -0
  98. package/src/console/ui-next/src/components/shell/app-sidebar.tsx +2 -0
  99. package/src/console/ui-next/src/components/shell/command-items.test.ts +1 -0
  100. package/src/console/ui-next/src/components/shell/command-items.ts +8 -0
  101. package/src/console/ui-next/src/components/shell/shell-layout.tsx +2 -1
  102. package/src/console/ui-next/src/components/ui/sheet-form.tsx +8 -3
  103. package/src/console/ui-next/src/features/access/access-page.tsx +347 -0
  104. package/src/console/ui-next/src/features/access/data/use-access.ts +23 -0
  105. package/src/console/ui-next/src/features/access/detail/access-detail.tsx +552 -0
  106. package/src/console/ui-next/src/features/access/explorer/access-list.tsx +125 -0
  107. package/src/console/ui-next/src/features/access/lib/format-when.test.ts +139 -0
  108. package/src/console/ui-next/src/features/access/lib/format-when.ts +296 -0
  109. package/src/console/ui-next/src/features/access/lib/scope-groups.test.ts +34 -0
  110. package/src/console/ui-next/src/features/access/lib/scope-groups.ts +40 -0
  111. package/src/console/ui-next/src/features/access/sheets/access-allow-fields.tsx +236 -0
  112. package/src/console/ui-next/src/features/access/sheets/access-create-sheet.tsx +316 -0
  113. package/src/console/ui-next/src/features/access/sheets/access-edit-sheet.tsx +240 -0
  114. package/src/console/ui-next/src/features/access/sheets/access-expiry-fields.tsx +66 -0
  115. package/src/console/ui-next/src/features/access/sheets/access-refresh-sheet.tsx +104 -0
  116. package/src/console/ui-next/src/features/access/sheets/access-scope-field.tsx +115 -0
  117. package/src/console/ui-next/src/features/access/sheets/access-secret-sheet.tsx +89 -0
  118. package/src/console/ui-next/src/features/access/sheets/access-sheet-search.tsx +49 -0
  119. package/src/console/ui-next/src/features/access/state/access-selection.ts +84 -0
  120. package/src/console/ui-next/src/features/auth/auth-redirect.test.ts +5 -0
  121. package/src/console/ui-next/src/features/auth/auth-redirect.ts +18 -2
  122. package/src/console/ui-next/src/features/flows/fixture.ts +0 -1
  123. package/src/console/ui-next/src/features/not-found/not-found-page.tsx +1 -1
  124. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +5 -1
  125. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +15 -4
  126. package/src/console/ui-next/src/lib/document-meta.test.ts +1 -0
  127. package/src/console/ui-next/src/lib/document-meta.ts +2 -1
  128. package/src/console/ui-next/src/lib/last-module-search.test.ts +1 -0
  129. package/src/console/ui-next/src/lib/last-module-search.ts +2 -1
  130. package/src/console/ui-next/src/lib/shortcut.test.ts +2 -0
  131. package/src/console/ui-next/src/lib/shortcut.ts +3 -0
  132. package/src/console/ui-next/src/router.tsx +10 -1
  133. package/src/console/ui-next/ui-next-seed-app-schema.ts +88 -37
  134. package/src/console/ui-next/ui-next-seed-manifest-surface.ts +2 -9
  135. package/src/console/ui-next/ui-next-seed-manifest.ts +0 -1
  136. package/src/console/ui-next/ui-next-seed-store.ts +43 -15
  137. package/src/console/xss.gate.test.ts +1 -0
  138. package/src/drivers/index.ts +2 -0
  139. package/src/drivers/journal-postgres.ts +12 -3
  140. package/src/drivers/pg-rls.ts +26 -2
  141. package/src/drivers/pg-vault-rls.ts +68 -0
  142. package/src/drivers/signal-engine.ts +69 -15
  143. package/src/drivers/signal-live-iter.ts +65 -0
  144. package/src/drivers/signal-nats.ts +2 -1
  145. package/src/drivers/signal-postgres.ts +95 -12
  146. package/src/drivers/signal-redis.ts +2 -1
  147. package/src/drivers/signal-retention.ts +64 -0
  148. package/src/drivers/signal-types.ts +35 -5
  149. package/src/elements/clock/declare.ts +64 -2
  150. package/src/elements/clock/reconcile.ts +98 -25
  151. package/src/elements/clock/runtime.ts +13 -2
  152. package/src/elements/clock.test.ts +28 -0
  153. package/src/elements/clock.ts +9 -1
  154. package/src/elements/gate/declare.ts +2 -0
  155. package/src/elements/gate/permissions.ts +12 -0
  156. package/src/elements/gate.ts +6 -1
  157. package/src/elements/signal/declare.ts +44 -10
  158. package/src/elements/signal/delivery-modes.test.ts +90 -4
  159. package/src/elements/signal/order-lifecycle.test.ts +20 -4
  160. package/src/elements/signal/runtime.ts +42 -0
  161. package/src/elements/signal.test.ts +21 -8
  162. package/src/elements/signal.ts +1 -1
  163. package/src/elements/store/cache.ts +1 -1
  164. package/src/elements/store/declare.ts +7 -0
  165. package/src/elements/store/rls-identity.test.ts +29 -0
  166. package/src/elements/store/rls-identity.ts +3 -0
  167. package/src/elements/store/runtime.ts +14 -0
  168. package/src/elements/store/schema-decl.ts +162 -44
  169. package/src/elements/store/schema-tenant.ts +41 -0
  170. package/src/elements/store/sql-rls-isolation.test.ts +39 -0
  171. package/src/elements/store/sql-select.types.test.ts +71 -0
  172. package/src/elements/store/sql-session.ts +54 -32
  173. package/src/elements/store.ts +5 -0
  174. package/src/elements/vault/builtin-adapter.ts +15 -2
  175. package/src/elements/vault/declare.ts +8 -0
  176. package/src/elements/vault/runtime.ts +7 -0
  177. package/src/elements/vault/sql-rls-isolation.test.ts +145 -0
  178. package/src/elements/vault/storage.ts +9 -0
  179. package/src/elements/vault/test-helpers.ts +2 -1
  180. package/src/i18n/catalogs/ar.ts +19 -0
  181. package/src/i18n/catalogs/en.ts +19 -0
  182. package/src/index.ts +2 -0
  183. package/src/kernel/adopt-routes.ts +56 -8
  184. package/src/kernel/api-key-host-persist.test.ts +143 -0
  185. package/src/kernel/app-tenant.ts +122 -0
  186. package/src/kernel/app.ts +196 -57
  187. package/src/kernel/auth-resolve.ts +13 -5
  188. package/src/kernel/boot-bind/clock.ts +9 -3
  189. package/src/kernel/boot.test.ts +4 -4
  190. package/src/kernel/boot.ts +2 -0
  191. package/src/kernel/budget.test.ts +1 -1
  192. package/src/kernel/clock-durable.ts +8 -0
  193. package/src/kernel/clock-per-tenant-name.ts +5 -0
  194. package/src/kernel/clock-reconcile.ts +8 -0
  195. package/src/kernel/errors-live-resume.ts +15 -0
  196. package/src/kernel/errors-tenant.ts +29 -0
  197. package/src/kernel/errors.registry.test.ts +31 -3
  198. package/src/kernel/errors.ts +43 -3
  199. package/src/kernel/flow.ts +26 -5
  200. package/src/kernel/fx-auth-keys.test.ts +88 -0
  201. package/src/kernel/fx-auth-keys.ts +203 -0
  202. package/src/kernel/fx-auth-tenants.test.ts +87 -0
  203. package/src/kernel/fx-auth-tenants.ts +286 -0
  204. package/src/kernel/fx-live-stream.ts +149 -0
  205. package/src/kernel/fx-live.test.ts +157 -0
  206. package/src/kernel/fx-runtime.ts +15 -0
  207. package/src/kernel/fx-tenant-store.ts +213 -0
  208. package/src/kernel/fx.test.ts +108 -1
  209. package/src/kernel/fx.ts +206 -28
  210. package/src/kernel/hooks.ts +2 -2
  211. package/src/kernel/http-resource.ts +9 -18
  212. package/src/kernel/index.ts +2 -0
  213. package/src/kernel/journal.ts +12 -0
  214. package/src/kernel/live-http.test.ts +78 -0
  215. package/src/kernel/live-http.ts +114 -0
  216. package/src/kernel/live-resume.test.ts +125 -0
  217. package/src/kernel/on.ts +51 -0
  218. package/src/kernel/pipeline-api-key.test.ts +192 -7
  219. package/src/kernel/pipeline-tenant.ts +49 -0
  220. package/src/kernel/pipeline.test.ts +1 -1
  221. package/src/kernel/pipeline.ts +41 -4
  222. package/src/kernel/resource-mount.test.ts +10 -27
  223. package/src/kernel/tenant-resolve.test.ts +101 -0
  224. package/src/kernel/tenant-resolve.ts +124 -0
  225. package/src/kernel/tenant-roles.test.ts +87 -0
  226. package/src/kernel/triggers.ts +59 -21
  227. package/src/manifest/diff.test.ts +11 -2
  228. package/src/manifest/diff.ts +53 -11
  229. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  230. package/src/manifest/fixtures/skyport.manifest.json +0 -1
  231. package/src/manifest/types.ts +58 -6
  232. package/src/release/build-lib.ts +14 -1
  233. package/src/release/limits.ts +2 -2
  234. package/src/release/measure.ts +55 -1
  235. package/src/client/live-gap.test.ts +0 -35
  236. package/src/console/ui-next/dist/assets/cache-glyph-F1FI122b.js +0 -1
  237. package/src/console/ui-next/dist/assets/call-pii-button-DEDCl_j3.js +0 -1
  238. package/src/console/ui-next/dist/assets/collapsible-LPqGvfoz.js +0 -1
  239. package/src/console/ui-next/dist/assets/confirm-sheet-3ptDzXbz.js +0 -1
  240. package/src/console/ui-next/dist/assets/explorer-empty-BvYrygyO.js +0 -1
  241. package/src/console/ui-next/dist/assets/flows-page-Dluo1Bd7.js +0 -1
  242. package/src/console/ui-next/dist/assets/http-method--sWDdXSB.js +0 -1
  243. package/src/console/ui-next/dist/assets/index-Ca3HZMVq.js +0 -66
  244. package/src/console/ui-next/dist/assets/index-UtTDRQpS.css +0 -2
  245. package/src/console/ui-next/dist/assets/link-COeyggt-.js +0 -1
  246. package/src/console/ui-next/dist/assets/observability-page-HK9-BO8z.js +0 -4
  247. package/src/console/ui-next/dist/assets/replica-lag-DBIFFf7d.js +0 -18
  248. package/src/console/ui-next/dist/assets/sheet-form-Dnwa5oOR.js +0 -1
  249. package/src/console/ui-next/dist/assets/shortcut-Cgzuv4k1.js +0 -1
  250. package/src/console/ui-next/dist/assets/shortcut-keys-CoqwEIi0.js +0 -1
  251. package/src/console/ui-next/dist/assets/skeleton-BHcfDYcb.js +0 -1
  252. package/src/console/ui-next/dist/assets/store-page-BSohH3wM.js +0 -41
  253. package/src/console/ui-next/dist/assets/trace-detail-sheet-CFIGRnnA.js +0 -2
  254. package/src/console/ui-next/dist/assets/tree-expand-toggle-BgLl34w7.js +0 -54
  255. package/src/console/ui-next/dist/assets/units-page-B0cFE76A.js +0 -1
  256. package/src/console/ui-next/dist/assets/vault-page-B1SbYe10.js +0 -2
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Client live subscribe — fetch SSE, callback + unsubscribe.
3
+ */
4
+
5
+ import type { ClientFetch, ClientOptions, ClientRouteMap, FlowContract } from "./types.ts";
6
+ import type { LiveHandlers, LiveUnsubscribe } from "./types.ts";
7
+
8
+ /** One HTTP exposure of a live signal. */
9
+ export interface LiveExposure {
10
+ readonly flow: string;
11
+ readonly method: string;
12
+ readonly path: string;
13
+ readonly matchKey: readonly string[];
14
+ }
15
+
16
+ /** Signal name → exposures (multiple audiences). */
17
+ export type LiveRouteTable = Readonly<Record<string, readonly LiveExposure[]>>;
18
+
19
+ /** Flow id (`unit.flow`) → exposure. */
20
+ export type LiveByFlow = Readonly<Record<string, LiveExposure>>;
21
+
22
+ /**
23
+ * Build live route tables from `app.$routes`.
24
+ *
25
+ * @param $routes - Runtime route map
26
+ */
27
+ export function flattenLiveRoutes($routes: ClientRouteMap | undefined): {
28
+ readonly bySignal: LiveRouteTable;
29
+ readonly byFlow: LiveByFlow;
30
+ } {
31
+ const bySignal: Record<string, LiveExposure[]> = {};
32
+ const byFlow: Record<string, LiveExposure> = {};
33
+ if (!$routes) return { bySignal, byFlow };
34
+ for (const [unit, flows] of Object.entries($routes)) {
35
+ if (!flows || typeof flows !== "object") continue;
36
+ for (const [flow, contract] of Object.entries(flows)) {
37
+ if (!contract || typeof contract !== "object") continue;
38
+ const live = liveName(contract);
39
+ const method = "method" in contract ? contract.method : undefined;
40
+ const path = "path" in contract ? contract.path : undefined;
41
+ if (typeof live !== "string" || typeof method !== "string" || typeof path !== "string") {
42
+ continue;
43
+ }
44
+ const matchKey = matchKeyOf(contract);
45
+ const id = `${unit}.${flow}`;
46
+ const exposure: LiveExposure = { flow: id, method, path, matchKey };
47
+ (bySignal[live] ??= []).push(exposure);
48
+ byFlow[id] = exposure;
49
+ }
50
+ }
51
+ return { bySignal, byFlow };
52
+ }
53
+
54
+ function liveName(contract: FlowContract): string | undefined {
55
+ return "live" in contract && typeof contract.live === "string" ? contract.live : undefined;
56
+ }
57
+
58
+ function matchKeyOf(contract: FlowContract): readonly string[] {
59
+ if ("matchKey" in contract && Array.isArray(contract.matchKey)) {
60
+ return contract.matchKey.filter((k): k is string => typeof k === "string");
61
+ }
62
+ return [];
63
+ }
64
+
65
+ /** True when `value` is a handlers bag (`onEvent` present). */
66
+ export function isLiveHandlers(value: unknown): value is LiveHandlers<unknown> {
67
+ return (
68
+ typeof value === "object" &&
69
+ value !== null &&
70
+ typeof (value as LiveHandlers<unknown>).onEvent === "function"
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Pick the unique exposure whose `matchKey` is a subset of `input` keys.
76
+ * Prefer the largest matchKey. Ties require `via`.
77
+ *
78
+ * @param exposures - All exposures for one signal
79
+ * @param input - Filter fields
80
+ * @param via - `unit.flow` disambiguator
81
+ */
82
+ export function pickLiveExposure(
83
+ exposures: readonly LiveExposure[],
84
+ input: unknown,
85
+ via?: string,
86
+ ): LiveExposure {
87
+ if (via) {
88
+ const hit = exposures.find((e) => e.flow === via);
89
+ if (!hit) {
90
+ throw new Error(
91
+ `Unknown live via "${via}". Known: ${exposures.map((e) => e.flow).join(", ")}`,
92
+ );
93
+ }
94
+ return hit;
95
+ }
96
+ const keys =
97
+ input !== null && typeof input === "object"
98
+ ? Object.keys(input as Record<string, unknown>)
99
+ : [];
100
+ const candidates = exposures.filter((e) => e.matchKey.every((k) => keys.includes(k)));
101
+ if (candidates.length === 0) {
102
+ throw new Error(
103
+ `No live exposure matches input keys [${keys.join(", ")}]. Flows: ${exposures.map((e) => e.flow).join(", ")}`,
104
+ );
105
+ }
106
+ let max = -1;
107
+ for (const e of candidates) {
108
+ if (e.matchKey.length > max) max = e.matchKey.length;
109
+ }
110
+ const top = candidates.filter((e) => e.matchKey.length === max);
111
+ if (top.length !== 1) {
112
+ throw new Error(
113
+ `Multiple live exposures match: ${top.map((e) => e.flow).join(", ")}. Pass via: "unit.flow".`,
114
+ );
115
+ }
116
+ return top[0]!;
117
+ }
118
+
119
+ /** First wait after a drop when {@link LiveHandlers.autoResubscribe} is true. */
120
+ export const LIVE_RESUBSCRIBE_INITIAL_MS = 500;
121
+
122
+ /** Cap for autoResubscribe exponential backoff. */
123
+ export const LIVE_RESUBSCRIBE_MAX_MS = 30_000;
124
+
125
+ /**
126
+ * Next backoff after `delayMs` (`initial * 2^n`, capped).
127
+ *
128
+ * @param delayMs - Current delay
129
+ */
130
+ export function nextResubscribeDelay(delayMs: number): number {
131
+ return Math.min(Math.max(delayMs, LIVE_RESUBSCRIBE_INITIAL_MS) * 2, LIVE_RESUBSCRIBE_MAX_MS);
132
+ }
133
+
134
+ /**
135
+ * Open an SSE subscription. `unsubscribe` aborts and drops in-flight frames.
136
+ *
137
+ * @param base - Origin
138
+ * @param exposure - Route
139
+ * @param input - Path/query fields
140
+ * @param handlers - Callbacks
141
+ * @param opts - Client options (auth, headers, fetch)
142
+ */
143
+ export function subscribeLive(
144
+ base: string,
145
+ exposure: LiveExposure,
146
+ input: unknown,
147
+ handlers: LiveHandlers<unknown>,
148
+ opts: ClientOptions,
149
+ ): LiveUnsubscribe {
150
+ const ctrl = new AbortController();
151
+ const onAbort = (): void => {
152
+ ctrl.abort();
153
+ };
154
+ handlers.signal?.addEventListener("abort", onAbort, { once: true });
155
+ const run = pump(base, exposure, input, handlers, opts, ctrl.signal);
156
+ void run;
157
+ return () => {
158
+ handlers.signal?.removeEventListener("abort", onAbort);
159
+ ctrl.abort();
160
+ };
161
+ }
162
+
163
+ async function pump(
164
+ base: string,
165
+ exposure: LiveExposure,
166
+ input: unknown,
167
+ handlers: LiveHandlers<unknown>,
168
+ opts: ClientOptions,
169
+ signal: AbortSignal,
170
+ ): Promise<void> {
171
+ const auto = handlers.autoResubscribe === true;
172
+ let delayMs = LIVE_RESUBSCRIBE_INITIAL_MS;
173
+ let attempt = 0;
174
+ let lastSeenId: string | undefined;
175
+ for (;;) {
176
+ if (signal.aborted) return;
177
+ if (attempt > 0 && auto) {
178
+ await sleep(delayMs, signal);
179
+ if (signal.aborted) return;
180
+ delayMs = nextResubscribeDelay(delayMs);
181
+ }
182
+ attempt += 1;
183
+ try {
184
+ const res = await openSse(base, exposure, input, opts, signal, lastSeenId);
185
+ if (signal.aborted) return;
186
+ if (res.status === 401 && opts.auth?.refresh) {
187
+ await opts.auth.refresh();
188
+ const retry = await openSse(base, exposure, input, opts, signal, lastSeenId);
189
+ if (signal.aborted) return;
190
+ lastSeenId = await consumeSse(retry, handlers, signal, lastSeenId);
191
+ if (signal.aborted) return;
192
+ const closed = new Error("live connection closed");
193
+ if (auto) {
194
+ handlers.onError?.(closed);
195
+ continue;
196
+ }
197
+ handlers.onError?.(closed);
198
+ return;
199
+ }
200
+ if (res.status === 410) {
201
+ const err = await liveResumeGapError(res);
202
+ lastSeenId = undefined;
203
+ handlers.onError?.(err);
204
+ if (!auto) return;
205
+ continue;
206
+ }
207
+ lastSeenId = await consumeSse(res, handlers, signal, lastSeenId);
208
+ if (signal.aborted) return;
209
+ const closed = new Error("live connection closed");
210
+ if (auto) {
211
+ handlers.onError?.(closed);
212
+ continue;
213
+ }
214
+ handlers.onError?.(closed);
215
+ return;
216
+ } catch (err) {
217
+ if (signal.aborted) return;
218
+ if (auto) {
219
+ handlers.onError?.(err);
220
+ continue;
221
+ }
222
+ handlers.onError?.(err);
223
+ return;
224
+ }
225
+ }
226
+ }
227
+
228
+ async function consumeSse(
229
+ res: Response,
230
+ handlers: LiveHandlers<unknown>,
231
+ signal: AbortSignal,
232
+ lastSeenId: string | undefined,
233
+ ): Promise<string | undefined> {
234
+ let cursor = lastSeenId;
235
+ await readSse(
236
+ res,
237
+ (event, id) => {
238
+ handlers.onEvent(event);
239
+ if (id !== undefined && id.length > 0) cursor = id;
240
+ },
241
+ signal,
242
+ handlers.onOpen,
243
+ );
244
+ return cursor;
245
+ }
246
+
247
+ async function liveResumeGapError(res: Response): Promise<Error> {
248
+ const text = await res.text().catch(() => "");
249
+ return sseError(410, text);
250
+ }
251
+
252
+ /**
253
+ * Abortable delay. Resolves immediately when `signal` is already aborted.
254
+ *
255
+ * @param ms - Duration
256
+ * @param signal - Unsubscribe / caller abort
257
+ */
258
+ function sleep(ms: number, signal: AbortSignal): Promise<void> {
259
+ if (signal.aborted || ms <= 0) return Promise.resolve();
260
+ return new Promise((resolve) => {
261
+ function finish(): void {
262
+ clearTimeout(timer);
263
+ signal.removeEventListener("abort", finish);
264
+ resolve();
265
+ }
266
+ const timer = setTimeout(finish, ms);
267
+ signal.addEventListener("abort", finish, { once: true });
268
+ if (signal.aborted) finish();
269
+ });
270
+ }
271
+
272
+ async function openSse(
273
+ base: string,
274
+ exposure: LiveExposure,
275
+ input: unknown,
276
+ opts: ClientOptions,
277
+ signal: AbortSignal,
278
+ lastSeenId?: string,
279
+ ): Promise<Response> {
280
+ const { url, method } = restGet(base, exposure.path, input);
281
+ const headers = new Headers({ accept: "text/event-stream" });
282
+ const extra = typeof opts.headers === "function" ? await opts.headers() : opts.headers;
283
+ if (Array.isArray(extra)) {
284
+ for (const [k, v] of extra) headers.set(k, v);
285
+ } else if (extra) {
286
+ for (const [k, v] of Object.entries(extra)) headers.set(k, v);
287
+ }
288
+ const token = opts.auth ? await opts.auth.getToken() : undefined;
289
+ if (token && !headers.has("authorization")) {
290
+ headers.set("authorization", `Bearer ${token}`);
291
+ }
292
+ if (lastSeenId) headers.set("last-event-id", lastSeenId);
293
+ const fetchFn: ClientFetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
294
+ return fetchFn(url, { method, headers, signal });
295
+ }
296
+
297
+ function restGet(base: string, path: string, input: unknown): { url: string; method: string } {
298
+ const params =
299
+ input !== null && typeof input === "object" ? (input as Record<string, unknown>) : {};
300
+ let pathOut = path;
301
+ const query: string[] = [];
302
+ for (const [k, v] of Object.entries(params)) {
303
+ const token = `:${k}`;
304
+ if (pathOut.includes(token)) {
305
+ pathOut = pathOut.replaceAll(token, encodeURIComponent(String(v)));
306
+ } else if (v !== undefined) {
307
+ query.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
308
+ }
309
+ }
310
+ const qs = query.length ? `?${query.join("&")}` : "";
311
+ return { url: `${base}${pathOut}${qs}`, method: "GET" };
312
+ }
313
+
314
+ async function readSse(
315
+ res: Response,
316
+ onEvent: (event: unknown, id: string | undefined) => void,
317
+ signal: AbortSignal,
318
+ onOpen?: () => void,
319
+ ): Promise<void> {
320
+ if (signal.aborted) return;
321
+ const ct = res.headers.get("content-type") ?? "";
322
+ if (!res.ok || !res.body) {
323
+ const text = await res.text().catch(() => "");
324
+ throw sseError(res.status, text);
325
+ }
326
+ if (!ct.includes("text/event-stream")) {
327
+ const text = await res.text().catch(() => "");
328
+ throw sseError(res.status, text || `Expected text/event-stream, got ${ct || "none"}`);
329
+ }
330
+ onOpen?.();
331
+ const reader = res.body.getReader();
332
+ const dec = new TextDecoder();
333
+ let buf = "";
334
+ try {
335
+ for (;;) {
336
+ if (signal.aborted) return;
337
+ const { done, value } = await reader.read();
338
+ if (done) break;
339
+ buf += dec.decode(value, { stream: true });
340
+ let sep = buf.indexOf("\n\n");
341
+ while (sep >= 0) {
342
+ const raw = buf.slice(0, sep);
343
+ buf = buf.slice(sep + 2);
344
+ const stop = dispatchFrame(raw, onEvent, signal);
345
+ if (stop || signal.aborted) return;
346
+ sep = buf.indexOf("\n\n");
347
+ }
348
+ }
349
+ } finally {
350
+ reader.releaseLock();
351
+ }
352
+ }
353
+
354
+ function dispatchFrame(
355
+ raw: string,
356
+ onEvent: (event: unknown, id: string | undefined) => void,
357
+ signal: AbortSignal,
358
+ ): boolean {
359
+ const dataLines: string[] = [];
360
+ let id: string | undefined;
361
+ for (const line of raw.split("\n")) {
362
+ if (line.startsWith("id:")) id = line.slice(3).replace(/^ /, "");
363
+ if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
364
+ }
365
+ if (dataLines.length === 0) return false;
366
+ const data = dataLines.join("\n");
367
+ if (data === "[DONE]") return true;
368
+ if (signal.aborted) return true;
369
+ onEvent(JSON.parse(data) as unknown, id);
370
+ return false;
371
+ }
372
+
373
+ function sseError(status: number, body: string): Error {
374
+ let message = `HTTP ${status}`;
375
+ if (body) {
376
+ try {
377
+ const json: unknown = JSON.parse(body);
378
+ if (json !== null && typeof json === "object" && "error" in json) {
379
+ const err = (json as { error?: { code?: string; data?: { message?: string } } }).error;
380
+ message = err?.data?.message ?? err?.code ?? message;
381
+ }
382
+ } catch {
383
+ message = body.slice(0, 200);
384
+ }
385
+ }
386
+ const e = new Error(message);
387
+ (e as Error & { status?: number }).status = status;
388
+ return e;
389
+ }
@@ -11,12 +11,14 @@ import { oke } from "../kernel/app.ts";
11
11
  import { flow, resetFlowSeq } from "../kernel/flow.ts";
12
12
  import { on, resetBindings } from "../kernel/on.ts";
13
13
  import { http } from "../kernel/triggers.ts";
14
+ import { resetSignals, signal } from "../elements/signal/declare.ts";
14
15
  import { createClient } from "./create.ts";
15
16
  import type { Client } from "./types.ts";
16
17
 
17
18
  beforeEach(() => {
18
19
  resetBindings();
19
20
  resetFlowSeq();
21
+ resetSignals();
20
22
  });
21
23
 
22
24
  /** Compile-time equality. */
@@ -276,4 +278,43 @@ describe("Notes — typeof app carries contracts", () => {
276
278
  expect(method).toBe("POST");
277
279
  expect(url).toBe("http://app.test/_oke/notes/stats");
278
280
  });
281
+
282
+ test("typeof app live exposure is subscribe, not JSON RPC", () => {
283
+ const orderStatus = signal("order-status", {
284
+ delivery: "live",
285
+ optional: true,
286
+ schema: z.object({ orderId: z.string(), status: z.string() }),
287
+ });
288
+ const events = on(http.get("/orders/:orderId/events").live(orderStatus));
289
+ const app = oke({ name: "shop", autoBoot: false, registry: "ignore" }).adopt({
290
+ orders: { events },
291
+ });
292
+
293
+ type LiveFlag = typeof app.$routes.orders.events extends { readonly live: string }
294
+ ? true
295
+ : false;
296
+ type StreamFlag = typeof app.$routes.orders.events extends { readonly stream: true }
297
+ ? true
298
+ : false;
299
+ type _Live = Assert<Eq<LiveFlag, true>>;
300
+ type _Stream = Assert<Eq<StreamFlag, true>>;
301
+ const flags: [_Live, _Stream] = [true, true];
302
+ expect(flags).toEqual([true, true]);
303
+ expect(app.$routes.orders.events.live).toBe("order-status");
304
+ expect(app.$routes.orders.events.stream).toBe(true);
305
+
306
+ const api = createClient(app, "http://app.test", {
307
+ fetch: async () =>
308
+ new Response("data: [DONE]\n\n", {
309
+ status: 200,
310
+ headers: { "content-type": "text/event-stream" },
311
+ }),
312
+ });
313
+ const stop = api.orders.events({ orderId: "ord_1" }, { onEvent: () => undefined });
314
+ type _Unsub = Assert<Eq<typeof stop, () => void>>;
315
+ const unsub: _Unsub = true;
316
+ expect(unsub).toBe(true);
317
+ expect(typeof stop).toBe("function");
318
+ stop();
319
+ });
279
320
  });
@@ -27,6 +27,14 @@ export interface FlowContract<
27
27
  readonly method?: string;
28
28
  /** Optional HTTP path template (`/notes/:id`). */
29
29
  readonly path?: string;
30
+ /** Live signal name when this flow is an SSE exposure. */
31
+ readonly live?: string;
32
+ /** Auto-match path-param names (empty = firehose). */
33
+ readonly matchKey?: readonly string[];
34
+ /** Flattened gate names on the HTTP trigger. */
35
+ readonly gates?: readonly string[];
36
+ /** True when this flow returns `text/event-stream`. */
37
+ readonly stream?: true;
30
38
  }
31
39
 
32
40
  /**
@@ -248,6 +256,75 @@ type ClientCallFn<I, O, E extends Record<string, unknown>> = [I] extends [void]
248
256
  */
249
257
  export type ClientCall<I, O, E extends Record<string, unknown>> = ClientCallFn<I, O, E>;
250
258
 
259
+ /** Unsubscribe a live SSE subscription. */
260
+ export type LiveUnsubscribe = () => void;
261
+
262
+ /**
263
+ * Callbacks for {@link ClientLive} / live-exposing flow calls.
264
+ *
265
+ * @typeParam T - Signal payload
266
+ */
267
+ export interface LiveHandlers<T> {
268
+ readonly onEvent: (event: T) => void;
269
+ readonly onError?: (error: unknown) => void;
270
+ /** Fired after a successful SSE open (HTTP 200), including reconnects. */
271
+ readonly onOpen?: () => void;
272
+ /**
273
+ * Re-open the SSE request after a drop. Default false.
274
+ * Reconnects send `Last-Event-ID` when a cursor was received.
275
+ * A 410 LiveResumeGap clears the cursor and replays the remaining tape.
276
+ * Backoff starts at 500ms and doubles to 30s so a closed stream cannot
277
+ * tight-loop reconnects.
278
+ */
279
+ readonly autoResubscribe?: boolean;
280
+ /** Disambiguate when two exposures share a match shape. `unit.flow`. */
281
+ readonly via?: string;
282
+ readonly signal?: AbortSignal;
283
+ }
284
+
285
+ /** Named live signal handle (`signal()`). */
286
+ export type LiveSignalHandle<T = unknown> = {
287
+ readonly name: string;
288
+ readonly _payload?: T;
289
+ };
290
+
291
+ /** Filter fields for a live subscribe (path params / payload keys). */
292
+ export type LiveInput<T> = T extends object
293
+ ? Partial<T> & Record<string, unknown>
294
+ : Record<string, unknown>;
295
+
296
+ /**
297
+ * Root `api.live(signal, …)` — callback subscribe, not `for await`.
298
+ *
299
+ * @typeParam App - App brand (reserved; runtime uses `$routes`)
300
+ */
301
+ export type ClientLive<App = never> = [App] extends [never]
302
+ ? ClientLiveOverloads
303
+ : ClientLiveOverloads;
304
+
305
+ type ClientLiveOverloads = {
306
+ <T>(signal: LiveSignalHandle<T>, input: LiveInput<T>, handlers: LiveHandlers<T>): LiveUnsubscribe;
307
+ <T>(signal: LiveSignalHandle<T>, handlers: LiveHandlers<T>): LiveUnsubscribe;
308
+ <T>(name: string, input: unknown, handlers: LiveHandlers<T>): LiveUnsubscribe;
309
+ <T>(name: string, handlers: LiveHandlers<T>): LiveUnsubscribe;
310
+ };
311
+
312
+ type IsLiveContract<C> = C extends { readonly live: string } ? true : false;
313
+
314
+ type ClientLiveFlowCall<I, O> = [I] extends [void]
315
+ ? (handlers: LiveHandlers<O>) => LiveUnsubscribe
316
+ : Partial<I> extends I
317
+ ? {
318
+ (handlers: LiveHandlers<O>): LiveUnsubscribe;
319
+ (input?: I, handlers?: LiveHandlers<O>): LiveUnsubscribe;
320
+ }
321
+ : (input: I, handlers: LiveHandlers<O>) => LiveUnsubscribe;
322
+
323
+ type ClientCallFor<C> =
324
+ IsLiveContract<C> extends true
325
+ ? ClientLiveFlowCall<ContractIn<C>, ContractOut<C>>
326
+ : ClientCall<ContractIn<C>, ContractOut<C>, ContractErrors<C>>;
327
+
251
328
  /**
252
329
  * Pull input from a contract shape (supports required or phantom-optional `in`).
253
330
  */
@@ -274,20 +351,22 @@ type ContractErrors<C> = "errors" extends keyof C
274
351
  */
275
352
  export type ClientFromRoutes<R extends ClientRouteMap> = {
276
353
  readonly [U in keyof R]: {
277
- readonly [F in keyof R[U]]: ClientCall<
278
- ContractIn<R[U][F]>,
279
- ContractOut<R[U][F]>,
280
- ContractErrors<R[U][F]>
281
- >;
354
+ readonly [F in keyof R[U]]: ClientCallFor<R[U][F]>;
282
355
  };
283
356
  };
284
357
 
285
358
  /**
286
359
  * Fully typed client for an App.
287
360
  *
361
+ * `live` is always on the instance. Pass `typeof app` / `$routes` so unit
362
+ * bags exist on the type — an empty-`$routes` client cannot be asserted onto
363
+ * `{ console: … }` (TS2352).
364
+ *
288
365
  * @typeParam App - App or route map
289
366
  */
290
- export type Client<App = never> = ClientFromRoutes<RoutesOf<ResolveApp<App>>>;
367
+ export type Client<App = never> = ClientFromRoutes<RoutesOf<ResolveApp<App>>> & {
368
+ readonly live: ClientLive<ResolveApp<App>>;
369
+ };
291
370
 
292
371
  /**
293
372
  * Convenience: brand a route map as an App (`export type App = AppOf<…>`).
@@ -1,11 +1,12 @@
1
1
  /**
2
- * React helpers for okengine clients — `useSession` over {@link createClient}.
2
+ * React helpers for okengine clients — `useSession` / `useLive` over {@link createClient}.
3
3
  *
4
4
  * @module
5
5
  */
6
6
 
7
7
  import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
8
8
  import type { MemorySession } from "../client/auth.ts";
9
+ import type { ClientLive, LiveInput, LiveSignalHandle } from "../client/types.ts";
9
10
 
10
11
  /** Minimal client surface for session reading. */
11
12
  export interface SessionClient {
@@ -91,3 +92,86 @@ export function useSession(api: SessionClient, session?: MemorySession): Session
91
92
  signOut,
92
93
  };
93
94
  }
95
+
96
+ /** Options for {@link useLive}. */
97
+ export interface UseLiveOptions {
98
+ readonly autoResubscribe?: boolean;
99
+ readonly via?: string;
100
+ }
101
+
102
+ /** Hook state from {@link useLive}. */
103
+ export interface LiveState<T> {
104
+ readonly events: T[];
105
+ readonly latest: T | null;
106
+ readonly error: unknown;
107
+ readonly isConnected: boolean;
108
+ }
109
+
110
+ /** Client surface that exposes `api.live`. */
111
+ export interface LiveClient {
112
+ readonly live: ClientLive;
113
+ }
114
+
115
+ /**
116
+ * Subscribe to a `delivery: "live"` signal for the component lifetime.
117
+ *
118
+ * Cleanup calls `stop()`. Changing `signal` / `input` / `via` resets `events`.
119
+ *
120
+ * @param api - Typed client from `createClient`
121
+ * @param signal - Signal handle or name
122
+ * @param input - Path / match fields
123
+ * @param options - Resubscribe and `via`
124
+ */
125
+ export function useLive<T>(
126
+ api: LiveClient,
127
+ signal: LiveSignalHandle<T> | string,
128
+ input?: LiveInput<T>,
129
+ options?: UseLiveOptions,
130
+ ): LiveState<T> {
131
+ const signalName = typeof signal === "string" ? signal : signal.name;
132
+ const inputKey = JSON.stringify(input ?? null);
133
+ const autoResubscribe = options?.autoResubscribe;
134
+ const via = options?.via;
135
+
136
+ const [events, setEvents] = useState<T[]>([]);
137
+ const [latest, setLatest] = useState<T | null>(null);
138
+ const [error, setError] = useState<unknown>(undefined);
139
+ const [isConnected, setConnected] = useState(false);
140
+
141
+ useEffect(() => {
142
+ setEvents([]);
143
+ setLatest(null);
144
+ setError(undefined);
145
+ setConnected(false);
146
+ let stopped = false;
147
+ const handlers = {
148
+ autoResubscribe,
149
+ ...(via !== undefined ? { via } : {}),
150
+ onOpen: () => {
151
+ if (!stopped) setConnected(true);
152
+ },
153
+ onEvent: (event: T) => {
154
+ if (stopped) return;
155
+ setConnected(true);
156
+ setEvents((prev) => [...prev, event]);
157
+ setLatest(event);
158
+ },
159
+ onError: (err: unknown) => {
160
+ if (stopped) return;
161
+ setError(err);
162
+ setConnected(false);
163
+ },
164
+ };
165
+ const parsedInput = inputKey === "null" ? undefined : (JSON.parse(inputKey) as LiveInput<T>);
166
+ const stop =
167
+ parsedInput === undefined
168
+ ? api.live(signalName, handlers)
169
+ : api.live(signalName, parsedInput, handlers);
170
+ return () => {
171
+ stopped = true;
172
+ stop();
173
+ };
174
+ }, [api, signalName, inputKey, autoResubscribe, via]);
175
+
176
+ return { events, latest, error, isConnected };
177
+ }