@haikit/server 0.17.0 → 0.19.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.
package/src/routes.ts CHANGED
@@ -22,7 +22,7 @@ export function nodeHandler(hai: Hai, basePath = "/hai") {
22
22
  if (!url.pathname.startsWith(basePath)) return false;
23
23
  const route = url.pathname.slice(basePath.length);
24
24
 
25
- if (req.method === "GET" && route === "/events" && hai.sendsNotices) {
25
+ if (req.method === "GET" && route === "/events" && hai.streamsEvents) {
26
26
  await streamNotices(hai, req, res, url);
27
27
  return true;
28
28
  }
@@ -59,7 +59,7 @@ export function nodeHandler(hai: Hai, basePath = "/hai") {
59
59
  type: "hello",
60
60
  conversationId: conversation.id,
61
61
  model: hai.config.model.id,
62
- ...(hai.sendsNotices ? { events: true as const } : {}),
62
+ ...(hai.streamsEvents ? { events: true as const } : {}),
63
63
  });
64
64
  }
65
65
 
@@ -195,6 +195,32 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
195
195
  // turn, not only the one whose attempt won the lease: another tab shows the
196
196
  // reply too, and holds its requests until the turn lets go.
197
197
  watchers.streams.add(emit);
198
+ // Signals from here on, starting with each one's latest; and it counts as
199
+ // present. A signal only matters as its latest value, so a reader that has
200
+ // fallen behind is sent just the newest of each once it catches up: a
201
+ // stalled socket holds at most one per signal, never every one sent.
202
+ const behind = new Map<string, WireEvent>();
203
+ let draining = false;
204
+ const flush = () => {
205
+ draining = false;
206
+ const pending = [...behind.values()];
207
+ behind.clear();
208
+ for (const event of pending) emitSignal(event);
209
+ };
210
+ const emitSignal: Emit = (event) => {
211
+ if (event.type !== "signal") return emit(event);
212
+ if (signal.aborted || res.writableEnded) return;
213
+ if (res.writableNeedDrain || behind.size) {
214
+ behind.set(event.name, event);
215
+ if (!draining) {
216
+ draining = true;
217
+ res.once("drain", flush);
218
+ }
219
+ return;
220
+ }
221
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
222
+ };
223
+ const closeStream = hai.streamOpened(conversationId, emitSignal);
198
224
  // Joining while a wake turn runs: what it has shown so far, starting with
199
225
  // its `streaming` status, so the browser holds requests until it lets go,
200
226
  // and the reply's later text has a block to land in.
@@ -232,6 +258,33 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
232
258
  });
233
259
  };
234
260
 
261
+ // An update notice goes out as the revision it announces: the surface, with
262
+ // how long ago it was stored, and its props. Only the last frame carries the
263
+ // notice's id, so a stream cut off between the two resumes with both. A
264
+ // revision whose payload is gone, swept with an old conversation's, is
265
+ // passed over with its id alone, so a browser resumes after it rather than
266
+ // being sent it again on every reconnect.
267
+ const sendRevision = async (handle: string, replaces: string, seq: number) => {
268
+ const record = await store.getPayload(handle, conversationId);
269
+ if (signal.aborted) return;
270
+ if (!record) return write(`id: ${seq}\n\n`);
271
+ const window = typeof record.staleAfterMs === "number" ? record.staleAfterMs : undefined;
272
+ const open: WireEvent = {
273
+ type: "ui_open",
274
+ handle,
275
+ // no tool call made it: the browser keeps the row of the one it replaces
276
+ toolId: "",
277
+ component: record.component,
278
+ version: record.version,
279
+ mode: record.mode,
280
+ ...(window === undefined ? {} : { staleAfterMs: window }),
281
+ ageMs: Math.max(0, Date.now() - record.createdAt),
282
+ replaces,
283
+ };
284
+ await write(`data: ${JSON.stringify(open)}\n\n`);
285
+ await write(`id: ${seq}\ndata: ${JSON.stringify({ type: "ui_props", handle, props: record.props } satisfies WireEvent)}\n\n`);
286
+ };
287
+
235
288
  // Everything after `after`, a page at a time, until a page comes back short.
236
289
  // A conversation deleted since the stream opened ends it: there is nothing
237
290
  // more to wait for, and the browser's reconnect is then refused.
@@ -243,6 +296,12 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
243
296
  for (const n of records) {
244
297
  if (signal.aborted) return;
245
298
  if (n.seq <= after) continue;
299
+ if (n.replaces !== undefined && n.handle !== undefined) {
300
+ await sendRevision(n.handle, n.replaces, n.seq);
301
+ after = n.seq;
302
+ woken ||= n.kind === "wake";
303
+ continue;
304
+ }
246
305
  const event: WireEvent = {
247
306
  type: "notice",
248
307
  seq: n.seq,
@@ -263,8 +322,17 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
263
322
 
264
323
  // a heartbeat is only for a stream gone quiet, never one already backed up
265
324
  const heartbeat = setInterval(() => res.writableNeedDrain || res.write(": ping\n\n"), HEARTBEAT_MS);
266
- const wakes = wakeups(store.watch?.(conversationId, signal), signal)[Symbol.asyncIterator]();
325
+ // An app that sends no notices has nothing stored to read: the stream is only
326
+ // for signals, and stays open, reading nothing, until the browser leaves.
327
+ const wakes = hai.sendsNotices ? wakeups(store.watch?.(conversationId, signal), signal)[Symbol.asyncIterator]() : null;
267
328
  try {
329
+ if (!wakes) {
330
+ await new Promise<void>((resolve) => {
331
+ if (signal.aborted) resolve();
332
+ else signal.addEventListener("abort", () => resolve(), { once: true });
333
+ });
334
+ return;
335
+ }
268
336
  await catchUp(first);
269
337
  // A wake notice may be waiting from before this browser connected: one
270
338
  // that arrived while nobody watched, or while the conversation was busy.
@@ -281,9 +349,10 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
281
349
  } finally {
282
350
  clearInterval(heartbeat);
283
351
  aborter.abort();
352
+ closeStream();
284
353
  watchers.streams.delete(emit);
285
354
  if (!watchers.streams.size) forgetWatchers(hai, conversationId, watchers);
286
- await wakes.return?.();
355
+ await wakes?.return?.();
287
356
  res.end();
288
357
  }
289
358
  }
@@ -338,7 +407,15 @@ function remember(shown: WireEvent[], event: WireEvent) {
338
407
  else {
339
408
  // A revision of a surface this turn showed takes its place: a stream
340
409
  // joining later sees the surface once, as it is now. Its old props go.
341
- delete copy.replaces;
410
+ // It keeps `replaces`, so a browser that did see the old one swaps it in
411
+ // place rather than adding a second, and the old one's tool row, which
412
+ // the browser keeps through a revision too.
413
+ const before = shown[replaced] as Extract<WireEvent, { type: "ui_open" }>;
414
+ copy.toolId = before.toolId;
415
+ // and every handle the surface had before, for a browser that last saw
416
+ // one of those and missed the revisions between
417
+ const earlier = [...(before.alsoReplaces ?? []), ...(before.replaces === undefined ? [] : [before.replaces])];
418
+ if (earlier.length) copy.alsoReplaces = earlier;
342
419
  shown[replaced] = copy;
343
420
  for (let i = shown.length - 1; i >= 0; i--) {
344
421
  const e = shown[i];