@loomwork/ui-hydration 0.0.0-stage → 0.1.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 (179) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +398 -2
  4. package/dist/cache.d.ts +71 -0
  5. package/dist/cache.d.ts.map +1 -0
  6. package/dist/cache.js +157 -0
  7. package/dist/cache.js.map +1 -0
  8. package/dist/chatSource.d.ts +105 -0
  9. package/dist/chatSource.d.ts.map +1 -0
  10. package/dist/chatSource.js +259 -0
  11. package/dist/chatSource.js.map +1 -0
  12. package/dist/client.d.ts +17 -0
  13. package/dist/client.d.ts.map +1 -0
  14. package/dist/client.js +36 -0
  15. package/dist/client.js.map +1 -0
  16. package/dist/contentRefs.d.ts +78 -0
  17. package/dist/contentRefs.d.ts.map +1 -0
  18. package/dist/contentRefs.js +129 -0
  19. package/dist/contentRefs.js.map +1 -0
  20. package/dist/controller.d.ts +228 -0
  21. package/dist/controller.d.ts.map +1 -0
  22. package/dist/controller.js +454 -0
  23. package/dist/controller.js.map +1 -0
  24. package/dist/dataProvider.d.ts +53 -0
  25. package/dist/dataProvider.d.ts.map +1 -0
  26. package/dist/dataProvider.js +77 -0
  27. package/dist/dataProvider.js.map +1 -0
  28. package/dist/esm/cache.js +151 -0
  29. package/dist/esm/cache.js.map +1 -0
  30. package/dist/esm/chatSource.js +254 -0
  31. package/dist/esm/chatSource.js.map +1 -0
  32. package/dist/esm/client.js +15 -0
  33. package/dist/esm/client.js.map +1 -0
  34. package/dist/esm/contentRefs.js +119 -0
  35. package/dist/esm/contentRefs.js.map +1 -0
  36. package/dist/esm/controller.js +449 -0
  37. package/dist/esm/controller.js.map +1 -0
  38. package/dist/esm/dataProvider.js +72 -0
  39. package/dist/esm/dataProvider.js.map +1 -0
  40. package/dist/esm/index.js +15 -0
  41. package/dist/esm/index.js.map +1 -0
  42. package/dist/esm/invoker.js +2 -0
  43. package/dist/esm/invoker.js.map +1 -0
  44. package/dist/esm/invokers.js +310 -0
  45. package/dist/esm/invokers.js.map +1 -0
  46. package/dist/esm/loomEffectProvider.js +96 -0
  47. package/dist/esm/loomEffectProvider.js.map +1 -0
  48. package/dist/esm/params.js +207 -0
  49. package/dist/esm/params.js.map +1 -0
  50. package/dist/esm/path.js +24 -0
  51. package/dist/esm/path.js.map +1 -0
  52. package/dist/esm/plan.js +190 -0
  53. package/dist/esm/plan.js.map +1 -0
  54. package/dist/esm/projection.js +230 -0
  55. package/dist/esm/projection.js.map +1 -0
  56. package/dist/esm/provider.js +416 -0
  57. package/dist/esm/provider.js.map +1 -0
  58. package/dist/esm/repair.js +53 -0
  59. package/dist/esm/repair.js.map +1 -0
  60. package/dist/esm/reshape.js +156 -0
  61. package/dist/esm/reshape.js.map +1 -0
  62. package/dist/esm/restricted.js +54 -0
  63. package/dist/esm/restricted.js.map +1 -0
  64. package/dist/esm/scheduler.js +247 -0
  65. package/dist/esm/scheduler.js.map +1 -0
  66. package/dist/esm/select.js +18 -0
  67. package/dist/esm/select.js.map +1 -0
  68. package/dist/esm/shape.js +34 -0
  69. package/dist/esm/shape.js.map +1 -0
  70. package/dist/esm/split/client.js +309 -0
  71. package/dist/esm/split/client.js.map +1 -0
  72. package/dist/esm/split/index.js +3 -0
  73. package/dist/esm/split/index.js.map +1 -0
  74. package/dist/esm/split/server.js +488 -0
  75. package/dist/esm/split/server.js.map +1 -0
  76. package/dist/esm/split/shared.js +13 -0
  77. package/dist/esm/split/shared.js.map +1 -0
  78. package/dist/index.d.ts +29 -0
  79. package/dist/index.d.ts.map +1 -0
  80. package/dist/index.js +63 -0
  81. package/dist/index.js.map +1 -0
  82. package/dist/invoker.d.ts +10 -0
  83. package/dist/invoker.d.ts.map +1 -0
  84. package/dist/invoker.js +3 -0
  85. package/dist/invoker.js.map +1 -0
  86. package/dist/invokers.d.ts +36 -0
  87. package/dist/invokers.d.ts.map +1 -0
  88. package/dist/invokers.js +315 -0
  89. package/dist/invokers.js.map +1 -0
  90. package/dist/loomEffectProvider.d.ts +67 -0
  91. package/dist/loomEffectProvider.d.ts.map +1 -0
  92. package/dist/loomEffectProvider.js +101 -0
  93. package/dist/loomEffectProvider.js.map +1 -0
  94. package/dist/params.d.ts +103 -0
  95. package/dist/params.d.ts.map +1 -0
  96. package/dist/params.js +221 -0
  97. package/dist/params.js.map +1 -0
  98. package/dist/path.d.ts +12 -0
  99. package/dist/path.d.ts.map +1 -0
  100. package/dist/path.js +28 -0
  101. package/dist/path.js.map +1 -0
  102. package/dist/plan.d.ts +56 -0
  103. package/dist/plan.d.ts.map +1 -0
  104. package/dist/plan.js +199 -0
  105. package/dist/plan.js.map +1 -0
  106. package/dist/projection.d.ts +122 -0
  107. package/dist/projection.d.ts.map +1 -0
  108. package/dist/projection.js +238 -0
  109. package/dist/projection.js.map +1 -0
  110. package/dist/provider.d.ts +152 -0
  111. package/dist/provider.d.ts.map +1 -0
  112. package/dist/provider.js +421 -0
  113. package/dist/provider.js.map +1 -0
  114. package/dist/repair.d.ts +18 -0
  115. package/dist/repair.d.ts.map +1 -0
  116. package/dist/repair.js +57 -0
  117. package/dist/repair.js.map +1 -0
  118. package/dist/reshape.d.ts +32 -0
  119. package/dist/reshape.d.ts.map +1 -0
  120. package/dist/reshape.js +164 -0
  121. package/dist/reshape.js.map +1 -0
  122. package/dist/restricted.d.ts +21 -0
  123. package/dist/restricted.d.ts.map +1 -0
  124. package/dist/restricted.js +61 -0
  125. package/dist/restricted.js.map +1 -0
  126. package/dist/scheduler.d.ts +82 -0
  127. package/dist/scheduler.d.ts.map +1 -0
  128. package/dist/scheduler.js +254 -0
  129. package/dist/scheduler.js.map +1 -0
  130. package/dist/select.d.ts +34 -0
  131. package/dist/select.d.ts.map +1 -0
  132. package/dist/select.js +21 -0
  133. package/dist/select.js.map +1 -0
  134. package/dist/shape.d.ts +3 -0
  135. package/dist/shape.d.ts.map +1 -0
  136. package/dist/shape.js +37 -0
  137. package/dist/shape.js.map +1 -0
  138. package/dist/split/client.d.ts +132 -0
  139. package/dist/split/client.d.ts.map +1 -0
  140. package/dist/split/client.js +313 -0
  141. package/dist/split/client.js.map +1 -0
  142. package/dist/split/index.d.ts +5 -0
  143. package/dist/split/index.d.ts.map +1 -0
  144. package/dist/split/index.js +14 -0
  145. package/dist/split/index.js.map +1 -0
  146. package/dist/split/server.d.ts +160 -0
  147. package/dist/split/server.d.ts.map +1 -0
  148. package/dist/split/server.js +496 -0
  149. package/dist/split/server.js.map +1 -0
  150. package/dist/split/shared.d.ts +6 -0
  151. package/dist/split/shared.d.ts.map +1 -0
  152. package/dist/split/shared.js +19 -0
  153. package/dist/split/shared.js.map +1 -0
  154. package/package.json +77 -3
  155. package/src/cache.ts +211 -0
  156. package/src/chatSource.ts +374 -0
  157. package/src/client.ts +46 -0
  158. package/src/contentRefs.ts +195 -0
  159. package/src/controller.ts +708 -0
  160. package/src/dataProvider.ts +141 -0
  161. package/src/index.ts +86 -0
  162. package/src/invoker.ts +14 -0
  163. package/src/invokers.ts +348 -0
  164. package/src/loomEffectProvider.ts +136 -0
  165. package/src/params.ts +314 -0
  166. package/src/path.ts +26 -0
  167. package/src/plan.ts +197 -0
  168. package/src/projection.ts +297 -0
  169. package/src/provider.ts +657 -0
  170. package/src/repair.ts +70 -0
  171. package/src/reshape.ts +193 -0
  172. package/src/restricted.ts +69 -0
  173. package/src/scheduler.ts +284 -0
  174. package/src/select.ts +59 -0
  175. package/src/shape.ts +39 -0
  176. package/src/split/client.ts +449 -0
  177. package/src/split/index.ts +18 -0
  178. package/src/split/server.ts +746 -0
  179. package/src/split/shared.ts +18 -0
