@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/README.md CHANGED
@@ -205,6 +205,36 @@ the inspector, the same as a digest. It reads the store every two
205
205
  seconds, and a store with `watch` wakes it the moment a notice lands. The
206
206
  two-second read stays even then, so a lost wake-up only delays a notice.
207
207
 
208
+ **For browsers behind a proxy that holds streams back, the route also
209
+ polls.** Corporate security tools that inspect HTTPS (Netskope, Zscaler and
210
+ the like) often hold a response until it ends, and a stream never ends, so a
211
+ browser behind one hears nothing. The route sends a comment (`: open`) the
212
+ moment a stream opens; a client that hears nothing at all switches to
213
+ polling, with `?poll=1`. A poll sends what's waiting and ends, and responses
214
+ that end get through any proxy:
215
+
216
+ - **What a poll holds for.** It sends any notices after `Last-Event-ID`, each
217
+ signal's latest value, and any frames of a wake turn under way. It ends
218
+ about 250 ms after something new goes out, or after 15 s with nothing new.
219
+ `wait` asks for a shorter hold.
220
+ - **Signals.** The latest values count as new only when the client lacks
221
+ them. The poll's `signals` parameter is a fingerprint of the values the
222
+ client holds.
223
+ - **First poll and wake turns.** `?poll=now` answers at once, for a page's
224
+ first poll. A wake turn that runs across several polls carries on through
225
+ the replay of what it has shown so far. One that ends between two polls is
226
+ kept for a minute (the last five at most): a poll whose `turn` parameter
227
+ says it hasn't seen a turn end is sent all of each such turn, oldest first,
228
+ its last text, `idle` and `released` included. A poll that finds no turn running says so with `released`, so
229
+ the browser never waits for one that has ended.
230
+ - **More than one server.** Each server keeps hearing a conversation for a
231
+ minute after its last stream or poll closes. A browser whose polls move
232
+ between servers still gets a turn that ended on another one. A poll that
233
+ lands on a server that hasn't served that conversation in the past minute
234
+ can miss it; sticky sessions avoid that.
235
+ - **Presence.** A tab polling back to back stays inside the presence grace,
236
+ so it keeps counting.
237
+
208
238
  **A `wake` notice starts a turn of its own.** Declare one with `kind: "wake"`,
209
239
  and its `model` function must return text, since the turn is the model's
210
240
  answer to it:
package/dist/routes.d.ts CHANGED
@@ -2,7 +2,8 @@ import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { Hai } from "./runtime.js";
3
3
  /**
4
4
  * Node http handler for the three routes, and a fourth when the app sends
5
- * notices. Returns true if it handled the request.
5
+ * notices. Returns true if it handled the request. It never rejects: a request
6
+ * that fails is answered, ended and logged (see `failed`).
6
7
  *
7
8
  * The three POST routes stream SSE: chat and interact because both can resume
8
9
  * the agent loop, start because the init tool it runs can render surfaces.
@@ -14,4 +15,10 @@ import type { Hai } from "./runtime.js";
14
15
  * conversation, and where to resume.
15
16
  */
