@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/README.md +78 -0
- package/dist/routes.d.ts +6 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +174 -13
- package/dist/routes.js.map +1 -1
- package/dist/runtime.d.ts +67 -3
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +83 -2
- package/dist/runtime.js.map +1 -1
- package/dist/signals.d.ts +77 -0
- package/dist/signals.d.ts.map +1 -0
- package/dist/signals.js +229 -0
- package/dist/signals.js.map +1 -0
- package/package.json +2 -2
- package/src/routes.ts +179 -13
- package/src/runtime.ts +104 -2
- package/src/signals.ts +221 -0
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
|
|
222
|
-
*
|
|
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
|
+
}
|