@haikit/server 0.19.0 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/routes.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { isConversationBusy, isStaleLease } from "@haikit/core";
3
3
  import type { Emit, NoticeRecord, WireEvent } from "@haikit/core";
4
- import type { Hai } from "./runtime.js";
4
+ import type { Hai, WakeOutcome } from "./runtime.js";
5
5
 
6
6
  /**
7
7
  * Node http handler for the three routes, and a fourth when the app sends
8
- * notices. Returns true if it handled the request.
8
+ * notices. Returns true if it handled the request. It never rejects: a request
9
+ * that fails is answered, ended and logged (see `failed`).
9
10
  *
10
11
  * The three POST routes stream SSE: chat and interact because both can resume
11
12
  * the agent loop, start because the init tool it runs can render surfaces.
@@ -18,92 +19,139 @@ import type { Hai } from "./runtime.js";
18
19
  */
19
20
  export function nodeHandler(hai: Hai, basePath = "/hai") {
20
21
  return async function handle(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
21
- const url = new URL(req.url ?? "/", "http://localhost");
22
+ let url: URL;
23
+ try {
24
+ url = new URL(req.url ?? "/", "http://localhost");
25
+ } catch {
26
+ // Node accepts targets a URL can't parse, such as `//[`. None is under
27
+ // a base path, which a URL always parses, so it's not ours: the app's.
28
+ return false;
29
+ }
22
30
  if (!url.pathname.startsWith(basePath)) return false;
23
31
  const route = url.pathname.slice(basePath.length);
24
32
 
25
33
  if (req.method === "GET" && route === "/events" && hai.streamsEvents) {
26
- await streamNotices(hai, req, res, url);
34
+ try {
35
+ await streamNotices(hai, req, res, url);
36
+ } catch (err) {
37
+ failed(res, err);
38
+ }
27
39
  return true;
28
40
  }
29
41
 
30
42
  if (req.method !== "POST") return false;
31
43
  if (route !== "/chat" && route !== "/interact" && route !== "/start") return false;
32
44
 
33
- const body = await readJson(req);
34
-
35
- // Nothing to run before the first message: answer with an empty stream and
36
- // create no conversation, so an app without init doesn't get one for every
37
- // page view. Its first message starts the conversation, as it always has.
38
- if (route === "/start" && !hai.config.init) {
39
- openSSE(res);
40
- res.end();
41
- return true;
42
- }
43
-
44
- // Before the SSE stream opens, because a rejected load has no stream to
45
- // report into — and an uncaught throw here would take down the request.
46
- let conversation;
47
45
  try {
48
- conversation = await hai.config.store.loadConversation(body.conversationId);
46
+ await respond(hai, route, req, res);
49
47
  } catch (err) {
50
- if (!isConversationBusy(err)) throw err;
51
- res.writeHead(409, { "content-type": "application/json" });
52
- res.end(JSON.stringify({ error: (err as Error).message }));
53
- return true;
48
+ failed(res, err);
54
49
  }
50
+ return true;
51
+ };
52
+ }
55
53
 