16
17
  export declare function nodeHandler(hai: Hai, basePath?: string): (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
18
+ /**
19
+ * A short fingerprint of signal values, by name: what a polling browser says it
20
+ * has, so a poll knows whether the latest values are news to it. The client
21
+ * computes the same (packages/client/src/index.js); change both together.
22
+ */
23
+ export declare function signalsDigest(entries: [string, unknown][]): string;
17
24
  //# sourceMappingURL=routes.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAGjE,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,cAAc,CAAC;AAExC;;;;;;;;;;;;GAYG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,GAAG,EAAE,QAAQ,SAAS,IACxB,KAAK,eAAe,EAAE,KAAK,cAAc,KAAG,OAAO,CAAC,OAAO,CAAC,CAuF1F"}
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAGjE,OAAO,KAAK,EAAE,GAAG,EAAe,MAAM,cAAc,CAAC;AAErD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,GAAG,EAAE,QAAQ,SAAS,IACxB,KAAK,eAAe,EAAE,KAAK,cAAc,KAAG,OAAO,CAAC,OAAO,CAAC,CA+B1F;AA8GD;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,MAAM,CAKlE"}
package/dist/routes.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { isConversationBusy, isStaleLease } from "@haikit/core";
2
2
  /**
3
3
  * Node http handler for the three routes, and a fourth when the app sends
4
- * notices. Returns true if it handled the request.
4
+ * notices. Returns true if it handled the request. It never rejects: a request
5
+ * that fails is answered, ended and logged (see `failed`).
5
6
  *
6
7
  * The three POST routes stream SSE: chat and interact because both can resume
7
8
  * the agent loop, start because the init tool it runs can render surfaces.
@@ -14,99 +15,170 @@ import { isConversationBusy, isStaleLease } from "@haikit/core";
14
15
  */
15
16
  export function nodeHandler(hai, basePath = "/hai") {
16
17
  return async function handle(req, res) {
17
- const url = new URL(req.url ?? "/", "http://localhost");
18
+ let url;
19
+ try {
20
+ url = new URL(req.url ?? "/", "http://localhost");
21
+ }
22
+ catch {
23
+ // Node accepts targets a URL can't parse, such as `//[`. None is under
24
+ // a base path, which a URL always parses, so it's not ours: the app's.
25
+ return false;
26
+ }
18
27
  if (!url.pathname.startsWith(basePath))
19
28
  return false;
20
29
  const route = url.pathname.slice(basePath.length);
21
30
  if (req.method === "GET" && route === "/events" && hai.streamsEvents) {
22
- await streamNotices(hai, req, res, url);
31
+ try {
32
+ await streamNotices(hai, req, res, url);
33
+ }
34
+ catch (err) {
35
+ failed(res, err);
36
+ }
23
37
  return true;
24
38
  }
25
39
  if (req.method !== "POST")
26
40
  return false;
27
41
  if (route !== "/chat" && route !== "/interact" && route !== "/start")
28
42
  return false;
29
- const body = await readJson(req);
30
- // Nothing to run before the first message: answer with an empty stream and
31
- // create no conversation, so an app without init doesn't get one for every
32
- // page view. Its first message starts the conversation, as it always has.
33
- if (route === "/start" && !hai.config.init) {
34
- openSSE(res);
35
- res.end();
36
- return true;
37
- }
38
- // Before the SSE stream opens, because a rejected load has no stream to
39
- // report into — and an uncaught throw here would take down the request.
40
- let conversation;
41
43
  try {
42
- conversation = await hai.config.store.loadConversation(body.conversationId);
44
+ await respond(hai, route, req, res);
43
45
  }
44
46
  catch (err) {
45
- if (!isConversationBusy(err))
46
- throw err;
47
- res.writeHead(409, { "content-type": "application/json" });
48
- res.end(JSON.stringify({ error: err.message }));
49
- return true;
47
+ failed(res, err);
50
48
  }
51
- const emit = openSSE(res);
52
- if (route === "/chat" || route === "/start") {
53
- emit({
54
- type: "hello",
55
- conversationId: conversation.id,
56
- model: hai.config.model.id,
57
- ...(hai.streamsEvents ? { events: true } : {}),
58
- });
49
+ return true;
50
+ };
51
+ }
52
+ /**
53
+ * A request that failed in a way the route has no event for, such as a store
54
+ * error: the browser is told, the response ends, and the error is logged. It
55
+ * is never thrown on to the server, where an unhandled rejection would take
56
+ * the process down and leave the response open.
57
+ */
58
+ function failed(res, err) {
59
+ console.error("[haikit] request failed:", err);
60
+ const message = err instanceof Error ? err.message : String(err);
61
+ if (!res.headersSent) {
62
+ res.writeHead(500, { "content-type": "application/json" });
63
+ res.end(JSON.stringify({ error: message }));
64
+ }
65
+ else if (!res.writableEnded) {
66
+ res.write(`data: ${JSON.stringify({ type: "error", message })}\n\n`);
67
+ res.end();
68
+ }
69
+ }
70
+ /** One of the three POST routes, once the request is known to be one. */
71
+ async function respond(hai, route, req, res) {
72
+ let body;
73
+ try {
74
+ body = await readJson(req);
75
+ }
76
+ catch {
77
+ res.writeHead(400, { "content-type": "application/json" });
78
+ res.end(JSON.stringify({ error: "the request body must be a JSON object" }));
79
+ return;
80
+ }
81
+ // Nothing to run before the first message: answer with an empty stream and
82
+ // create no conversation, so an app without init doesn't get one for every
83
+ // page view. Its first message starts the conversation, as it always has.
84
+ if (route === "/start" && !hai.config.init) {
85
+ openSSE(res);
86
+ res.end();
87
+ return;
88
+ }
89
+ // Before the SSE stream opens, because a rejected load has no stream to
90
+ // report into: busy is a 409, and anything else a 500 (see `failed`).
91
+ let conversation;
92
+ try {
93
+ conversation = await hai.config.store.loadConversation(body.conversationId);
94
+ }
95
+ catch (err) {
96
+ if (!isConversationBusy(err))
97
+ throw err;
98
+ res.writeHead(409, { "content-type": "application/json" });
99
+ res.end(JSON.stringify({ error: err.message }));
100
+ return;
101
+ }
102
+ const emit = openSSE(res);
103
+ if (route === "/chat" || route === "/start") {
104
+ emit({
105
+ type: "hello",
106
+ conversationId: conversation.id,
107
+ model: hai.config.model.id,
108
+ ...(hai.streamsEvents ? { events: true } : {}),
109
+ });
110
+ }
111
+ let superseded = false;
112
+ try {
113
+ if (route === "/chat") {
114
+ await hai.send(conversation, String(body.message ?? ""), emit);
115
+ }
116
+ else if (route === "/start") {
117
+ await hai.start(conversation, emit);
59
118
  }
60
- let superseded = false;
119
+ else {
120
+ await hai.interact(conversation, { handle: body.handle, action: body.action, value: body.value }, emit);
121
+ }
122
+ }
123
+ catch (err) {
124
+ if (isStaleLease(err))
125
+ superseded = true;
126
+ emit({ type: "error", message: err.message });
127
+ }
128
+ finally {
129
+ // The lease lives exactly as long as the request. Releasing it any
130
+ // earlier — when a turn parks, say — lets the client's next interaction
131
+ // arrive before this request has finished writing, and the two turns
132
+ // interleave on one conversation.
133
+ //
134
+ // Only the expiry is cleared. The token has to survive so the save below
135
+ // can still prove this request is the rightful holder.
136
+ conversation.leaseUntil = null;
61
137
  try {
62
- if (route === "/chat") {
63
- await hai.send(conversation, String(body.message ?? ""), emit);
64
- }
65
- else if (route === "/start") {
66
- await hai.start(conversation, emit);
67
- }
68
- else {
69
- await hai.interact(conversation, { handle: body.handle, action: body.action, value: body.value }, emit);
70
- }
138
+ // A turn that already hit a fence knows its save would be rejected too;
139
+ // attempting it only repeats the same error to the client.
140
+ if (!superseded)
141
+ await hai.config.store.saveConversation(conversation);
71
142
  }
72
143
  catch (err) {
73
- if (isStaleLease(err))
74
- 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))
150
+ throw err;
75
151
  emit({ type: "error", message: err.message });
76
152
  }
77
- finally {
78
- // The lease lives exactly as long as the request. Releasing it any
79
- // earlier — when a turn parks, say — lets the client's next interaction
80
- // arrive before this request has finished writing, and the two turns
81
- // interleave on one conversation.
82
- //
83
- // Only the expiry is cleared. The token has to survive so the save below
84
- // can still prove this request is the rightful holder.
85
- conversation.leaseUntil = null;
86
- try {
87
- // A turn that already hit a fence knows its save would be rejected too;
88
- // attempting it only repeats the same error to the client.
89
- if (!superseded)
90
- await hai.config.store.saveConversation(conversation);
91
- }
92
- catch (err) {
93
- // Overtaken while we were slow: another turn already wrote newer state
94
- // under a fresh token. Dropping this write is the correct outcome, but
95
- // the client asked for something it is not getting, so say so.
96
- if (!isStaleLease(err))
97
- throw err;
98
- emit({ type: "error", message: err.message });
99
- }
100
- }
101
- res.end();
102
- return true;
103
- };
153
+ }
154
+ res.end();
104
155
  }