@@ -0,0 +1,374 @@
1
+ import type { Finding } from "@loomwork/ui-core/browser";
2
+ import type { ChatFeed } from "@loomwork/ui-core/chat";
3
+
4
+ // ── The browser's connection to loom's conversation API (U8) ─────────────────────────────────────
5
+ //
6
+ // `@loomwork/plugin-conversation-sse` (ADR 0122) serves a conversation over two routes this connects:
7
+ //
8
+ // GET {baseUrl}/history?direction=backward&limit=N[&cursor=C] a bounded page, newest first
9
+ // GET {baseUrl}/events[?cursor=C] text/event-stream (SSE)
10
+ // GET {baseUrl}/reasoning?cursor=<reply cursor> one reply's stored reasoning (opt-in, lazy)
11
+ //
12
+ // REASONING (loom ADR 0133 D6) is opt-in on the server AND on the feed (`ChatFeed({ reasoning })`). This
13
+ // file only routes: the `reasoning` / `reasoning-partial` events go to the feed (which ignores them unless
14
+ // the host turned reasoning on), and `loadReasoning(cursor)` fetches ONE reply's durable reasoning, only
15
+ // when the host asks (a Chat's `LOAD_REASONING` action, i.e. the reader expanded it), never for the whole
16
+ // history. The body is folded by the feed; a 404 (the server's answer for every reason) is "none kept".
17
+ //
18
+ // and the sends go the other way through the host's write-back (`conversationTarget`), NOT through
19
+ // this file: a submit is an authenticated, signed action the host validates (`acceptChatSubmit`);
20
+ // this is the read half. It folds what arrives into a {@link ChatFeed} and publishes the resulting
21
+ // `ChatValue` through `publish` (usually `publishChat(store, handle)`), so a Chat widget bound to
22
+ // that handle just draws it.
23
+ //
24
+ // NOTHING OF LOOM RUNS HERE. The transport is injected: `fetch` for history, `openStream` for the
25
+ // event stream (the host builds the `EventSource` with its own credentials / `withCredentials`; an
26
+ // `EventSource` cannot send an Authorization header, which is why this takes a factory). This file
27
+ // imports no loom package and no Node module, so it is part of the browser entry.
28
+ //
29
+ // Failures are FIXED findings: a status code, a response body or an exception message is never
30
+ // carried (a 404 for "no such conversation" and "not yours" is the same on purpose, ADR 0122 D6).
31
+ // Frames over `maxFrameBytes` and non-JSON frames are dropped with a finding; the feed validates
32
+ // everything else.
33
+
34
+ /** The slice of the platform's `EventSource` this uses. */
35
+ export interface EventSourceLike {
36
+ addEventListener(type: string, listener: (event: { readonly data?: unknown }) => void): void;
37
+ close(): void;
38
+ /** 2 = CLOSED: the platform gave up reconnecting and we must reopen. */
39
+ readonly readyState?: number;
40
+ }
41
+ export interface FetchResponseLike {
42
+ readonly ok: boolean;
43
+ /** The HTTP status, when the host's `fetch` has one: a 403/404 on the reasoning route means "none for you". */
44
+ readonly status?: number;
45
+ json(): Promise<unknown>;
46
+ /**
47
+ * The body as text, when the host's fetch has it. The reasoning read prefers it: the size is checked
48
+ * before `JSON.parse`. Without it the read falls back to `json()`, which the host's own fetch must bound.
49
+ */
50
+ text?(): Promise<string>;
51
+ /** Response headers, when the host exposes them: a declared `Content-Length` over the limit refuses the read. */
52
+ readonly headers?: { get(name: string): string | null };
53
+ }
54
+
55
+ /**
56
+ * The most a durable reasoning response may weigh (bytes, or characters for the `text()` check). loom's
57
+ * route caps the readable text at 1 MiB, so 2 MiB leaves room for the JSON envelope and escaping.
58
+ * Residual: a host `fetch` that only offers `json()` and sends no `Content-Length` is not bounded here.
59
+ */
60
+ export const MAX_REASONING_RESPONSE_BYTES = 2 * 1024 * 1024;
61
+
62
+ export interface ConversationConnectionOptions {
63
+ /** `/conversations/<id>` (or absolute). The id was minted by the server; this appends `/history` and `/events`. */
64
+ readonly baseUrl: string;
65
+ readonly fetch: (
66
+ url: string,
67
+ init: { readonly signal: AbortSignal }
68
+ ) => Promise<FetchResponseLike>;
69
+ /** Open the SSE stream at `url` (the host owns credentials). */
70
+ readonly openStream: (url: string) => EventSourceLike;
71
+ readonly feed: Pick<
72
+ ChatFeed,
73
+ "applyHistory" | "applyEvent" | "resumeCursor" | "olderCursor" | "value" | "streaming"
74
+ > &
75
+ Partial<
76
+ Pick<
77
+ ChatFeed,
78
+ "beginReasoning" | "applyReasoning" | "reasoningFailed" | "reasoningUnavailable"
79
+ >
80
+ >;
81
+ /**
82
+ * The server serves `/reasoning` (loom's `reasoning: { store }`) and the feed is `reasoning: { durable: true }`:
83
+ * `loadReasoning` then fetches. Default false: `loadReasoning` does nothing.
84
+ */
85
+ readonly reasoning?: boolean;
86
+ /** Called with the new value after every change. Deltas arrive fast: throttle here if the host wants. */
87
+ readonly publish: (
88
+ value: ReturnType<ChatFeed["value"]>,
89
+ meta: { readonly streaming: boolean }
90
+ ) => void;
91
+ /** Fixed-text findings (a failed load, a bad frame). */
92
+ readonly onFinding?: (finding: Finding) => void;
93
+ /** Entries per history page (default 50, loom caps it at 500). */
94
+ readonly pageLimit?: number;
95
+ /** Largest SSE frame parsed, bytes of text (default 256 KiB). */
96
+ readonly maxFrameBytes?: number;
97
+ /** First delay after a failure, ms (default 2 000); it doubles per consecutive failure up to `maxReconnectMs`. */
98
+ readonly reconnectMs?: number;
99
+ /** The longest delay between attempts, ms (default 30 000). */
100
+ readonly maxReconnectMs?: number;
101
+ /**
102
+ * The shortest delay before reopening a stream the server asked to recycle (`reconnect`), ms
103
+ * (default 250). Never 0: a server that answered `reconnect` at once would be a hot loop.
104
+ */
105
+ readonly reconnectFloorMs?: number;
106
+ readonly now?: () => number;
107
+ readonly setTimer?: (fn: () => void, ms: number) => () => void;
108
+ }
109
+
110
+ export interface ConversationConnection {
111
+ /** Load the newest page, then open the stream from where it ended. Resolves when the first page is in. */
112
+ start(): Promise<void>;
113
+ /** Load the page before the window (no-op when there is none or one is in flight). */
114
+ loadOlder(): Promise<void>;
115
+ /**
116
+ * Fetch the stored reasoning of the reply at `cursor` (the entry's own cursor), once: a no-op unless
117
+ * `reasoning` is on, the feed offers that reply, and it is not already loaded or loading.
118
+ */
119
+ loadReasoning(cursor: string): Promise<void>;
120
+ /** Stop everything. Idempotent. */
121
+ close(): void;
122
+ }
123
+
124
+ /** A stream open at least this long (ms) was healthy: its end is not a failure streak. */
125
+ const HEALTHY_MS = 5000;
126
+ const EVENTS = [
127
+ "status",
128
+ "partial",
129
+ "delta",
130
+ "reasoning",
131
+ "reasoning-partial",
132
+ "reset",
133
+ "message",
134
+ "closed",
135
+ "reconnect",
136
+ "error",
137
+ ];
138
+ const changedBy = (r: { readonly ok: boolean; readonly changed?: boolean } | undefined): boolean =>
139
+ r?.ok === true && r.changed === true;
140
+ const finding = (code: string, message: string): Finding => ({ code, severity: "error", message });
141
+
142
+ const defaultTimer = (fn: () => void, ms: number): (() => void) => {
143
+ const id = setTimeout(fn, ms);
144
+ return () => clearTimeout(id);
145
+ };
146
+
147
+ export function connectConversation(
148
+ options: ConversationConnectionOptions
149
+ ): ConversationConnection {
150
+ const { feed, baseUrl } = options;
151
+ const pageLimit = Math.max(1, Math.min(options.pageLimit ?? 50, 500));
152
+ const maxFrame = options.maxFrameBytes ?? 256 * 1024;
153
+ const timer = options.setTimer ?? defaultTimer;
154
+ const ac = new AbortController();
155
+ let source: EventSourceLike | undefined;
156
+ let cancelReopen: (() => void) | undefined;
157
+ let closed = false;
158
+ let loadingOlder = false;
159
+ let failures = 0;
160
+ let openedAt = 0;
161
+ const now = options.now ?? Date.now;
162
+ const base = Math.max(1, options.reconnectMs ?? 2000);
163
+ const ceiling = Math.max(base, options.maxReconnectMs ?? 30_000);
164
+ const floor = Math.max(1, options.reconnectFloorMs ?? 250);
165
+ /** Consecutive failures double the wait, capped; the first is the base. */
166
+ const backoff = (): number => Math.min(ceiling, base * 2 ** Math.min(failures, 16));
167
+
168
+ const report = (code: string, message: string): void =>
169
+ options.onFinding?.(finding(code, message));
170
+ const publish = (): void => options.publish(feed.value(), { streaming: feed.streaming });
171
+
172
+ async function page(cursor?: string): Promise<boolean> {
173
+ const q = `direction=backward&limit=${pageLimit}${cursor === undefined ? "" : `&cursor=${encodeURIComponent(cursor)}`}`;
174
+ let body: unknown;
175
+ try {
176
+ const res = await options.fetch(`${baseUrl}/history?${q}`, { signal: ac.signal });
177
+ if (!res.ok) throw new Error("not ok");
178
+ body = await res.json();
179
+ } catch {
180
+ if (!ac.signal.aborted)
181
+ report("history-unavailable", "the conversation history could not be loaded");
182
+ return false;
183
+ }
184
+ if (closed) return false;
185
+ const r = feed.applyHistory(body, "backward");
186
+ if (!r.ok) {
187
+ report(r.finding.code, r.finding.message);
188
+ return false;
189
+ }
190
+ if (r.changed) publish();
191
+ return true;
192
+ }
193
+
194
+ function open(): void {
195
+ if (closed) return;
196
+ const cursor = feed.resumeCursor();
197
+ const url = `${baseUrl}/events${cursor === undefined ? "" : `?cursor=${encodeURIComponent(cursor)}`}`;
198
+ const es = options.openStream(url);
199
+ source = es;
200
+ openedAt = now();
201
+ for (const name of EVENTS)
202
+ es.addEventListener(name, (ev) => {
203
+ if (closed || source !== es) return;
204
+ const raw = ev.data;
205
+ let data: unknown;
206
+ if (typeof raw === "string") {
207
+ if (raw.length > maxFrame)
208
+ return report("frame-too-large", "a conversation event was too large and was ignored");
209
+ try {
210
+ data = JSON.parse(raw);
211
+ } catch {
212
+ return report(
213
+ "frame-invalid",
214
+ "a conversation event was not understood and was ignored"
215
+ );
216
+ }
217
+ }
218
+ const r = feed.applyEvent(name, data);
219
+ if (!r.ok) {
220
+ // The feed's finding text is fixed; an `error` event's body was never read.
221
+ report(r.finding.code, r.finding.message);
222
+ return;
223
+ }
224
+ if (r.reconnect !== true) failures = 0; // a frame the feed understood: the connection works
225
+ if (r.changed) publish();
226
+ if (r.reconnect === true) {
227
+ // Asked to recycle. A server that does so right after opening is looping: back off like a failure.
228
+ const healthy = now() - openedAt >= HEALTHY_MS;
229
+ if (healthy) failures = 0;
230
+ reopen(failures === 0 ? floor : Math.max(floor, backoff()));
231
+ if (!healthy) failures++;
232
+ }
233
+ });
234
+ // A stream the platform has given up on (it keeps trying by itself while CONNECTING).
235
+ (es as { onerror?: () => void }).onerror = () => {
236
+ if (es.readyState === 2) {
237
+ // A stream that stayed up long enough was healthy: its end starts the backoff afresh, and the
238
+ // first wait is the documented base.
239
+ if (now() - openedAt >= HEALTHY_MS) failures = 0;
240
+ const delay = backoff();
241
+ failures++;
242
+ reopen(delay);
243
+ }
244
+ };
245
+ }
246
+
247
+ function reopen(delay: number): void {
248
+ if (closed) return;
249
+ source?.close();
250
+ source = undefined;
251
+ cancelReopen?.();
252
+ cancelReopen = timer(() => {
253
+ cancelReopen = undefined;
254
+ open();
255
+ }, delay);
256
+ }
257
+
258
+ /** The first page; on failure (and nothing to show) try again after a backoff instead of giving up. */
259
+ async function begin(): Promise<void> {
260
+ const ok = await page();
261
+ if (closed) return;
262
+ if (ok || feed.value().entries.length > 0) {
263
+ if (ok) failures = 0;
264
+ open();
265
+ return;
266
+ }
267
+ const delay = backoff();
268
+ failures++;
269
+ cancelReopen?.();
270
+ cancelReopen = timer(() => {
271
+ cancelReopen = undefined;
272
+ void begin();
273
+ }, delay);
274
+ }
275
+
276
+ const loadingReasoning = new Set<string>();
277
+ async function reasoningFor(cursor: string): Promise<void> {
278
+ const { feed: f } = options;
279
+ if (options.reasoning !== true || closed || loadingReasoning.has(cursor)) return;
280
+ if (f.beginReasoning === undefined || !changedBy(f.beginReasoning(cursor))) return;
281
+ loadingReasoning.add(cursor);
282
+ publish(); // the entry now reads "loading"
283
+ try {
284
+ let body: unknown;
285
+ let gone = false;
286
+ try {
287
+ const res = await options.fetch(
288
+ `${baseUrl}/reasoning?cursor=${encodeURIComponent(cursor)}`,
289
+ {
290
+ signal: ac.signal,
291
+ }
292
+ );
293
+ if (!res.ok) {
294
+ gone = res.status === 404 || res.status === 403;
295
+ throw new Error("not ok");
296
+ }
297
+ const declared = Number(res.headers?.get("content-length"));
298
+ if (Number.isFinite(declared) && declared > MAX_REASONING_RESPONSE_BYTES)
299
+ throw new Error("too large");
300
+ if (res.text !== undefined) {
301
+ const raw = await res.text();
302
+ if (raw.length > MAX_REASONING_RESPONSE_BYTES) throw new Error("too large");
303
+ body = JSON.parse(raw);
304
+ } else body = await res.json();
305
+ } catch {
306
+ if (closed) return;
307
+ // A refusal is a settled "none for you" (no retry); anything else is a failure the reader can retry.
308
+ if (gone) f.reasoningUnavailable?.(cursor);
309
+ else {
310
+ f.reasoningFailed?.(cursor);
311
+ report("reasoning-unavailable", "the reasoning could not be loaded");
312
+ }
313
+ publish();
314
+ return;
315
+ }
316
+ if (closed) return;
317
+ if (changedBy(f.applyReasoning?.(cursor, body))) publish();
318
+ } finally {
319
+ loadingReasoning.delete(cursor);
320
+ }
321
+ }
322
+
323
+ return {
324
+ start: begin,
325
+ loadReasoning: reasoningFor,
326
+ async loadOlder() {
327
+ const cursor = feed.olderCursor();
328
+ if (cursor === undefined || loadingOlder || closed) return;
329
+ loadingOlder = true;
330
+ try {
331
+ await page(cursor);
332
+ } finally {
333
+ loadingOlder = false;
334
+ }
335
+ },
336
+ close() {
337
+ closed = true;
338
+ ac.abort();
339
+ cancelReopen?.();
340
+ source?.close();
341
+ source = undefined;
342
+ },
343
+ };
344
+ }
345
+
346
+ /** What a hydration controller offers for a value no loader serves (`publishValue`): its viewer / inventory gate and contract check. */
347
+ export interface ValuePublisher {
348
+ publishValue(
349
+ handle: string,
350
+ value: unknown,
351
+ options: { readonly state: "ready" | "pending"; readonly kind: string }
352
+ ): { readonly ok: true } | { readonly ok: false; readonly finding: Finding };
353
+ }
354
+
355
+ /**
356
+ * A `publish` that writes the transcript to a handle THROUGH a controller's gate (`publishValue`):
357
+ * refused (a finding, nothing written) for a handle the experience does not bind, one that is
358
+ * restricted for the viewer, one that is not a `Chat`, or a value that fails the `ChatValue`
359
+ * contract. `pending` while a reply streams (a screen reader holds its announcements), `ready` once
360
+ * it is durable. Writing the store directly would bypass all of that.
361
+ */
362
+ export function publishChat(
363
+ controller: ValuePublisher,
364
+ handle: string,
365
+ onFinding?: (finding: Finding) => void
366
+ ): (value: unknown, meta: { readonly streaming: boolean }) => void {
367
+ return (value, meta) => {
368
+ const r = controller.publishValue(handle, value, {
369
+ state: meta.streaming ? "pending" : "ready",
370
+ kind: "Chat",
371
+ });
372
+ if (!r.ok) onFinding?.(r.finding);
373
+ };
374
+ }
package/src/client.ts ADDED
@@ -0,0 +1,46 @@
1
+ // ── @loomwork/ui-hydration/client: the browser entry ─────────────────────────────────
2
+ // Everything a browser needs for SPLIT hydration and nothing that executes a provider: the split
3
+ // controller, the loader contract it calls, the store it publishes to, and param checks. The
4
+ // server half (`handleInventory`, `serveHandles`, `executeProvider`, the invokers and reshapers)
5
+ // stays on the root entry, `@loomwork/ui-hydration`, which imports the sandbox and the MCP plugin.
6
+ // `clientEntry.test.ts` fails the build if this entry's module graph ever reaches them.
7
+ export {
8
+ HYDRATION_PROJECTION_ID,
9
+ HYDRATION_PROJECTION_VERSION,
10
+ HYDRATION_SET_EVENT,
11
+ HydrationStore,
12
+ hydrationProjection,
13
+ isReservedHandle,
14
+ } from "./projection";
15
+ export type {
16
+ HandleSlice,
17
+ HandleState,
18
+ HandleStatus,
19
+ HydrationScope,
20
+ HydrationState,
21
+ } from "./projection";
22
+ export { HYDRATION_VALUES_PREFIX, handleOfPath } from "./path";
23
+ export { DEFAULT_TIME_RANGE, checkParams } from "./params";
24
+ export type { LoadParams } from "./params";
25
+ export type { HandleLoadResult } from "./scheduler";
26
+ export { stepReferences, terminalSteps, topoWaves } from "./plan";
27
+ export { jsonPathSelect } from "./select";
28
+ export type { ReshapeFailureKind, ReshapeResult } from "./select";
29
+ export { outputShapeHashOf } from "./shape";
30
+ export type { TransientInvoker } from "./invoker";
31
+ export type { DriftRecord } from "./provider";
32
+ export { SplitHydrationController } from "./split/client";
33
+ export type {
34
+ HandleInventory,
35
+ HandleLoadRequest,
36
+ HandleLoader,
37
+ HandleRef,
38
+ SplitControllerOptions,
39
+ } from "./split/client";
40
+ export { connectConversation, publishChat } from "./chatSource";
41
+ export type {
42
+ ConversationConnection,
43
+ ConversationConnectionOptions,
44
+ EventSourceLike,
45
+ FetchResponseLike,
46
+ } from "./chatSource";
@@ -0,0 +1,195 @@
1
+ import type { TransientInvokeOptions } from "@loomwork/effect-runtime";
2
+ import {
3
+ type ContentRefTable,
4
+ type Finding,
5
+ type MCPToolNode,
6
+ type ProviderSpec,
7
+ contentRefArgFindings,
8
+ contentRefEntry,
9
+ markedTopLevelKeys,
10
+ parseContentRefTable,
11
+ provenIn,
12
+ stripNullContentRefs,
13
+ toolKey,
14
+ } from "@loomwork/ui-core";
15
+ import { type LoadParams, applyParamsDetailed } from "./params";
16
+
17
+ // U9b, the SERVER half: a module of its own so that the browser entry (which imports params.ts) never
18
+ // reaches `@loomwork/ui-core`'s root, which pulls in node:crypto.
19
+
20
+ const bad = (code: string, message: string): Finding => ({ code, severity: "error", message });
21
+
22
+ export type ContentRefPrincipal = TransientInvokeOptions["principal"];
23
+
24
+ /** What the host's gate is told about a param that would fill a content-reference argument. */
25
+ export interface ContentRefParamContext {
26
+ readonly handle: string;
27
+ readonly stepId: string;
28
+ readonly toolName: string;
29
+ readonly kind?: string;
30
+ readonly param: string;
31
+ readonly value: unknown;
32
+ /** The viewer the load runs for (the controller's `principal.id` / serve's `principalId`). */
33
+ readonly principalId?: string;
34
+ /** The full principal chain, when the load has one. */
35
+ readonly principal?: ContentRefPrincipal;
36
+ }
37
+
38
+ /**
39
+ * The host's gate on a PARAM that would fill a content-reference argument (U9b). Called once per
40
+ * replaced top-level argument that holds a marked path (for a tool whose schema is `"unknown"`, once
41
+ * per replaced param), EVERY time the answer is wanted: before a cache lookup as well as before a call.
42
+ * Return `true` only for a reference the host itself vouches for: one that `acceptMediaAttach`
43
+ * accepted, was ISSUED TO `ctx.principalId` (check issuance to THIS principal, not merely "some
44
+ * principal"), and is still current (a revoked share must answer `false`). Synchronous; a throw or a
45
+ * non-`true` answer refuses the step. With no gate configured, EVERY such param is refused: a param is
46
+ * a client-reachable value and a content address is a capability.
47
+ */
48
+ export type ContentRefParamGate = (ctx: ContentRefParamContext) => boolean;
49
+
50
+ /** Everything the guard needs about the load; built once per provider load. */
51
+ export interface ContentRefEnv {
52
+ readonly table: ContentRefTable;
53
+ readonly handle: string;
54
+ readonly steps: ReadonlyMap<string, MCPToolNode>;
55
+ readonly acceptContentRefParam?: ContentRefParamGate;
56
+ readonly principalId?: string;
57
+ readonly principal?: ContentRefPrincipal;
58
+ }
59
+
60
+ export const contentRefEnv = (
61
+ table: ContentRefTable,
62
+ spec: ProviderSpec,
63
+ gate: ContentRefParamGate | undefined,
64
+ who: { principalId?: string | undefined; principal?: ContentRefPrincipal | undefined }
65
+ ): ContentRefEnv => ({
66
+ table,
67
+ handle: spec.handle,
68
+ steps: new Map(spec.executionGraph.map((s) => [s.stepId, s] as const)),
69
+ ...(gate !== undefined && { acceptContentRefParam: gate }),
70
+ ...(who.principalId !== undefined && { principalId: who.principalId }),
71
+ ...(who.principal !== undefined && { principal: who.principal }),
72
+ });
73
+
74
+ /**
75
+ * U9b, defence in depth behind composition: after params are applied and BEFORE anything resolves,
76
+ * a marked content-reference argument must be absent / null, a step reference to an output its tool
77
+ * declares to be a content reference, or a param the host's gate accepted. A model-authored literal,
78
+ * an echo / concat reference, or an ungated param refuses the step, so the call is never made.
79
+ * `paramFilled` says a param filled a guarded argument (the caller then caches per principal only).
80
+ */
81
+ export function checkContentRefs(
82
+ node: MCPToolNode,
83
+ applied: { readonly args: Record<string, unknown>; readonly replaced: readonly string[] },
84
+ env: ContentRefEnv
85
+ ): { readonly finding?: Finding; readonly paramFilled: boolean } {
86
+ const entry = contentRefEntry(env.table, toolKey(node));
87
+ if (entry === undefined) return { paramFilled: false };
88
+ const skip = new Set<string>();
89
+ // A tool whose schema mentions the marker but cannot be read: every replaced param is gated.
90
+ const guarded =
91
+ entry === "unknown"
92
+ ? new Set(applied.replaced)
93
+ : typeof entry === "string"
94
+ ? new Set<string>()
95
+ : markedTopLevelKeys(entry);
96
+ let paramFilled = false;
97
+ for (const name of applied.replaced) {
98
+ if (!guarded.has(name)) continue;
99
+ paramFilled = true;
100
+ let ok = false;
101
+ try {
102
+ ok =
103
+ env.acceptContentRefParam?.({
104
+ handle: env.handle,
105
+ stepId: node.stepId,
106
+ toolName: node.toolName,
107
+ ...(node.kind !== undefined && { kind: node.kind }),
108
+ param: name,
109
+ value: applied.args[name],
110
+ ...(env.principalId !== undefined && { principalId: env.principalId }),
111
+ ...(env.principal !== undefined && { principal: env.principal }),
112
+ }) === true;
113
+ } catch {
114
+ ok = false;
115
+ }
116
+ if (!ok)
117
+ return {
118
+ paramFilled,
119
+ finding: bad(
120
+ "media-arg-param-refused",
121
+ `step "${node.stepId}": the param for "${name}" fills a content-reference argument and the host did not accept it`
122
+ ),
123
+ };
124
+ skip.add(name);
125
+ }
126
+ const found = contentRefArgFindings(applied.args, entry, `step "${node.stepId}"`, {
127
+ skipKeys: skip,
128
+ proven: provenIn(env.table, env.steps, node.stepId, toolKey),
129
+ }).find((f) => f.severity === "error");
130
+ return { paramFilled, ...(found !== undefined && { finding: found }) };
131
+ }
132
+
133
+ /** `args` without JSON null at the step's marked paths (a tool never sees `null` where a reference belongs). */
134
+ export function finishContentRefArgs(
135
+ node: MCPToolNode,
136
+ args: Record<string, unknown>,
137
+ table: ContentRefTable
138
+ ): Record<string, unknown> {
139
+ const entry = contentRefEntry(table, toolKey(node));
140
+ return entry === undefined || typeof entry === "string"
141
+ ? args
142
+ : stripNullContentRefs(args, entry);
143
+ }
144
+
145
+ /**
146
+ * Judge a whole provider BEFORE a cache lookup: the same guard `executeProvider` runs per step, over
147
+ * every step, with the gate asked now. A cached value was produced for an argument the gate accepted
148
+ * once; serving it to another principal, or after the host revoked it, would skip the gate. A
149
+ * refusal means: do not serve from the cache (the load itself will refuse). `paramFilled` means the
150
+ * answer depends on who is asking: cache it per principal, never globally.
151
+ */
152
+ export function inspectContentRefs(
153
+ spec: ProviderSpec,
154
+ params: LoadParams,
155
+ env: ContentRefEnv
156
+ ): { readonly refusal?: Finding; readonly paramFilled: boolean } {
157
+ let paramFilled = false;
158
+ for (const step of spec.executionGraph) {
159
+ const applied = applyParamsDetailed(step, params);
160
+ const r = checkContentRefs(step, applied, env);
161
+ paramFilled ||= r.paramFilled;
162
+ if (r.finding !== undefined) return { refusal: r.finding, paramFilled };
163
+ }
164
+ return { paramFilled };
165
+ }
166
+
167
+ const PRINCIPAL_SCOPED = new WeakMap<ProviderSpec, ProviderSpec>();
168
+
169
+ /** `spec` with `cachePolicy.scope: "principal"` (memoised per spec object, so its fingerprint is computed once). */
170
+ export function principalScoped(spec: ProviderSpec): ProviderSpec {
171
+ if (spec.cachePolicy.scope === "principal") return spec;
172
+ let s = PRINCIPAL_SCOPED.get(spec);
173
+ if (s === undefined) {
174
+ s = { ...spec, cachePolicy: { ...spec.cachePolicy, scope: "principal" } };
175
+ PRINCIPAL_SCOPED.set(spec, s);
176
+ }
177
+ return s;
178
+ }
179
+
180
+ /** `contentRefArgs` is missing or malformed: a configuration error, never read as "nothing is guarded". */
181
+ export class ContentRefConfigError extends Error {
182
+ readonly code = "content-ref-config";
183
+ constructor(where: string) {
184
+ super(
185
+ `${where}: contentRefArgs is required: pass the table contentRefArgsFrom(capabilities) builds (a malformed or missing one is refused, never read as "none"), or the explicit opt-out "none"`
186
+ );
187
+ this.name = "ContentRefConfigError";
188
+ }
189
+ }
190
+
191
+ /** Throws {@link ContentRefConfigError} unless `value` is `"none"` or exactly a well-formed table. */
192
+ export function assertContentRefConfig(value: unknown, where: string): void {
193
+ if (value === "none") return;
194
+ if (parseContentRefTable(value) === undefined) throw new ContentRefConfigError(where);
195
+ }