56
- const emit = openSSE(res);
57
- if (route === "/chat" || route === "/start") {
58
- emit({
59
- type: "hello",
60
- conversationId: conversation.id,
61
- model: hai.config.model.id,
62
- ...(hai.streamsEvents ? { events: true as const } : {}),
63
- });
64
- }
54
+ /**
55
+ * A request that failed in a way the route has no event for, such as a store
56
+ * error: the browser is told, the response ends, and the error is logged. It
57
+ * is never thrown on to the server, where an unhandled rejection would take
58
+ * the process down and leave the response open.
59
+ */
60
+ function failed(res: ServerResponse, err: unknown) {
61
+ console.error("[haikit] request failed:", err);
62
+ const message = err instanceof Error ? err.message : String(err);
63
+ if (!res.headersSent) {
64
+ res.writeHead(500, { "content-type": "application/json" });
65
+ res.end(JSON.stringify({ error: message }));
66
+ } else if (!res.writableEnded) {
67
+ res.write(`data: ${JSON.stringify({ type: "error", message } satisfies WireEvent)}\n\n`);
68
+ res.end();
69
+ }
70
+ }
71
+
72
+ /** One of the three POST routes, once the request is known to be one. */
73
+ async function respond(hai: Hai, route: string, req: IncomingMessage, res: ServerResponse) {
74
+ let body: Record<string, any>;
75
+ try {
76
+ body = await readJson(req);
77
+ } catch {
78
+ res.writeHead(400, { "content-type": "application/json" });
79
+ res.end(JSON.stringify({ error: "the request body must be a JSON object" }));
80
+ return;
81
+ }
65
82
 
66
- let superseded = false;
83
+ // Nothing to run before the first message: answer with an empty stream and
84
+ // create no conversation, so an app without init doesn't get one for every
85
+ // page view. Its first message starts the conversation, as it always has.
86
+ if (route === "/start" && !hai.config.init) {
87
+ openSSE(res);
88
+ res.end();
89
+ return;
90
+ }
91
+
92
+ // Before the SSE stream opens, because a rejected load has no stream to
93
+ // report into: busy is a 409, and anything else a 500 (see `failed`).
94
+ let conversation;
95
+ try {
96
+ conversation = await hai.config.store.loadConversation(body.conversationId);
97
+ } catch (err) {
98
+ if (!isConversationBusy(err)) throw err;
99
+ res.writeHead(409, { "content-type": "application/json" });
100
+ res.end(JSON.stringify({ error: (err as Error).message }));
101
+ return;
102
+ }
103
+
104
+ const emit = openSSE(res);
105
+ if (route === "/chat" || route === "/start") {
106
+ emit({
107
+ type: "hello",
108
+ conversationId: conversation.id,
109
+ model: hai.config.model.id,
110
+ ...(hai.streamsEvents ? { events: true as const } : {}),
111
+ });
112
+ }
113
+
114
+ let superseded = false;
115
+ try {
116
+ if (route === "/chat") {
117
+ await hai.send(conversation, String(body.message ?? ""), emit);
118
+ } else if (route === "/start") {
119
+ await hai.start(conversation, emit);
120
+ } else {
121
+ await hai.interact(
122
+ conversation,
123
+ { handle: body.handle, action: body.action, value: body.value },
124
+ emit,
125
+ );
126
+ }
127
+ } catch (err) {
128
+ if (isStaleLease(err)) superseded = true;
129
+ emit({ type: "error", message: (err as Error).message });
130
+ } finally {
131
+ // The lease lives exactly as long as the request. Releasing it any
132
+ // earlier — when a turn parks, say — lets the client's next interaction
133
+ // arrive before this request has finished writing, and the two turns
134
+ // interleave on one conversation.
135
+ //
136
+ // Only the expiry is cleared. The token has to survive so the save below
137
+ // can still prove this request is the rightful holder.
138
+ conversation.leaseUntil = null;
67
139
  try {
68
- if (route === "/chat") {
69
- await hai.send(conversation, String(body.message ?? ""), emit);
70
- } else if (route === "/start") {
71
- await hai.start(conversation, emit);
72
- } else {
73
- await hai.interact(
74
- conversation,
75
- { handle: body.handle, action: body.action, value: body.value },
76
- emit,
77
- );
78
- }
140
+ // A turn that already hit a fence knows its save would be rejected too;
141
+ // attempting it only repeats the same error to the client.
142
+ if (!superseded) await hai.config.store.saveConversation(conversation);
79
143
  } catch (err) {
80
- if (isStaleLease(err)) superseded = true;
144
+ // Overtaken while we were slow: another turn already wrote newer state
145
+ // under a fresh token. Dropping this write is the correct outcome, but
146
+ // the client asked for something it is not getting, so say so. Any
147
+ // other failure goes to `failed`, which says so too, ends the response
148
+ // and logs it: the conversation stays held until its lease lapses.
149
+ if (!isStaleLease(err)) throw err;
81
150
  emit({ type: "error", message: (err as Error).message });
82
- } finally {
83
- // The lease lives exactly as long as the request. Releasing it any
84
- // earlier — when a turn parks, say — lets the client's next interaction
85
- // arrive before this request has finished writing, and the two turns
86
- // interleave on one conversation.
87
- //
88
- // Only the expiry is cleared. The token has to survive so the save below
89
- // can still prove this request is the rightful holder.
90
- conversation.leaseUntil = null;
91
- try {
92
- // A turn that already hit a fence knows its save would be rejected too;
93
- // attempting it only repeats the same error to the client.
94
- if (!superseded) await hai.config.store.saveConversation(conversation);
95
- } catch (err) {
96
- // Overtaken while we were slow: another turn already wrote newer state
97
- // under a fresh token. Dropping this write is the correct outcome, but
98
- // the client asked for something it is not getting, so say so.
99
- if (!isStaleLease(err)) throw err;
100
- emit({ type: "error", message: (err as Error).message });
101
- }
102
151
  }
152
+ }
103
153
 
104
- res.end();
105
- return true;
106
- };
154
+ res.end();
107
155
  }