105
156
  /**
106
157
  * The longest a notice waits to be read. A store's `watch` makes it sooner;
107
158
  * this is what still finds a notice whose wake-up was lost.
108
159
  */
109
160
  const POLL_MS = 2_000;
161
+ /**
162
+ * A short fingerprint of signal values, by name: what a polling browser says it
163
+ * has, so a poll knows whether the latest values are news to it. The client
164
+ * computes the same (packages/client/src/index.js); change both together.
165
+ */
166
+ export function signalsDigest(entries) {
167
+ const text = JSON.stringify([...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
168
+ let hash = 0x811c9dc5;
169
+ for (let i = 0; i < text.length; i++)
170
+ hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193);
171
+ return (hash >>> 0).toString(16);
172
+ }
173
+ /** How long a process keeps hearing a conversation's turns after its last stream closes. */
174
+ const WATCH_LINGER_MS = 60_000;
175
+ /** How long a wake turn that has ended is kept, for a poll that missed its end, and how many at most. */
176
+ const ENDED_TURN_KEPT_MS = 60_000;
177
+ const ENDED_TURNS_KEPT = 5;
178
+ /** The longest a poll holds when there is nothing new to send. */
179
+ const POLL_HOLD_MS = 15_000;
180
+ /** How long a poll waits after something new, for what comes with it, before it ends. */
181
+ const POLL_GATHER_MS = 250;
110
182
  /** Proxies drop a stream that says nothing for long; a comment keeps it open. */
111
183
  const HEARTBEAT_MS = 25_000;
112
184
  /** Notices read from the store at a time, so a long backlog goes out page by page. */
@@ -126,6 +198,18 @@ const PAGE = 100;
126
198
  async function streamNotices(hai, req, res, url) {
127
199
  const { store } = hai.config;
128
200
  const conversationId = url.searchParams.get("conversationId") ?? "";
201
+ // A poll, for a browser behind a proxy that holds a stream back until it
202
+ // ends: what's waiting, then the response ends, which any proxy delivers.
203
+ // `poll=now` answers at once; `poll=1` holds until something new arrives,
204
+ // or for `wait` ms (at most POLL_HOLD_MS) if nothing does.
205
+ const poll = url.searchParams.get("poll");
206
+ const polling = poll === "1" || poll === "now";
207
+ // a fingerprint of the signal values the browser already has (signalsDigest)
208
+ const hasSignals = url.searchParams.get("signals");
209
+ // the last wake turn the browser saw end, by its number
210
+ const sawTurn = Number(url.searchParams.get("turn") ?? 0);
211
+ const asked = Number(url.searchParams.get("wait") ?? POLL_HOLD_MS);
212
+ const holdMs = Number.isFinite(asked) ? Math.min(Math.max(asked, 0), POLL_HOLD_MS) : POLL_HOLD_MS;
129
213
  const resume = Number(req.headers["last-event-id"] ?? url.searchParams.get("after") ?? 0);
130
214
  let after = Number.isSafeInteger(resume) && resume > 0 ? resume : 0;
131
215
  const first = await store.getNotices(conversationId, after, PAGE);
@@ -139,6 +223,7 @@ async function streamNotices(hai, req, res, url) {
139
223
  // Counted as watching from here, so nothing lets the entry go while this
140
224
  // stream is still being set up.
141
225
  const watchers = watchersOf(hai, conversationId);
226
+ watchers.stay();
142
227
  watchers.pending++;
143
228
  try {
144
229
  await watchers.ready;
@@ -159,8 +244,11 @@ async function streamNotices(hai, req, res, url) {
159
244
  connection: "keep-alive",
160
245
  });
161
246
  // Sent now, not with the first frame: with nothing to catch up on, that is a
162
- // heartbeat away, and the browser's request would hang open until then.
247
+ // heartbeat away, and the browser's request would hang open until then. A
248
+ // comment with them, so the browser hears its first bytes at once: one that
249
+ // hears nothing knows something on the way is holding the stream, and polls.
163
250
  res.flushHeaders();
251
+ res.write(": open\n\n");
164
252
  const aborter = new AbortController();
165
253
  const { signal } = aborter;
166
254
  res.on("close", () => aborter.abort());
@@ -178,12 +266,20 @@ async function streamNotices(hai, req, res, url) {
178
266
  signal.addEventListener("abort", done);
179
267
  });
180
268
  };
181
- // A wake turn's events go out as they come, one frame each, and a stream
182
- // the browser has left takes no more.
183
- const emit = (event) => {
269
+ // Something new for this browser: a poll ends a moment after it, to gather
270
+ // what comes with it.
271
+ let markFresh = () => { };
272
+ const freshened = new Promise((resolve) => (markFresh = resolve));
273
+ // A frame out, and a stream the browser has left takes no more.
274
+ const send = (event) => {
184
275
  if (!signal.aborted && !res.writableEnded)
185
276
  res.write(`data: ${JSON.stringify(event)}\n\n`);
186
277
  };
278
+ // A wake turn's events go out as they come, one frame each.
279
+ const emit = (event) => {
280
+ send(event);
281
+ markFresh();
282
+ };
187
283
  // Every stream this process has open for the conversation hears a wake
188
284
  // turn, not only the one whose attempt won the lease: another tab shows the
189
285
  // reply too, and holds its requests until the turn lets go.
@@ -216,7 +312,23 @@ async function streamNotices(hai, req, res, url) {
216
312
  }
217
313
  res.write(`data: ${JSON.stringify(event)}\n\n`);
218
314
  };
219
- const closeStream = hai.streamOpened(conversationId, emitSignal);
315
+ // A new signal is news. Each one's latest, sent as the stream opens, is
316
+ // news to a poll only if the browser doesn't have those values yet, such as
317
+ // one sent between two polls: every poll gets them, and a poll that always
318
+ // ended on them would never wait.
319
+ const replayed = [];
320
+ const closeStream = hai.streamOpened(conversationId, (event) => {
321
+ emitSignal(event);
322
+ markFresh();
323
+ }, (event) => {
324
+ emitSignal(event);
325
+ if (!polling || event.type !== "signal")
326
+ return;
327
+ // the latest are sent one after another, at once: compare once all are in
328
+ if (!replayed.length)
329
+ queueMicrotask(() => signalsDigest(replayed) !== hasSignals && markFresh());
330
+ replayed.push([event.name, event.payload]);
331
+ });
220
332
  // Joining while a wake turn runs: what it has shown so far, starting with
221
333
  // its `streaming` status, so the browser holds requests until it lets go,
222
334
  // and the reply's later text has a block to land in.
@@ -224,16 +336,38 @@ async function streamNotices(hai, req, res, url) {
224
336
  // freshness from when the surface was stored, not from now.
225
337
  if (watchers.live)
226
338
  for (const event of watchers.shown)
227
- emit(aged(event));
339
+ send(aged(event));
340
+ else if (polling) {
341
+ // A turn that ended between two polls, unseen: all it showed, its end
342
+ // included, which is news. Then that no turn is running, which a browser
343
+ // that saw nothing of one needs too, or it would wait for it for ever.
344
+ // every one it missed, oldest first, if more than one ended meanwhile
345
+ const missed = Number.isSafeInteger(sawTurn) ? watchers.ended.filter((turn) => turn.number > sawTurn) : [];
346
+ for (const turn of missed)
347
+ for (const event of turn.shown)
348
+ send(aged(event));
349
+ if (missed.length)
350
+ markFresh();
351
+ send({ type: "released" });
352
+ }
228
353
  // Wake turns run beside the notice loop, so a notice never waits behind a
229
354
  // turn, nor behind the retries while another request holds the
230
355
  // conversation. One attempt at a time; a wake notice that lands meanwhile
231
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.
232
364
  let waking = null;
233
365
  let again = false;
234
366
  const wake = () => {
235
367
  if (!hai.wakes || signal.aborted)
236
368
  return;
369
+ if (watchers.wakeRead <= watchers.wakeSettled)
370
+ return;
237
371
  if (waking) {
238
372
  again = true;
239
373
  return;
@@ -241,12 +375,24 @@ async function streamNotices(hai, req, res, url) {
241
375
  waking = (async () => {
242
376
  do {
243
377
  again = false;
378
+ // every wake notice read here so far: what the attempt settles, unless it fails
379
+ const through = watchers.wakeRead;
244
380
  let wait = WAKE_RETRY_MS;
245
- while (!signal.aborted && (await hai.wake(conversationId, watchers.turn())) === "busy") {
381
+ let outcome = "busy";
382
+ while (!signal.aborted && (outcome = await hai.wake(conversationId, watchers.turn())) === "busy") {
246
383
  await pause(wait, signal);
247
384
  wait = Math.min(wait * 2, WAKE_RETRY_MAX_MS);
385
+ // Another stream here may have been the one holding it, with a turn
386
+ // that settled these notices: taking the lease again finds nothing.
387
+ if (through <= watchers.wakeSettled)
388
+ break;
248
389
  }
249
- } while (again && !signal.aborted);
390
+ // A failed turn gives its notices back to wake again, so it settles
391
+ // nothing: the next stream to open tries once more.
392
+ if (outcome !== "busy" && outcome !== "failed")
393
+ watchers.wakeSettled = Math.max(watchers.wakeSettled, through);
394
+ // asked again meanwhile: only if a notice came that this one didn't settle
395
+ } while (again && !signal.aborted && watchers.wakeRead > watchers.wakeSettled);
250
396
  })()
251
397
  .catch((err) => emit({ type: "error", message: err.message }))
252
398
  .finally(() => {
@@ -301,7 +447,11 @@ async function streamNotices(hai, req, res, url) {
301
447
  if (n.replaces !== undefined && n.handle !== undefined) {
302
448
  await sendRevision(n.handle, n.replaces, n.seq);
303
449
  after = n.seq;
304
- woken ||= n.kind === "wake";
450
+ markFresh();
451
+ if (n.kind === "wake") {
452
+ woken = true;
453
+ watchers.wakeRead = Math.max(watchers.wakeRead, n.seq);
454
+ }
305
455
  continue;
306
456
  }
307
457
  const event = {
@@ -314,7 +464,11 @@ async function streamNotices(hai, req, res, url) {
314
464
  };
315
465
  await write(`id: ${n.seq}\ndata: ${JSON.stringify(event)}\n\n`);
316
466
  after = n.seq;
317
- woken ||= n.kind === "wake";
467
+ markFresh();
468
+ if (n.kind === "wake") {
469
+ woken = true;
470
+ watchers.wakeRead = Math.max(watchers.wakeRead, n.seq);
471
+ }
318
472
  }
319
473
  if (records.length < PAGE || signal.aborted)
320
474
  break;
@@ -328,16 +482,15 @@ async function streamNotices(hai, req, res, url) {
328
482
  // An app that sends no notices has nothing stored to read: the stream is only
329
483
  // for signals, and stays open, reading nothing, until the browser leaves.
330
484
  const wakes = hai.sendsNotices ? wakeups(store.watch?.(conversationId, signal), signal)[Symbol.asyncIterator]() : null;
331
- try {
332
- if (!wakes) {
333
- await new Promise((resolve) => {
334
- if (signal.aborted)
335
- resolve();
336
- else
337
- signal.addEventListener("abort", () => resolve(), { once: true });
338
- });
339
- return;
340
- }
485
+ const left = new Promise((resolve) => {
486
+ if (signal.aborted)
487
+ resolve();
488
+ else
489
+ signal.addEventListener("abort", () => resolve(), { once: true });
490
+ });
491
+ const run = async () => {
492
+ if (!wakes)
493
+ return left;
341
494
  await catchUp(first);
342
495
  // A wake notice may be waiting from before this browser connected: one
343
496
  // that arrived while nobody watched, or while the conversation was busy.
@@ -352,6 +505,24 @@ async function streamNotices(hai, req, res, url) {
352
505
  next = wakes.next();
353
506
  await catchUp();
354
507
  }
508
+ };
509
+ try {
510
+ if (!polling)
511
+ return await run();
512
+ // A poll: the same reading, until something new has gone out and what
513
+ // came with it has had a moment to follow, or the hold runs out.
514
+ if (poll === "now")
515
+ markFresh();
516
+ let hold;
517
+ const done = Promise.race([
518
+ left,
519
+ freshened.then(() => new Promise((resolve) => setTimeout(resolve, POLL_GATHER_MS))),
520
+ new Promise((resolve) => (hold = setTimeout(resolve, holdMs))),
521
+ ]);
522
+ // a read that fails ends nothing early: the poll answers at its hold
523
+ void run().catch((err) => console.error("[haikit] events poll failed:", err));
524
+ await done;
525
+ clearTimeout(hold);
355
526
  }
356
527
  finally {
357
528
  clearInterval(heartbeat);
@@ -450,8 +621,9 @@ function watchersOf(hai, conversationId) {
450
621
  const origin = originOf(hai);
451
622
  // Hearing other processes' wake turns lasts as long as this entry does.
452
623
  const hearing = new AbortController();
624
+ let lingering = null;
453
625
  const forget = () => {
454
- if (map.get(conversationId) !== fresh || fresh.pending || fresh.streams.size || fresh.live)
626
+ if (map.get(conversationId) !== fresh || fresh.pending || fresh.streams.size || fresh.live || fresh.ended.length || lingering)
455
627
  return;
456
628
  map.delete(conversationId);
457
629
  hearing.abort();
@@ -466,6 +638,7 @@ function watchersOf(hai, conversationId) {
466
638
  // start. A turn takes over only with a number at least as high as any
467
639
  // seen; anything but the current turn's frames is dropped.
468
640
  let current = null;
641
+ let currentNumber;
469
642
  let newest = 0;
470
643
  const deliver = (event, turn, number) => {
471
644
  if (event.type === "status" && event.status === "streaming") {
@@ -473,6 +646,7 @@ function watchersOf(hai, conversationId) {
473
646
  if (number !== undefined && number < newest)
474
647
  return; // an older turn's late start
475
648
  current = turn;
649
+ currentNumber = number;
476
650
  if (number !== undefined)
477
651
  newest = number;
478
652
  fresh.live = true;
@@ -484,6 +658,15 @@ function watchersOf(hai, conversationId) {
484
658
  }
485
659
  remember(fresh.shown, event);
486
660
  if (event.type === "released") {
661
+ // kept a while for a poll that missed the end, then let go
662
+ if (currentNumber !== undefined) {
663
+ const ended = { number: currentNumber, shown: fresh.shown };
664
+ fresh.ended = [...fresh.ended, ended].slice(-ENDED_TURNS_KEPT);
665
+ setTimeout(() => {
666
+ fresh.ended = fresh.ended.filter((turn) => turn !== ended);
667
+ forget();
668
+ }, ENDED_TURN_KEPT_MS).unref?.();
669
+ }
487
670
  current = null;
488
671
  fresh.live = false;
489
672
  fresh.shown = [];
@@ -498,8 +681,25 @@ function watchersOf(hai, conversationId) {
498
681
  streams: new Set(),
499
682
  live: false,
500
683
  shown: [],
684
+ ended: [],
501
685
  forget,
686
+ stay: () => {
687
+ if (lingering)
688
+ clearTimeout(lingering);
689
+ lingering = null;
690
+ },
691
+ leave: () => {
692
+ if (lingering)
693
+ clearTimeout(lingering);
694
+ lingering = setTimeout(() => {
695
+ lingering = null;
696
+ forget();
697
+ }, WATCH_LINGER_MS);
698
+ lingering.unref?.();
699
+ },
502
700
  ready: Promise.resolve(),
701
+ wakeRead: 0,
702
+ wakeSettled: -1,
503
703
  // A wake turn running here: its frames to this process's streams, and,
504
704
  // through the store, to every other process's. All but `context`, the
505
705
  // whole history, which another tab gets with its own next request.
@@ -542,8 +742,9 @@ function watchersOf(hai, conversationId) {
542
742
  }
543
743
  function forgetWatchers(_hai, _conversationId, watchers) {
544
744
  // Kept while a wake turn runs, so a stream that joins later still hears it,
545
- // and while any stream is open or being set up. `forget` checks all three.
546
- watchers.forget();
745
+ // while any stream is open or being set up, while an ended turn is kept, and
746
+ // for a while after the last stream closes. `forget` checks them all.
747
+ watchers.leave();
547
748
  }
548
749
  /** The first wait before trying a wake turn again on a busy conversation, and the longest. */
549
750
  const WAKE_RETRY_MS = 250;
@@ -607,10 +808,14 @@ function openSSE(res) {
607
808
  res.write(`data: ${JSON.stringify(event)}\n\n`);
608
809
  };
609
810
  }
811
+ /** The request body, as a JSON object; anything else, or a body that fails to arrive, throws. */
610
812
  async function readJson(req) {
611
813
  const chunks = [];
612
814
  for await (const chunk of req)
613
815
  chunks.push(chunk);
614
- return JSON.parse(Buffer.concat(chunks).toString() || "{}");
816
+ const body = JSON.parse(Buffer.concat(chunks).toString() || "{}");
817
+ if (!body || typeof body !== "object" || Array.isArray(body))
818
+ throw new TypeError("not a JSON object");
819
+ return body;
615
820
  }
616
821
  //# sourceMappingURL=routes.js.map