@haikit/server 0.18.0 → 0.20.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/runtime.ts CHANGED
@@ -10,6 +10,8 @@ import {
10
10
  type ModelAdapter,
11
11
  type Notify,
12
12
  type Progress,
13
+ type SendSignal,
14
+ type Signal,
13
15
  type StoreAdapter,
14
16
  type Tool,
15
17
  type ToolCtx,
@@ -18,6 +20,7 @@ import {
18
20
  type UpdateOptions,
19
21
  type WireEvent,
20
22
  } from "@haikit/core";
23
+ import { Broadcast } from "./signals.js";
21
24
 
22
25
  export interface HaiConfig {
23
26
  model: ModelAdapter;
@@ -74,6 +77,20 @@ export interface HaiConfig {
74
77
  * with `wake` asks for.
75
78
  */
76
79
  updates?: boolean;
80
+ /**
81
+ * The signals this app sends with `hai.signal`: short-lived broadcasts to
82
+ * every open browser. A signal not listed here is refused. With any listed,
83
+ * the browser opens the events stream, which is how a signal reaches it.
84
+ */
85
+ signals?: Signal<any>[];
86
+ /**
87
+ * How presence is counted (`hai.onPresence`). `graceMs`: how long a
88
+ * conversation whose events stream closed still counts, so a browser that
89
+ * reconnects doesn't flicker the count; default 5 s. `heartbeatMs`: how
90
+ * often servers sharing a store tell each other their counts; a server not
91
+ * heard from for three beats stops counting; default 10 s.
92
+ */
93
+ presence?: { graceMs?: number; heartbeatMs?: number };
77
94
  }
78
95
 
79
96
  /**
@@ -143,6 +160,9 @@ interface UpdateQueue {
143
160
  next: UpdateJob | null;
144
161
  }
145
162
 
163
+ /** The longest delay a Node timer keeps; past it, one fires after about a millisecond. */
164
+ const MAX_TIMER_MS = 2_147_483_647;
165
+
146
166
  /** How long `hai.update` waits for a turn to let the conversation go, by default. */
147
167
  const UPDATE_TIMEOUT_MS = 30_000;
148
168
  /** How many conversations `hai.update` remembers its surfaces' handles for. */
@@ -167,6 +187,9 @@ export class Hai {
167
187
  private readonly queryUi: Tool;
168
188
  /** Handles each conversation has a revision of in flight, so two calls can't revise one at once. */
169
189
  private readonly revising = new WeakMap<Conversation, Set<string>>();
190
+ private readonly signals = new Map<string, Signal<any>>();
191
+ /** Signals sent, streams open, and presence, here and on the other servers. */
192
+ private readonly broadcast: Broadcast;
170
193
  /** `hai.update` calls by conversation and surface: one writing, and the newest waiting behind it. */
171
194
  private readonly updating = new Map<string, UpdateQueue>();
172
195
  /**
@@ -203,6 +226,25 @@ export class Hai {
203
226
  );
204
227
  }
205
228
  this.maxWakes = { count, perMs };
229
+ for (const s of config.signals ?? []) {
230
+ if (this.signals.has(s.name)) throw new Error(`two signals are named "${s.name}"`);
231
+ this.signals.set(s.name, s);
232
+ }
233
+ const { graceMs = 5_000, heartbeatMs = 10_000 } = config.presence ?? {};
234
+ for (const [name, ms] of [["graceMs", graceMs], ["heartbeatMs", heartbeatMs]] as const) {
235
+ // Past the longest timer Node keeps, it fires after about a millisecond:
236
+ // a grace would end at once, and a heartbeat would spin.
237
+ // Whole milliseconds, as timers count them: a fractional one would be
238
+ // scheduled shorter than the staleness arithmetic assumes.
239
+ if (typeof ms !== "number" || !Number.isInteger(ms) || ms <= 0 || ms > MAX_TIMER_MS) {
240
+ throw new RangeError(
241
+ `presence.${name} must be a whole number of milliseconds, from 1 to ${MAX_TIMER_MS} (got ${String(ms)})`,
242
+ );
243
+ }
244
+ }
245
+ this.broadcast = new Broadcast(config.store, graceMs, heartbeatMs);
246
+ // hear the other servers' signals from now, so a stream that opens later gets their latest
247
+ if (this.signals.size) this.broadcast.start();
206
248
  // query_ui is DERIVED, never authored. It cannot drift from the surfaces
207
249
  // that actually exist, and it always exists.
208
250
  const queryUi = this.buildQueryUiTool();
@@ -218,13 +260,22 @@ export class Hai {
218
260
  }
219
261
 
220
262
  /**
221
- * Whether this app sends notices, `hai.update`'s included, so the browser
222
- * should open the events stream.
263
+ * Whether this app sends notices, `hai.update`'s included: what the
264
+ * conversation's history may have unread, and the events stream may have to
265
+ * deliver.
223
266
  */
224
267
  get sendsNotices(): boolean {
225
268
  return this.notices.size > 0 || this.config.updates === true;
226
269
  }
227
270
 
271
+ /**
272
+ * Whether the browser should open the events stream: for notices, or for
273
+ * signals, which travel on it too.
274
+ */
275
+ get streamsEvents(): boolean {
276
+ return this.sendsNotices || this.signals.size > 0;
277
+ }
278
+
228
279
  /**
229
280
  * Whether any of its notices may be `wake` notices, which the events route
230
281
  * starts turns for: a registered one, or an update sent with `wake`.
@@ -277,6 +328,57 @@ export class Hai {
277
328
  return { seq: record.seq };
278
329
  };
279
330
 
331
+ /**
332
+ * Send a signal to every open events stream, on this server and, through a
333
+ * store with `publish` and `subscribe`, on the others. Not stored, not
334
+ * numbered, never in the model's context or the transcript: the browser
335
+ * hands it to the app's handler for it. Each signal's latest payload is
336
+ * kept, and a stream that opens later is sent it first.
337
+ *
338
+ * Throws for a signal not listed in `signals`, or a payload that fails its
339
+ * schema.
340
+ */
341
+ readonly signal: SendSignal = (signal, payload) => {
342
+ const name = signal?.name;
343
+ if (this.signals.get(name) !== signal) {
344
+ throw new Error(`signal "${name}" is not registered: list it in createHai({ signals })`);
345
+ }
346
+ this.broadcast.send({ type: "signal", name, version: signal.version, payload: signal.payload.parse(payload) });
347
+ };
348
+
349
+ /**
350
+ * How many conversations have an events stream open, on every server
351
+ * sharing the store: a tab with the app open, as near as the server can
352
+ * tell. `listener` hears the count now and whenever it changes. A stream
353
+ * that closes still counts for `presence.graceMs`, so a reconnect doesn't
354
+ * flicker it. Returns a function that stops listening.
355
+ *
356
+ * Each server counts its own and the totals are summed, so a conversation
357
+ * counts once per server it has a stream on: one that reconnects to another
358
+ * server counts on both until the grace runs out, and one with streams on
359
+ * two servers at once counts twice. (The client opens one stream per
360
+ * conversation, so the second doesn't happen with it.)
361
+ *
362
+ * Only the number leaves a server: no conversation ids, and nothing the
363
+ * model sees. An app with no notices, updates or signals opens no events
364
+ * stream, so counts nothing.
365
+ */
366
+ onPresence(listener: (count: number) => void): () => void {
367
+ return this.broadcast.onPresence(listener);
368
+ }
369
+
370
+ /**
371
+ * An events stream opened for `conversationId`: it hears every signal from
372
+ * now on, starting with each one's latest payload, and counts toward
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. The
375
+ * latest payloads go through `replay` when given, so a caller can tell them
376
+ * from new ones.
377
+ */
378
+ streamOpened(conversationId: string, emit: Emit, replay?: Emit): () => void {
379
+ return this.broadcast.opened(conversationId, emit, replay);
380
+ }
381
+
280
382
  /**
281
383
  * Revise a surface from outside any turn: a webhook, a job, a price feed.
282
384
  * The revision is stored and attached as `ctx.update` does, under the
package/src/signals.ts ADDED
@@ -0,0 +1,221 @@
1
+ import type { Emit, StoreAdapter, WireEvent } from "@haikit/core";
2
+
3
+ /**
4
+ * The channel signals and presence travel on between servers, through the
5
+ * store's `publish` and `subscribe`. Those are keyed by conversation; this
6
+ * name has a colon, which no conversation id a store issues has, so it can't
7
+ * be mistaken for one.
8
+ */
9
+ export const SIGNAL_CHANNEL = "haikit:signals";
10
+
11
+ type SignalEvent = Extract<WireEvent, { type: "signal" }>;
12
+
13
+ /**
14
+ * What one Hai broadcasts, and who is watching: every open events stream in
15
+ * this process, each signal's latest payload, and how many conversations have
16
+ * a stream open, here and on the other servers sharing the store.
17
+ */
18
+ export class Broadcast {
19
+ /** Every events stream open in this process. */
20
+ private readonly streams = new Set<Emit>();
21
+ /** Each signal's latest payload, by name, for a stream that opens later. */
22
+ private readonly latest = new Map<string, SignalEvent>();
23
+ /** Conversations with a stream open here, and how many. */
24
+ private readonly open = new Map<string, number>();
25
+ /** Conversations whose last stream closed, still counted until the grace ends. */
26
+ private readonly leaving = new Map<string, ReturnType<typeof setTimeout>>();
27
+ /** The other servers' counts, by origin, with when each was last heard. */
28
+ private readonly peers = new Map<string, { count: number; heard: number }>();
29
+ private readonly listeners = new Set<(count: number) => void>();
30
+ private total = 0;
31
+ /** The local count other servers last heard from this one. */
32
+ private told = 0;
33
+ private started = false;
34
+ /** Settles once this server hears the others: before then, what they send is missed. */
35
+ private hearing: Promise<void> = Promise.resolve();
36
+ /** A publish to the other servers in flight, and what waits behind it: the latest of each kind only. */
37
+ private publishing = false;
38
+ private readonly unsent = new Map<string, object>();
39
+ readonly origin = globalThis.crypto.randomUUID();
40
+
41
+ constructor(
42
+ private readonly store: StoreAdapter,
43
+ private readonly graceMs: number,
44
+ private readonly heartbeatMs: number,
45
+ ) {}
46
+
47
+ /** Send a signal to every stream, here and on the other servers, and keep it for later ones. */
48
+ send(event: SignalEvent) {
49
+ this.latest.set(event.name, event);
50
+ for (const send of this.streams) send(event);
51
+ this.publish({ kind: "signal", event });
52
+ }
53
+
54
+ /**
55
+ * An events stream opened for `conversationId`: it hears signals from now
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.
59
+ */
60
+ opened(conversationId: string, emit: Emit, replay: Emit = emit): () => void {
61
+ this.start();
62
+ this.streams.add(emit);
63
+ let closed = false;
64
+ // Each signal's latest, once this server hears the others, so one they
65
+ // sent is among them. A live one may come first; the latest is still the
66
+ // latest, so at worst one arrives twice.
67
+ void this.hearing.then(() => {
68
+ if (!closed) for (const event of this.latest.values()) replay(event);
69
+ });
70
+
71
+ const wasLeaving = this.leaving.get(conversationId);
72
+ if (wasLeaving !== undefined) {
73
+ // back within the grace: it never stopped counting
74
+ clearTimeout(wasLeaving);
75
+ this.leaving.delete(conversationId);
76
+ }
77
+ const before = this.open.get(conversationId) ?? 0;
78
+ this.open.set(conversationId, before + 1);
79
+ if (before === 0 && wasLeaving === undefined) this.changed();
80
+
81
+ return () => {
82
+ if (closed) return;
83
+ closed = true;
84
+ this.streams.delete(emit);
85
+ const left = (this.open.get(conversationId) ?? 1) - 1;
86
+ if (left > 0) {
87
+ this.open.set(conversationId, left);
88
+ return;
89
+ }
90
+ this.open.delete(conversationId);
91
+ // A browser that drops its stream reconnects within a second or two:
92
+ // keep counting it a little longer, so the number doesn't flicker.
93
+ const timer = setTimeout(() => {
94
+ this.leaving.delete(conversationId);
95
+ this.changed();
96
+ }, this.graceMs);
97
+ timer.unref?.();
98
+ this.leaving.set(conversationId, timer);
99
+ };
100
+ }
101
+
102
+ /** Hear the total now and whenever it changes. Returns a function that stops. */
103
+ onPresence(listener: (count: number) => void): () => void {
104
+ this.start();
105
+ this.listeners.add(listener);
106
+ try {
107
+ listener(this.total);
108
+ } catch (err) {
109
+ // never handed its way to stop, so it must not stay
110
+ this.listeners.delete(listener);
111
+ throw err;
112
+ }
113
+ return () => void this.listeners.delete(listener);
114
+ }
115
+
116
+ /** Conversations with a stream open in this process, or within the grace. */
117
+ private get local() {
118
+ return this.open.size + this.leaving.size;
119
+ }
120
+
121
+ /**
122
+ * Start hearing the other servers, and telling them this one's count, once.
123
+ * A Hai that sends signals starts as it is created, so it has the others'
124
+ * latest values before its first stream opens; any other, when one does.
125
+ */
126
+ start() {
127
+ if (this.started) return;
128
+ this.started = true;
129
+ // For as long as this Hai lives. A store that can't listen still resolves,
130
+ // and hears nothing: signals and presence then stay on this server.
131
+ const subscribed = this.store.subscribe?.(SIGNAL_CHANNEL, (message) => this.hear(message), new AbortController().signal);
132
+ if (subscribed) this.hearing = Promise.resolve(subscribed).catch(() => {});
133
+ const beat = setInterval(() => this.beat(), this.heartbeatMs);
134
+ beat.unref?.();
135
+ }
136
+
137
+ private hear(message: string) {
138
+ let heard: { origin?: string; kind?: string; event?: SignalEvent; count?: number };
139
+ try {
140
+ heard = JSON.parse(message);
141
+ } catch {
142
+ return;
143
+ }
144
+ if (!heard || heard.origin === this.origin || typeof heard.origin !== "string") return;
145
+ if (heard.kind === "signal" && heard.event?.type === "signal" && typeof heard.event.name === "string") {
146
+ this.latest.set(heard.event.name, heard.event);
147
+ for (const send of this.streams) send(heard.event);
148
+ } else if (heard.kind === "presence" && Number.isSafeInteger(heard.count) && heard.count! >= 0) {
149
+ // a count is a number of conversations: a whole number, never Infinity
150
+ const known = this.peers.has(heard.origin);
151
+ const count = heard.count!;
152
+ if (count > 0) this.peers.set(heard.origin, { count, heard: Date.now() });
153
+ else this.peers.delete(heard.origin);
154
+ // a server it hadn't heard from yet hasn't heard from it either
155
+ if (!known && count > 0 && this.local > 0) this.publish({ kind: "presence", count: this.local });
156
+ this.recount();
157
+ }
158
+ }
159
+
160
+ /** This server's count changed: tell the listeners, and the other servers. */
161
+ private changed() {
162
+ this.recount();
163
+ if (this.local !== this.told) {
164
+ this.told = this.local;
165
+ this.publish({ kind: "presence", count: this.local });
166
+ }
167
+ }
168
+
169
+ /** On a timer: drop servers gone quiet, and remind the others this one is here. */
170
+ private beat() {
171
+ this.recount();
172
+ if (this.local > 0) this.publish({ kind: "presence", count: this.local });
173
+ }
174
+
175
+ private recount() {
176
+ // three missed beats and a server's count no longer counts: it has gone
177
+ const stale = Date.now() - 3 * this.heartbeatMs;
178
+ let others = 0;
179
+ for (const [origin, peer] of this.peers) {
180
+ if (peer.heard < stale) this.peers.delete(origin);
181
+ else others += peer.count;
182
+ }
183
+ // a sum of whole counts, kept a safe integer however large the peers claim to be
184
+ const total = Math.min(this.local + others, Number.MAX_SAFE_INTEGER);
185
+ if (total === this.total) return;
186
+ this.total = total;
187
+ for (const listener of [...this.listeners]) {
188
+ try {
189
+ listener(total);
190
+ } catch {
191
+ // one listener's mistake doesn't stop the others hearing
192
+ }
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Tell the other servers. One publish is in flight at a time, and only the
198
+ * latest of each signal, and of this server's count, waits behind it: a
199
+ * burst of signals can't queue every obsolete payload in the store.
200
+ */
201
+ private publish(body: { kind: "signal"; event: SignalEvent } | { kind: "presence"; count: number }) {
202
+ if (!this.store.publish) return;
203
+ this.unsent.delete(body.kind === "signal" ? `signal:${body.event.name}` : "presence");
204
+ this.unsent.set(body.kind === "signal" ? `signal:${body.event.name}` : "presence", body);
205
+ if (!this.publishing) void this.flush();
206
+ }
207
+
208
+ private async flush() {
209
+ this.publishing = true;
210
+ while (this.unsent.size) {
211
+ const [key, body] = this.unsent.entries().next().value!;
212
+ this.unsent.delete(key);
213
+ try {
214
+ await this.store.publish!(SIGNAL_CHANNEL, JSON.stringify({ origin: this.origin, ...body }));
215
+ } catch {
216
+ // best effort: the other servers miss this one, and the next beat corrects presence
217
+ }
218
+ }
219
+ this.publishing = false;
220
+ }
221
+ }