108
156
 
109
157
  /**
@@ -111,6 +159,27 @@ export function nodeHandler(hai: Hai, basePath = "/hai") {
111
159
  * this is what still finds a notice whose wake-up was lost.
112
160
  */
113
161
  const POLL_MS = 2_000;
162
+ /**
163
+ * A short fingerprint of signal values, by name: what a polling browser says it
164
+ * has, so a poll knows whether the latest values are news to it. The client
165
+ * computes the same (packages/client/src/index.js); change both together.
166
+ */
167
+ export function signalsDigest(entries: [string, unknown][]): string {
168
+ const text = JSON.stringify([...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
169
+ let hash = 0x811c9dc5;
170
+ for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193);
171
+ return (hash >>> 0).toString(16);
172
+ }
173
+
174
+ /** How long a process keeps hearing a conversation's turns after its last stream closes. */
175
+ const WATCH_LINGER_MS = 60_000;
176
+ /** How long a wake turn that has ended is kept, for a poll that missed its end, and how many at most. */
177
+ const ENDED_TURN_KEPT_MS = 60_000;
178
+ const ENDED_TURNS_KEPT = 5;
179
+ /** The longest a poll holds when there is nothing new to send. */
180
+ const POLL_HOLD_MS = 15_000;
181
+ /** How long a poll waits after something new, for what comes with it, before it ends. */
182
+ const POLL_GATHER_MS = 250;
114
183
  /** Proxies drop a stream that says nothing for long; a comment keeps it open. */
115
184
  const HEARTBEAT_MS = 25_000;
116
185
 
@@ -132,6 +201,18 @@ const PAGE = 100;
132
201
  async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse, url: URL) {
133
202
  const { store } = hai.config;
134
203
  const conversationId = url.searchParams.get("conversationId") ?? "";
204
+ // A poll, for a browser behind a proxy that holds a stream back until it
205
+ // ends: what's waiting, then the response ends, which any proxy delivers.
206
+ // `poll=now` answers at once; `poll=1` holds until something new arrives,
207
+ // or for `wait` ms (at most POLL_HOLD_MS) if nothing does.
208
+ const poll = url.searchParams.get("poll");
209
+ const polling = poll === "1" || poll === "now";
210
+ // a fingerprint of the signal values the browser already has (signalsDigest)
211
+ const hasSignals = url.searchParams.get("signals");
212
+ // the last wake turn the browser saw end, by its number
213
+ const sawTurn = Number(url.searchParams.get("turn") ?? 0);
214
+ const asked = Number(url.searchParams.get("wait") ?? POLL_HOLD_MS);
215
+ const holdMs = Number.isFinite(asked) ? Math.min(Math.max(asked, 0), POLL_HOLD_MS) : POLL_HOLD_MS;
135
216
  const resume = Number(req.headers["last-event-id"] ?? url.searchParams.get("after") ?? 0);
136
217
  let after = Number.isSafeInteger(resume) && resume > 0 ? resume : 0;
137
218
 
@@ -147,6 +228,7 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
147
228
  // Counted as watching from here, so nothing lets the entry go while this
148
229
  // stream is still being set up.
149
230
  const watchers = watchersOf(hai, conversationId);
231
+ watchers.stay();
150
232
  watchers.pending++;
151
233
  try {
152
234
  await watchers.ready;
@@ -166,8 +248,11 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
166
248
  connection: "keep-alive",
167
249
  });
168
250
  // Sent now, not with the first frame: with nothing to catch up on, that is a
169
- // heartbeat away, and the browser's request would hang open until then.
251
+ // heartbeat away, and the browser's request would hang open until then. A
252
+ // comment with them, so the browser hears its first bytes at once: one that
253
+ // hears nothing knows something on the way is holding the stream, and polls.
170
254
  res.flushHeaders();
255
+ res.write(": open\n\n");
171
256
  const aborter = new AbortController();
172
257
  const { signal } = aborter;
173
258
  res.on("close", () => aborter.abort());
@@ -186,11 +271,20 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
186
271
  });
187
272
  };
188
273
 
