@haikit/server 0.17.0 → 0.19.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 +108 -0
- package/dist/routes.js +89 -5
- package/dist/routes.js.map +1 -1
- package/dist/runtime.d.ts +118 -3
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +386 -15
- package/dist/runtime.js.map +1 -1
- package/dist/signals.d.ts +76 -0
- package/dist/signals.d.ts.map +1 -0
- package/dist/signals.js +228 -0
- package/dist/signals.js.map +1 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +1 -0
- package/dist/store.js.map +1 -1
- package/package.json +2 -2
- package/src/routes.ts +82 -5
- package/src/runtime.ts +437 -14
- package/src/signals.ts +220 -0
- package/src/store.ts +1 -0
package/src/signals.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
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, starting with each one's latest payload, and the conversation counts
|
|
57
|
+
* as present. Call what this returns when the stream closes.
|
|
58
|
+
*/
|
|
59
|
+
opened(conversationId: string, emit: Emit): () => void {
|
|
60
|
+
this.start();
|
|
61
|
+
this.streams.add(emit);
|
|
62
|
+
let closed = false;
|
|
63
|
+
// Each signal's latest, once this server hears the others, so one they
|
|
64
|
+
// sent is among them. A live one may come first; the latest is still the
|
|
65
|
+
// latest, so at worst one arrives twice.
|
|
66
|
+
void this.hearing.then(() => {
|
|
67
|
+
if (!closed) for (const event of this.latest.values()) emit(event);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const wasLeaving = this.leaving.get(conversationId);
|
|
71
|
+
if (wasLeaving !== undefined) {
|
|
72
|
+
// back within the grace: it never stopped counting
|
|
73
|
+
clearTimeout(wasLeaving);
|
|
74
|
+
this.leaving.delete(conversationId);
|
|
75
|
+
}
|
|
76
|
+
const before = this.open.get(conversationId) ?? 0;
|
|
77
|
+
this.open.set(conversationId, before + 1);
|
|
78
|
+
if (before === 0 && wasLeaving === undefined) this.changed();
|
|
79
|
+
|
|
80
|
+
return () => {
|
|
81
|
+
if (closed) return;
|
|
82
|
+
closed = true;
|
|
83
|
+
this.streams.delete(emit);
|
|
84
|
+
const left = (this.open.get(conversationId) ?? 1) - 1;
|
|
85
|
+
if (left > 0) {
|
|
86
|
+
this.open.set(conversationId, left);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
this.open.delete(conversationId);
|
|
90
|
+
// A browser that drops its stream reconnects within a second or two:
|
|
91
|
+
// keep counting it a little longer, so the number doesn't flicker.
|
|
92
|
+
const timer = setTimeout(() => {
|
|
93
|
+
this.leaving.delete(conversationId);
|
|
94
|
+
this.changed();
|
|
95
|
+
}, this.graceMs);
|
|
96
|
+
timer.unref?.();
|
|
97
|
+
this.leaving.set(conversationId, timer);
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Hear the total now and whenever it changes. Returns a function that stops. */
|
|
102
|
+
onPresence(listener: (count: number) => void): () => void {
|
|
103
|
+
this.start();
|
|
104
|
+
this.listeners.add(listener);
|
|
105
|
+
try {
|
|
106
|
+
listener(this.total);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
// never handed its way to stop, so it must not stay
|
|
109
|
+
this.listeners.delete(listener);
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
return () => void this.listeners.delete(listener);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Conversations with a stream open in this process, or within the grace. */
|
|
116
|
+
private get local() {
|
|
117
|
+
return this.open.size + this.leaving.size;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Start hearing the other servers, and telling them this one's count, once.
|
|
122
|
+
* A Hai that sends signals starts as it is created, so it has the others'
|
|
123
|
+
* latest values before its first stream opens; any other, when one does.
|
|
124
|
+
*/
|
|
125
|
+
start() {
|
|
126
|
+
if (this.started) return;
|
|
127
|
+
this.started = true;
|
|
128
|
+
// For as long as this Hai lives. A store that can't listen still resolves,
|
|
129
|
+
// and hears nothing: signals and presence then stay on this server.
|
|
130
|
+
const subscribed = this.store.subscribe?.(SIGNAL_CHANNEL, (message) => this.hear(message), new AbortController().signal);
|
|
131
|
+
if (subscribed) this.hearing = Promise.resolve(subscribed).catch(() => {});
|
|
132
|
+
const beat = setInterval(() => this.beat(), this.heartbeatMs);
|
|
133
|
+
beat.unref?.();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private hear(message: string) {
|
|
137
|
+
let heard: { origin?: string; kind?: string; event?: SignalEvent; count?: number };
|
|
138
|
+
try {
|
|
139
|
+
heard = JSON.parse(message);
|
|
140
|
+
} catch {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (!heard || heard.origin === this.origin || typeof heard.origin !== "string") return;
|
|
144
|
+
if (heard.kind === "signal" && heard.event?.type === "signal" && typeof heard.event.name === "string") {
|
|
145
|
+
this.latest.set(heard.event.name, heard.event);
|
|
146
|
+
for (const send of this.streams) send(heard.event);
|
|
147
|
+
} else if (heard.kind === "presence" && Number.isSafeInteger(heard.count) && heard.count! >= 0) {
|
|
148
|
+
// a count is a number of conversations: a whole number, never Infinity
|
|
149
|
+
const known = this.peers.has(heard.origin);
|
|
150
|
+
const count = heard.count!;
|
|
151
|
+
if (count > 0) this.peers.set(heard.origin, { count, heard: Date.now() });
|
|
152
|
+
else this.peers.delete(heard.origin);
|
|
153
|
+
// a server it hadn't heard from yet hasn't heard from it either
|
|
154
|
+
if (!known && count > 0 && this.local > 0) this.publish({ kind: "presence", count: this.local });
|
|
155
|
+
this.recount();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** This server's count changed: tell the listeners, and the other servers. */
|
|
160
|
+
private changed() {
|
|
161
|
+
this.recount();
|
|
162
|
+
if (this.local !== this.told) {
|
|
163
|
+
this.told = this.local;
|
|
164
|
+
this.publish({ kind: "presence", count: this.local });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** On a timer: drop servers gone quiet, and remind the others this one is here. */
|
|
169
|
+
private beat() {
|
|
170
|
+
this.recount();
|
|
171
|
+
if (this.local > 0) this.publish({ kind: "presence", count: this.local });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private recount() {
|
|
175
|
+
// three missed beats and a server's count no longer counts: it has gone
|
|
176
|
+
const stale = Date.now() - 3 * this.heartbeatMs;
|
|
177
|
+
let others = 0;
|
|
178
|
+
for (const [origin, peer] of this.peers) {
|
|
179
|
+
if (peer.heard < stale) this.peers.delete(origin);
|
|
180
|
+
else others += peer.count;
|
|
181
|
+
}
|
|
182
|
+
// a sum of whole counts, kept a safe integer however large the peers claim to be
|
|
183
|
+
const total = Math.min(this.local + others, Number.MAX_SAFE_INTEGER);
|
|
184
|
+
if (total === this.total) return;
|
|
185
|
+
this.total = total;
|
|
186
|
+
for (const listener of [...this.listeners]) {
|
|
187
|
+
try {
|
|
188
|
+
listener(total);
|
|
189
|
+
} catch {
|
|
190
|
+
// one listener's mistake doesn't stop the others hearing
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Tell the other servers. One publish is in flight at a time, and only the
|
|
197
|
+
* latest of each signal, and of this server's count, waits behind it: a
|
|
198
|
+
* burst of signals can't queue every obsolete payload in the store.
|
|
199
|
+
*/
|
|
200
|
+
private publish(body: { kind: "signal"; event: SignalEvent } | { kind: "presence"; count: number }) {
|
|
201
|
+
if (!this.store.publish) return;
|
|
202
|
+
this.unsent.delete(body.kind === "signal" ? `signal:${body.event.name}` : "presence");
|
|
203
|
+
this.unsent.set(body.kind === "signal" ? `signal:${body.event.name}` : "presence", body);
|
|
204
|
+
if (!this.publishing) void this.flush();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private async flush() {
|
|
208
|
+
this.publishing = true;
|
|
209
|
+
while (this.unsent.size) {
|
|
210
|
+
const [key, body] = this.unsent.entries().next().value!;
|
|
211
|
+
this.unsent.delete(key);
|
|
212
|
+
try {
|
|
213
|
+
await this.store.publish!(SIGNAL_CHANNEL, JSON.stringify({ origin: this.origin, ...body }));
|
|
214
|
+
} catch {
|
|
215
|
+
// best effort: the other servers miss this one, and the next beat corrects presence
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
this.publishing = false;
|
|
219
|
+
}
|
|
220
|
+
}
|
package/src/store.ts
CHANGED