@haikit/server 0.20.0 → 0.20.2

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
+ }
65
71
 
66
- let superseded = false;
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
+ }
82
+
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
  /**
@@ -306,10 +354,18 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
306
354
  // turn, nor behind the retries while another request holds the
307
355
  // conversation. One attempt at a time; a wake notice that lands meanwhile
308
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.
309
364
  let waking: Promise<void> | null = null;
310
365
  let again = false;
311
366
  const wake = () => {
312
367
  if (!hai.wakes || signal.aborted) return;
368
+ if (watchers.wakeRead <= watchers.wakeSettled) return;
313
369
  if (waking) {
314
370
  again = true;
315
371
  return;
@@ -317,12 +373,22 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
317
373
  waking = (async () => {
318
374
  do {
319
375
  again = false;
376
+ // every wake notice read here so far: what the attempt settles, unless it fails
377
+ const through = watchers.wakeRead;
320
378
  let wait = WAKE_RETRY_MS;
321
- 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") {
322
381
  await pause(wait, signal);
323
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;
324
386
  }
325
- } 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);
326
392
  })()
327
393
  .catch((err) => emit({ type: "error", message: (err as Error).message }))
328
394
  .finally(() => {
@@ -374,7 +440,10 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
374
440
  await sendRevision(n.handle, n.replaces, n.seq);
375
441
  after = n.seq;
376
442
  markFresh();
377
- woken ||= n.kind === "wake";
443
+ if (n.kind === "wake") {
444
+ woken = true;
445
+ watchers.wakeRead = Math.max(watchers.wakeRead, n.seq);
446
+ }
378
447
  continue;
379
448
  }
380
449
  const event: WireEvent = {
@@ -388,7 +457,10 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
388
457
  await write(`id: ${n.seq}\ndata: ${JSON.stringify(event)}\n\n`);
389
458
  after = n.seq;
390
459
  markFresh();
391
- woken ||= n.kind === "wake";
460
+ if (n.kind === "wake") {
461
+ woken = true;
462
+ watchers.wakeRead = Math.max(watchers.wakeRead, n.seq);
463
+ }
392
464
  }
393
465
  if (records.length < PAGE || signal.aborted) break;
394
466
  page = null;
@@ -432,7 +504,8 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
432
504
  freshened.then(() => new Promise<void>((resolve) => setTimeout(resolve, POLL_GATHER_MS))),
433
505
  new Promise<void>((resolve) => (hold = setTimeout(resolve, holdMs))),
434
506
  ]);
435
- void run().catch(() => {});
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));
436
509
  await done;
437
510
  clearTimeout(hold);
438
511
  } finally {
@@ -486,6 +559,21 @@ interface Watchers {
486
559
  leave: () => void;
487
560
  /** Settles once this process is listening for other processes' turns. */
488
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;
489
577
  }
490
578
 
491
579
  /** Fold a wake turn's frame into what it has shown so far. */
@@ -637,6 +725,8 @@ function watchersOf(hai: Hai, conversationId: string): Watchers {
637
725
  lingering.unref?.();
638
726
  },
639
727
  ready: Promise.resolve(),
728
+ wakeRead: 0,
729
+ wakeSettled: -1,
640
730
  // A wake turn running here: its frames to this process's streams, and,
641
731
  // through the store, to every other process's. All but `context`, the
642
732
  // whole history, which another tab gets with its own next request.
@@ -752,8 +842,11 @@ function openSSE(res: ServerResponse): Emit {
752
842
  };
753
843
  }
754
844
 
755
- 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>> {
756
847
  const chunks: Buffer[] = [];
757
848
  for await (const chunk of req) chunks.push(chunk as Buffer);
758
- 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>;
759
852
  }
package/src/runtime.ts CHANGED
@@ -201,6 +201,13 @@ export class Hai {
201
201
  private readonly surfaceNames = new Map<string, Map<string, string>>();
202
202
  /** What the model gets as its system prompt: the app's, then the scope. */
203
203
  readonly system: string;
204
+ /**
205
+ * Block ids: this Hai's own random part, then a count. The browser matches
206
+ * blocks by id for a whole conversation, across POST responses and the
207
+ * events stream, which another server, or this one restarted, may have
208
+ * sent: a count alone would repeat there, and a new block overwrite an old.
209
+ */
210
+ private readonly blockIds = globalThis.crypto.randomUUID().replaceAll("-", "");
204
211
  private seq = 0;
205
212
 
206
213
  constructor(config: HaiConfig) {
@@ -770,7 +777,7 @@ export class Hai {
770
777
  }
771
778
 
772
779
  private nid(prefix: string) {
773
- return `${prefix}_${++this.seq}`;
780
+ return `${prefix}_${this.blockIds}_${++this.seq}`;
774
781
  }
775
782
 
776
783
  // ───────────────────────────────────────────────── derived tool
package/src/store.ts CHANGED
@@ -9,6 +9,28 @@ import {
9
9
 
10
10
  const LEASE_MS = 120_000;
11
11
 
12
+ /**
13
+ * Postgres refuses U+0000 anywhere in text or jsonb, and a lone UTF-16
14
+ * surrogate in jsonb, so pgStore makes them storable as it saves. This store
15
+ * does the same, so development sees what production would store. Each
16
+ * becomes U+FFFD, the replacement character, in strings and object keys
17
+ * alike.
18
+ */
19
+ const UNSTORABLE = /\u0000|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
20
+ const MAYBE_UNSTORABLE = /[\u0000\uD800-\uDFFF]/;
21
+ const storable = (s: string): string => (MAYBE_UNSTORABLE.test(s) ? s.replace(UNSTORABLE, "\uFFFD") : s);
22
+ /** A `JSON.stringify` replacer that makes every string and key storable. */
23
+ function storableJson(_key: string, value: unknown): unknown {
24
+ if (typeof value === "string") return storable(value);
25
+ if (value && typeof value === "object" && !Array.isArray(value)) {
26
+ const keys = Object.keys(value);
27
+ if (keys.some((k) => MAYBE_UNSTORABLE.test(k))) {
28
+ return Object.fromEntries(keys.map((k) => [storable(k), (value as Record<string, unknown>)[k]]));
29
+ }
30
+ }
31
+ return value;
32
+ }
33
+
12
34
  export interface MemoryStoreOptions {
13
35
  /**
14
36
  * Turn lease TTL. A process that dies mid-turn strands the conversation for
@@ -60,7 +82,8 @@ export function memoryStore(options: MemoryStoreOptions = {}): StoreAdapter {
60
82
  let convSeq = 0;
61
83
  let handleSeq = 0;
62
84
 
63
- const copy = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
85
+ // and anything Postgres can't store comes back as pgStore would return it
86
+ const copy = <T>(value: T): T => JSON.parse(JSON.stringify(value, storableJson)) as T;
64
87
  const newToken = () => globalThis.crypto.randomUUID();
65
88
 
66
89
  /** Every durable mutation presents the token it was issued. A holder that has