189
- // A wake turn's events go out as they come, one frame each, and a stream
190
- // the browser has left takes no more.
191
- const emit: Emit = (event) => {
274
+ // Something new for this browser: a poll ends a moment after it, to gather
275
+ // what comes with it.
276
+ let markFresh = () => {};
277
+ const freshened = new Promise<void>((resolve) => (markFresh = resolve));
278
+
279
+ // A frame out, and a stream the browser has left takes no more.
280
+ const send = (event: WireEvent) => {
192
281
  if (!signal.aborted && !res.writableEnded) res.write(`data: ${JSON.stringify(event)}\n\n`);
193
282
  };
283
+ // A wake turn's events go out as they come, one frame each.
284
+ const emit: Emit = (event) => {
285
+ send(event);
286
+ markFresh();
287
+ };
194
288
  // Every stream this process has open for the conversation hears a wake
195
289
  // turn, not only the one whose attempt won the lease: another tab shows the
196
290
  // reply too, and holds its requests until the turn lets go.
@@ -220,22 +314,58 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
220
314
  }
221
315
  res.write(`data: ${JSON.stringify(event)}\n\n`);
222
316
  };
223
- const closeStream = hai.streamOpened(conversationId, emitSignal);
317
+ // A new signal is news. Each one's latest, sent as the stream opens, is
318
+ // news to a poll only if the browser doesn't have those values yet, such as
319
+ // one sent between two polls: every poll gets them, and a poll that always
320
+ // ended on them would never wait.
321
+ const replayed: [string, unknown][] = [];
322
+ const closeStream = hai.streamOpened(
323
+ conversationId,
324
+ (event) => {
325
+ emitSignal(event);
326
+ markFresh();
327
+ },
328
+ (event) => {
329
+ emitSignal(event);
330
+ if (!polling || event.type !== "signal") return;
331
+ // the latest are sent one after another, at once: compare once all are in
332
+ if (!replayed.length) queueMicrotask(() => signalsDigest(replayed) !== hasSignals && markFresh());
333
+ replayed.push([event.name, event.payload]);
334
+ },
335
+ );
224
336
  // Joining while a wake turn runs: what it has shown so far, starting with
225
337
  // its `streaming` status, so the browser holds requests until it lets go,
226
338
  // and the reply's later text has a block to land in.
227
339
  // A surface it shows is sent with its age, so the browser counts its
228
340
  // freshness from when the surface was stored, not from now.
229
- if (watchers.live) for (const event of watchers.shown) emit(aged(event));
341
+ if (watchers.live) for (const event of watchers.shown) send(aged(event));
342
+ else if (polling) {
343
+ // A turn that ended between two polls, unseen: all it showed, its end
344
+ // included, which is news. Then that no turn is running, which a browser
345
+ // that saw nothing of one needs too, or it would wait for it for ever.
346
+ // every one it missed, oldest first, if more than one ended meanwhile
347
+ const missed = Number.isSafeInteger(sawTurn) ? watchers.ended.filter((turn) => turn.number > sawTurn) : [];
348
+ for (const turn of missed) for (const event of turn.shown) send(aged(event));
349
+ if (missed.length) markFresh();
350
+ send({ type: "released" });
351
+ }
230
352
 
231
353
  // Wake turns run beside the notice loop, so a notice never waits behind a
232
354
  // turn, nor behind the retries while another request holds the
233
355
  // conversation. One attempt at a time; a wake notice that lands meanwhile
234
356
  // asks for another once it is done.
357
+ //
358
+ // An attempt takes the conversation's lease and saves it, even when it
359
+ // finds nothing to do, and a request that arrives meanwhile is turned away
360
+ // busy. So it is made only when there may be something new: a wake notice,
361
+ // read from the store by any stream here, past the last one an attempt here
362
+ // settled. A polling browser opens a stream several times a minute; each
363
+ // would otherwise take the lease.
235
364
  let waking: Promise<void> | null = null;
236
365
  let again = false;
237
366
  const wake = () => {
238
367
  if (!hai.wakes || signal.aborted) return;
368
+ if (watchers.wakeRead <= watchers.wakeSettled) return;
239
369
  if (waking) {
240
370
  again = true;
241
371
  return;
@@ -243,12 +373,22 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
243
373
  waking = (async () => {
244
374
  do {
245
375
  again = false;
376
+ // every wake notice read here so far: what the attempt settles, unless it fails
377
+ const through = watchers.wakeRead;
246
378
  let wait = WAKE_RETRY_MS;
247
- while (!signal.aborted && (await hai.wake(conversationId, watchers.turn())) === "busy") {
379
+ let outcome: WakeOutcome = "busy";
380
+ while (!signal.aborted && (outcome = await hai.wake(conversationId, watchers.turn())) === "busy") {
248
381
  await pause(wait, signal);
249
382
  wait = Math.min(wait * 2, WAKE_RETRY_MAX_MS);
383
+ // Another stream here may have been the one holding it, with a turn
384
+ // that settled these notices: taking the lease again finds nothing.
385
+ if (through <= watchers.wakeSettled) break;
250
386
  }
251
- } while (again && !signal.aborted);
387
+ // A failed turn gives its notices back to wake again, so it settles
388
+ // nothing: the next stream to open tries once more.
389
+ if (outcome !== "busy" && outcome !== "failed") watchers.wakeSettled = Math.max(watchers.wakeSettled, through);
390
+ // asked again meanwhile: only if a notice came that this one didn't settle
391
+ } while (again && !signal.aborted && watchers.wakeRead > watchers.wakeSettled);
252
392
  })()
253
393
  .catch((err) => emit({ type: "error", message: (err as Error).message }))
254
394
  .finally(() => {
@@ -299,7 +439,11 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
299
439
  if (n.replaces !== undefined && n.handle !== undefined) {
300
440
  await sendRevision(n.handle, n.replaces, n.seq);
301
441
  after = n.seq;
302
- woken ||= n.kind === "wake";
442
+ markFresh();
443
+ if (n.kind === "wake") {
444
+ woken = true;
445
+ watchers.wakeRead = Math.max(watchers.wakeRead, n.seq);
446
+ }
303
447
  continue;
304
448
  }
305
449
  const event: WireEvent = {
@@ -312,7 +456,11 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
312
456
  };
313
457
  await write(`id: ${n.seq}\ndata: ${JSON.stringify(event)}\n\n`);
314
458
  after = n.seq;
315
- woken ||= n.kind === "wake";
459
+ markFresh();
460
+ if (n.kind === "wake") {
461
+ woken = true;
462
+ watchers.wakeRead = Math.max(watchers.wakeRead, n.seq);
463
+ }
316
464
  }
317
465
  if (records.length < PAGE || signal.aborted) break;
318
466
  page = null;
@@ -325,14 +473,12 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
325
473
  // An app that sends no notices has nothing stored to read: the stream is only
326
474
  // for signals, and stays open, reading nothing, until the browser leaves.
327
475
  const wakes = hai.sendsNotices ? wakeups(store.watch?.(conversationId, signal), signal)[Symbol.asyncIterator]() : null;
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
- }
476
+ const left = new Promise<void>((resolve) => {
477
+ if (signal.aborted) resolve();
478
+ else signal.addEventListener("abort", () => resolve(), { once: true });
479
+ });
480
+ const run = async () => {
481
+ if (!wakes) return left;
336
482
  await catchUp(first);
337
483
  // A wake notice may be waiting from before this browser connected: one
338
484
  // that arrived while nobody watched, or while the conversation was busy.
@@ -346,6 +492,22 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
346
492
  next = wakes.next();
347
493
  await catchUp();
348
494
  }
495
+ };
496
+ try {
497
+ if (!polling) return await run();
498
+ // A poll: the same reading, until something new has gone out and what
499
+ // came with it has had a moment to follow, or the hold runs out.
500
+ if (poll === "now") markFresh();
501
+ let hold: ReturnType<typeof setTimeout> | undefined;
502
+ const done = Promise.race([
503
+ left,
504
+ freshened.then(() => new Promise<void>((resolve) => setTimeout(resolve, POLL_GATHER_MS))),
505
+ new Promise<void>((resolve) => (hold = setTimeout(resolve, holdMs))),
506
+ ]);
507
+ // a read that fails ends nothing early: the poll answers at its hold
508
+ void run().catch((err) => console.error("[haikit] events poll failed:", err));
509
+ await done;
510
+ clearTimeout(hold);
349
511
  } finally {
350
512
  clearInterval(heartbeat);
351
513
  aborter.abort();
@@ -371,6 +533,13 @@ interface Watchers {
371
533
  * then everything else in order. A turn's worth, dropped when it ends.
372
534
  */
373
535
  shown: WireEvent[];
536
+ /**
537
+ * The wake turns that ended in the past minute, the last few, oldest first:
538
+ * each one's number and everything it showed, `released` included. A poll
539
+ * is sent each one its browser hasn't seen end, since they may have ended
540
+ * between two polls.
541
+ */
542
+ ended: { number: number; shown: WireEvent[] }[];
374
543
  /**
375
544
  * An emitter for one wake attempt in this process: its frames go to every
376
545
  * stream here and to the other processes, marked as one turn.
@@ -380,8 +549,31 @@ interface Watchers {
380
549
  pending: number;
381
550
  /** Let this entry go, and stop hearing other processes for it. */
382
551
  forget: () => void;
552
+ /**
553
+ * A stream for the conversation opened: stop lingering. And its last stream
554
+ * closed: keep hearing other processes a while, then let go. A browser that
555
+ * polls comes back in moments, perhaps to another server, and a wake turn
556
+ * that ends meanwhile, wherever it ran, has to be here to replay.
557
+ */
558
+ stay: () => void;
559
+ leave: () => void;
383
560
  /** Settles once this process is listening for other processes' turns. */
384
561
  ready: Promise<void>;
562
+ /**
563
+ * The highest seq of a wake notice any stream here has read from the store,
564
+ * whether or not an attempt has settled it yet: a stream that resumes past
565
+ * it, reading nothing, still knows it's there. 0 until one is read.
566
+ */
567
+ wakeRead: number;
568
+ /**
569
+ * The highest wake notice seq a wake attempt here has settled: it woke,
570
+ * found nothing to do, or was held back by `maxWakes`. A stream makes an
571
+ * attempt only while `wakeRead` is past it. -1 until the first, so the first
572
+ * stream this process opens for the conversation looks once, for an update
573
+ * notice a revision still owes. Both come from the store, never from what a
574
+ * browser says it has seen, since every stream here shares them.
575
+ */
576
+ wakeSettled: number;
385
577
  }
386
578
 
387
579
  /** Fold a wake turn's frame into what it has shown so far. */
@@ -462,8 +654,9 @@ function watchersOf(hai: Hai, conversationId: string): Watchers {
462
654
  const origin = originOf(hai);
463
655
  // Hearing other processes' wake turns lasts as long as this entry does.
464
656
  const hearing = new AbortController();
657
+ let lingering: ReturnType<typeof setTimeout> | null = null;
465
658
  const forget = () => {
466
- if (map.get(conversationId) !== fresh || fresh.pending || fresh.streams.size || fresh.live) return;
659
+ if (map.get(conversationId) !== fresh || fresh.pending || fresh.streams.size || fresh.live || fresh.ended.length || lingering) return;
467
660
  map.delete(conversationId);
468
661
  hearing.abort();
469
662
  };
@@ -478,12 +671,14 @@ function watchersOf(hai: Hai, conversationId: string): Watchers {
478
671
  // start. A turn takes over only with a number at least as high as any
479
672
  // seen; anything but the current turn's frames is dropped.
480
673
  let current: string | null = null;
674
+ let currentNumber: number | undefined;
481
675
  let newest = 0;
482
676
  const deliver = (event: WireEvent, turn: string, number: number | undefined) => {
483
677
  if (event.type === "status" && event.status === "streaming") {
484
678
  if (current !== turn) {
485
679
  if (number !== undefined && number < newest) return; // an older turn's late start
486
680
  current = turn;
681
+ currentNumber = number;
487
682
  if (number !== undefined) newest = number;
488
683
  fresh.live = true;
489
684
  fresh.shown = [];
@@ -493,6 +688,15 @@ function watchersOf(hai: Hai, conversationId: string): Watchers {
493
688
  }
494
689
  remember(fresh.shown, event);
495
690
  if (event.type === "released") {
691
+ // kept a while for a poll that missed the end, then let go
692
+ if (currentNumber !== undefined) {
693
+ const ended = { number: currentNumber, shown: fresh.shown };
694
+ fresh.ended = [...fresh.ended, ended].slice(-ENDED_TURNS_KEPT);
695
+ setTimeout(() => {
696
+ fresh.ended = fresh.ended.filter((turn) => turn !== ended);
697
+ forget();
698
+ }, ENDED_TURN_KEPT_MS).unref?.();
699
+ }
496
700
  current = null;
497
701
  fresh.live = false;
498
702
  fresh.shown = [];
@@ -506,8 +710,23 @@ function watchersOf(hai: Hai, conversationId: string): Watchers {
506
710
  streams: new Set(),
507
711
  live: false,
508
712
  shown: [],
713
+ ended: [],
509
714
  forget,
715
+ stay: () => {
716
+ if (lingering) clearTimeout(lingering);
717
+ lingering = null;
718
+ },
719
+ leave: () => {
720
+ if (lingering) clearTimeout(lingering);
721
+ lingering = setTimeout(() => {
722
+ lingering = null;
723
+ forget();
724
+ }, WATCH_LINGER_MS);
725
+ lingering.unref?.();
726
+ },
510
727
  ready: Promise.resolve(),
728
+ wakeRead: 0,
729
+ wakeSettled: -1,
511
730
  // A wake turn running here: its frames to this process's streams, and,
512
731
  // through the store, to every other process's. All but `context`, the
513
732
  // whole history, which another tab gets with its own next request.
@@ -554,8 +773,9 @@ function watchersOf(hai: Hai, conversationId: string): Watchers {
554
773
 
555
774
  function forgetWatchers(_hai: Hai, _conversationId: string, watchers: Watchers) {
556
775
  // Kept while a wake turn runs, so a stream that joins later still hears it,
557
- // and while any stream is open or being set up. `forget` checks all three.
558
- watchers.forget();
776
+ // while any stream is open or being set up, while an ended turn is kept, and
777
+ // for a while after the last stream closes. `forget` checks them all.
778
+ watchers.leave();
559
779
  }
560
780
 
561
781
  /** The first wait before trying a wake turn again on a busy conversation, and the longest. */
@@ -622,8 +842,11 @@ function openSSE(res: ServerResponse): Emit {
622
842
  };
623
843
  }
624
844
 
625
- async function readJson(req: IncomingMessage): Promise<any> {
845
+ /** The request body, as a JSON object; anything else, or a body that fails to arrive, throws. */
846
+ async function readJson(req: IncomingMessage): Promise<Record<string, any>> {
626
847
  const chunks: Buffer[] = [];
627
848
  for await (const chunk of req) chunks.push(chunk as Buffer);
628
- return JSON.parse(Buffer.concat(chunks).toString() || "{}");
849
+ const body: unknown = JSON.parse(Buffer.concat(chunks).toString() || "{}");
850
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new TypeError("not a JSON object");
851
+ return body as Record<string, any>;
629
852
  }
package/src/runtime.ts CHANGED
@@ -371,10 +371,12 @@ export class Hai {
371
371
  * An events stream opened for `conversationId`: it hears every signal from
372
372
  * now on, starting with each one's latest payload, and counts toward
373
373
  * presence. The events route calls this; call it too if you serve the
374
- * stream yourself, and call what it returns when the stream closes.
374
+ * stream yourself, and call what it returns when the stream closes. The
375
+ * latest payloads go through `replay` when given, so a caller can tell them
376
+ * from new ones.
375
377
  */
376
- streamOpened(conversationId: string, emit: Emit): () => void {
377
- return this.broadcast.opened(conversationId, emit);
378
+ streamOpened(conversationId: string, emit: Emit, replay?: Emit): () => void {
379
+ return this.broadcast.opened(conversationId, emit, replay);
378
380
  }
379
381
 
380
382
  /**
package/src/signals.ts CHANGED
@@ -53,10 +53,11 @@ export class Broadcast {
53
53
 
54
54
  /**
55
55
  * An events stream opened for `conversationId`: it hears signals from now
56
- * on, starting with each one's latest payload, and the conversation counts
57
- * as present. Call what this returns when the stream closes.
56
+ * on, through `emit`, starting with each one's latest payload, through
57
+ * `replay`; and the conversation counts as present. Call what this returns
58
+ * when the stream closes.
58
59
  */
59
- opened(conversationId: string, emit: Emit): () => void {
60
+ opened(conversationId: string, emit: Emit, replay: Emit = emit): () => void {
60
61
  this.start();
61
62
  this.streams.add(emit);
62
63
  let closed = false;
@@ -64,7 +65,7 @@ export class Broadcast {
64
65
  // sent is among them. A live one may come first; the latest is still the
65
66
  // latest, so at worst one arrives twice.
66
67
  void this.hearing.then(() => {
67
- if (!closed) for (const event of this.latest.values()) emit(event);
68
+ if (!closed) for (const event of this.latest.values()) replay(event);
68
69
  });
69
70
 
70
71
  const wasLeaving = this.leaving.get(conversationId);