@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 +30 -0
- package/dist/routes.d.ts +8 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +297 -92
- package/dist/routes.js.map +1 -1
- package/dist/runtime.d.ts +4 -2
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +5 -3
- package/dist/runtime.js.map +1 -1
- package/dist/signals.d.ts +4 -3
- package/dist/signals.d.ts.map +1 -1
- package/dist/signals.js +5 -4
- package/dist/signals.js.map +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +24 -1
- package/dist/store.js.map +1 -1
- package/package.json +2 -2
- package/src/routes.ts +314 -91
- package/src/runtime.ts +5 -3
- package/src/signals.ts +5 -4
- package/src/store.ts +24 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
+
await respond(hai, route, req, res);
|
|
49
47
|
} catch (err) {
|
|
50
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
+
}
|
|
71
|
+
|
|
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
|
+
}
|
|
65
82
|
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
return true;
|
|
106
|
-
};
|
|
154
|
+
res.end();
|
|
107
155
|
}
|
|
108
156
|
|
|
109
157
|
/**
|
|
@@ -111,6 +159,27 @@ export function nodeHandler(hai: Hai, basePath = "/hai") {
|
|
|
111
159
|
* this is what still finds a notice whose wake-up was lost.
|
|
112
160
|
*/
|
|
113
161
|
const POLL_MS = 2_000;
|
|
162
|
+
/**
|
|
163
|
+
* A short fingerprint of signal values, by name: what a polling browser says it
|
|
164
|
+
* has, so a poll knows whether the latest values are news to it. The client
|
|
165
|
+
* computes the same (packages/client/src/index.js); change both together.
|
|
166
|
+
*/
|
|
167
|
+
export function signalsDigest(entries: [string, unknown][]): string {
|
|
168
|
+
const text = JSON.stringify([...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
169
|
+
let hash = 0x811c9dc5;
|
|
170
|
+
for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193);
|
|
171
|
+
return (hash >>> 0).toString(16);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** How long a process keeps hearing a conversation's turns after its last stream closes. */
|
|
175
|
+
const WATCH_LINGER_MS = 60_000;
|
|
176
|
+
/** How long a wake turn that has ended is kept, for a poll that missed its end, and how many at most. */
|
|
177
|
+
const ENDED_TURN_KEPT_MS = 60_000;
|
|
178
|
+
const ENDED_TURNS_KEPT = 5;
|
|
179
|
+
/** The longest a poll holds when there is nothing new to send. */
|
|
180
|
+
const POLL_HOLD_MS = 15_000;
|
|
181
|
+
/** How long a poll waits after something new, for what comes with it, before it ends. */
|
|
182
|
+
const POLL_GATHER_MS = 250;
|
|
114
183
|
/** Proxies drop a stream that says nothing for long; a comment keeps it open. */
|
|
115
184
|
const HEARTBEAT_MS = 25_000;
|
|
116
185
|
|
|
@@ -132,6 +201,18 @@ const PAGE = 100;
|
|
|
132
201
|
async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse, url: URL) {
|
|
133
202
|
const { store } = hai.config;
|
|
134
203
|
const conversationId = url.searchParams.get("conversationId") ?? "";
|
|
204
|
+
// A poll, for a browser behind a proxy that holds a stream back until it
|
|
205
|
+
// ends: what's waiting, then the response ends, which any proxy delivers.
|
|
206
|
+
// `poll=now` answers at once; `poll=1` holds until something new arrives,
|
|
207
|
+
// or for `wait` ms (at most POLL_HOLD_MS) if nothing does.
|
|
208
|
+
const poll = url.searchParams.get("poll");
|
|
209
|
+
const polling = poll === "1" || poll === "now";
|
|
210
|
+
// a fingerprint of the signal values the browser already has (signalsDigest)
|
|
211
|
+
const hasSignals = url.searchParams.get("signals");
|
|
212
|
+
// the last wake turn the browser saw end, by its number
|
|
213
|
+
const sawTurn = Number(url.searchParams.get("turn") ?? 0);
|
|
214
|
+
const asked = Number(url.searchParams.get("wait") ?? POLL_HOLD_MS);
|
|
215
|
+
const holdMs = Number.isFinite(asked) ? Math.min(Math.max(asked, 0), POLL_HOLD_MS) : POLL_HOLD_MS;
|
|
135
216
|
const resume = Number(req.headers["last-event-id"] ?? url.searchParams.get("after") ?? 0);
|
|
136
217
|
let after = Number.isSafeInteger(resume) && resume > 0 ? resume : 0;
|
|
137
218
|
|
|
@@ -147,6 +228,7 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
|
|
|
147
228
|
// Counted as watching from here, so nothing lets the entry go while this
|
|
148
229
|
// stream is still being set up.
|
|
149
230
|
const watchers = watchersOf(hai, conversationId);
|
|
231
|
+
watchers.stay();
|
|
150
232
|
watchers.pending++;
|
|
151
233
|
try {
|
|
152
234
|
await watchers.ready;
|
|
@@ -166,8 +248,11 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
|
|
|
166
248
|
connection: "keep-alive",
|
|
167
249
|
});
|
|
168
250
|
// Sent now, not with the first frame: with nothing to catch up on, that is a
|
|
169
|
-
// heartbeat away, and the browser's request would hang open until then.
|
|
251
|
+
// heartbeat away, and the browser's request would hang open until then. A
|
|
252
|
+
// comment with them, so the browser hears its first bytes at once: one that
|
|
253
|
+
// hears nothing knows something on the way is holding the stream, and polls.
|
|
170
254
|
res.flushHeaders();
|
|
255
|
+
res.write(": open\n\n");
|
|
171
256
|
const aborter = new AbortController();
|
|
172
257
|
const { signal } = aborter;
|
|
173
258
|
res.on("close", () => aborter.abort());
|
|
@@ -186,11 +271,20 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
|
|
|
186
271
|
});
|
|
187
272
|
};
|
|
188
273
|
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
|
|
274
|
+
// Something new for this browser: a poll ends a moment after it, to gather
|
|
275
|
+
// what comes with it.
|
|
276
|
+
let markFresh = () => {};
|
|
277
|
+
const freshened = new Promise<void>((resolve) => (markFresh = resolve));
|
|
278
|
+
|
|
279
|
+
// A frame out, and a stream the browser has left takes no more.
|
|
280
|
+
const send = (event: WireEvent) => {
|
|
192
281
|
if (!signal.aborted && !res.writableEnded) res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
193
282
|
};
|
|
283
|
+
// A wake turn's events go out as they come, one frame each.
|
|
284
|
+
const emit: Emit = (event) => {
|
|
285
|
+
send(event);
|
|
286
|
+
markFresh();
|
|
287
|
+
};
|
|
194
288
|
// Every stream this process has open for the conversation hears a wake
|
|
195
289
|
// turn, not only the one whose attempt won the lease: another tab shows the
|
|
196
290
|
// reply too, and holds its requests until the turn lets go.
|
|
@@ -220,22 +314,58 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
|
|
|
220
314
|
}
|
|
221
315
|
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
222
316
|
};
|
|
223
|
-
|
|
317
|
+
// A new signal is news. Each one's latest, sent as the stream opens, is
|
|
318
|
+
// news to a poll only if the browser doesn't have those values yet, such as
|
|
319
|
+
// one sent between two polls: every poll gets them, and a poll that always
|
|
320
|
+
// ended on them would never wait.
|
|
321
|
+
const replayed: [string, unknown][] = [];
|
|
322
|
+
const closeStream = hai.streamOpened(
|
|
323
|
+
conversationId,
|
|
324
|
+
(event) => {
|
|
325
|
+
emitSignal(event);
|
|
326
|
+
markFresh();
|
|
327
|
+
},
|
|
328
|
+
(event) => {
|
|
329
|
+
emitSignal(event);
|
|
330
|
+
if (!polling || event.type !== "signal") return;
|
|
331
|
+
// the latest are sent one after another, at once: compare once all are in
|
|
332
|
+
if (!replayed.length) queueMicrotask(() => signalsDigest(replayed) !== hasSignals && markFresh());
|
|
333
|
+
replayed.push([event.name, event.payload]);
|
|
334
|
+
},
|
|
335
|
+
);
|
|
224
336
|
// Joining while a wake turn runs: what it has shown so far, starting with
|
|
225
337
|
// its `streaming` status, so the browser holds requests until it lets go,
|
|
226
338
|
// and the reply's later text has a block to land in.
|
|
227
339
|
// A surface it shows is sent with its age, so the browser counts its
|
|
228
340
|
// freshness from when the surface was stored, not from now.
|
|
229
|
-
if (watchers.live) for (const event of watchers.shown)
|
|
341
|
+
if (watchers.live) for (const event of watchers.shown) send(aged(event));
|
|
342
|
+
else if (polling) {
|
|
343
|
+
// A turn that ended between two polls, unseen: all it showed, its end
|
|
344
|
+
// included, which is news. Then that no turn is running, which a browser
|
|
345
|
+
// that saw nothing of one needs too, or it would wait for it for ever.
|
|
346
|
+
// every one it missed, oldest first, if more than one ended meanwhile
|
|
347
|
+
const missed = Number.isSafeInteger(sawTurn) ? watchers.ended.filter((turn) => turn.number > sawTurn) : [];
|
|
348
|
+
for (const turn of missed) for (const event of turn.shown) send(aged(event));
|
|
349
|
+
if (missed.length) markFresh();
|
|
350
|
+
send({ type: "released" });
|
|
351
|
+
}
|
|
230
352
|
|
|
231
353
|
// Wake turns run beside the notice loop, so a notice never waits behind a
|
|
232
354
|
// turn, nor behind the retries while another request holds the
|
|
233
355
|
// conversation. One attempt at a time; a wake notice that lands meanwhile
|
|
234
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.
|
|
235
364
|
let waking: Promise<void> | null = null;
|
|
236
365
|
let again = false;
|
|
237
366
|
const wake = () => {
|
|
238
367
|
if (!hai.wakes || signal.aborted) return;
|
|
368
|
+
if (watchers.wakeRead <= watchers.wakeSettled) return;
|
|
239
369
|
if (waking) {
|
|
240
370
|
again = true;
|
|
241
371
|
return;
|
|
@@ -243,12 +373,22 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
|
|
|
243
373
|
waking = (async () => {
|
|
244
374
|
do {
|
|
245
375
|
again = false;
|
|
376
|
+
// every wake notice read here so far: what the attempt settles, unless it fails
|
|
377
|
+
const through = watchers.wakeRead;
|
|
246
378
|
let wait = WAKE_RETRY_MS;
|
|
247
|
-
|
|
379
|
+
let outcome: WakeOutcome = "busy";
|
|
380
|
+
while (!signal.aborted && (outcome = await hai.wake(conversationId, watchers.turn())) === "busy") {
|
|
248
381
|
await pause(wait, signal);
|
|
249
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;
|
|
250
386
|
}
|
|
251
|
-
|
|
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);
|
|
252
392
|
})()
|
|
253
393
|
.catch((err) => emit({ type: "error", message: (err as Error).message }))
|
|
254
394
|
.finally(() => {
|
|
@@ -299,7 +439,11 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
|
|
|
299
439
|
if (n.replaces !== undefined && n.handle !== undefined) {
|
|
300
440
|
await sendRevision(n.handle, n.replaces, n.seq);
|
|
301
441
|
after = n.seq;
|
|
302
|
-
|
|
442
|
+
markFresh();
|
|
443
|
+
if (n.kind === "wake") {
|
|
444
|
+
woken = true;
|
|
445
|
+
watchers.wakeRead = Math.max(watchers.wakeRead, n.seq);
|
|
446
|
+
}
|
|
303
447
|
continue;
|
|
304
448
|
}
|
|
305
449
|
const event: WireEvent = {
|
|
@@ -312,7 +456,11 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
|
|
|
312
456
|
};
|
|
313
457
|
await write(`id: ${n.seq}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
314
458
|
after = n.seq;
|
|
315
|
-
|
|
459
|
+
markFresh();
|
|
460
|
+
if (n.kind === "wake") {
|
|
461
|
+
woken = true;
|
|
462
|
+
watchers.wakeRead = Math.max(watchers.wakeRead, n.seq);
|
|
463
|
+
}
|
|
316
464
|
}
|
|
317
465
|
if (records.length < PAGE || signal.aborted) break;
|
|
318
466
|
page = null;
|
|
@@ -325,14 +473,12 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
|
|
|
325
473
|
// An app that sends no notices has nothing stored to read: the stream is only
|
|
326
474
|
// for signals, and stays open, reading nothing, until the browser leaves.
|
|
327
475
|
const wakes = hai.sendsNotices ? wakeups(store.watch?.(conversationId, signal), signal)[Symbol.asyncIterator]() : null;
|
|
328
|
-
|
|
329
|
-
if (
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
476
|
+
const left = new Promise<void>((resolve) => {
|
|
477
|
+
if (signal.aborted) resolve();
|
|
478
|
+
else signal.addEventListener("abort", () => resolve(), { once: true });
|
|
479
|
+
});
|
|
480
|
+
const run = async () => {
|
|
481
|
+
if (!wakes) return left;
|
|
336
482
|
await catchUp(first);
|
|
337
483
|
// A wake notice may be waiting from before this browser connected: one
|
|
338
484
|
// that arrived while nobody watched, or while the conversation was busy.
|
|
@@ -346,6 +492,22 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
|
|
|
346
492
|
next = wakes.next();
|
|
347
493
|
await catchUp();
|
|
348
494
|
}
|
|
495
|
+
};
|
|
496
|
+
try {
|
|
497
|
+
if (!polling) return await run();
|
|
498
|
+
// A poll: the same reading, until something new has gone out and what
|
|
499
|
+
// came with it has had a moment to follow, or the hold runs out.
|
|
500
|
+
if (poll === "now") markFresh();
|
|
501
|
+
let hold: ReturnType<typeof setTimeout> | undefined;
|
|
502
|
+
const done = Promise.race([
|
|
503
|
+
left,
|
|
504
|
+
freshened.then(() => new Promise<void>((resolve) => setTimeout(resolve, POLL_GATHER_MS))),
|
|
505
|
+
new Promise<void>((resolve) => (hold = setTimeout(resolve, holdMs))),
|
|
506
|
+
]);
|
|
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));
|
|
509
|
+
await done;
|
|
510
|
+
clearTimeout(hold);
|
|
349
511
|
} finally {
|
|
350
512
|
clearInterval(heartbeat);
|
|
351
513
|
aborter.abort();
|
|
@@ -371,6 +533,13 @@ interface Watchers {
|
|
|
371
533
|
* then everything else in order. A turn's worth, dropped when it ends.
|
|
372
534
|
*/
|
|
373
535
|
shown: WireEvent[];
|
|
536
|
+
/**
|
|
537
|
+
* The wake turns that ended in the past minute, the last few, oldest first:
|
|
538
|
+
* each one's number and everything it showed, `released` included. A poll
|
|
539
|
+
* is sent each one its browser hasn't seen end, since they may have ended
|
|
540
|
+
* between two polls.
|
|
541
|
+
*/
|
|
542
|
+
ended: { number: number; shown: WireEvent[] }[];
|
|
374
543
|
/**
|
|
375
544
|
* An emitter for one wake attempt in this process: its frames go to every
|
|
376
545
|
* stream here and to the other processes, marked as one turn.
|
|
@@ -380,8 +549,31 @@ interface Watchers {
|
|
|
380
549
|
pending: number;
|
|
381
550
|
/** Let this entry go, and stop hearing other processes for it. */
|
|
382
551
|
forget: () => void;
|
|
552
|
+
/**
|
|
553
|
+
* A stream for the conversation opened: stop lingering. And its last stream
|
|
554
|
+
* closed: keep hearing other processes a while, then let go. A browser that
|
|
555
|
+
* polls comes back in moments, perhaps to another server, and a wake turn
|
|
556
|
+
* that ends meanwhile, wherever it ran, has to be here to replay.
|
|
557
|
+
*/
|
|
558
|
+
stay: () => void;
|
|
559
|
+
leave: () => void;
|
|
383
560
|
/** Settles once this process is listening for other processes' turns. */
|
|
384
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;
|
|
385
577
|
}
|
|
386
578
|
|
|
387
579
|
/** Fold a wake turn's frame into what it has shown so far. */
|
|
@@ -462,8 +654,9 @@ function watchersOf(hai: Hai, conversationId: string): Watchers {
|
|
|
462
654
|
const origin = originOf(hai);
|
|
463
655
|
// Hearing other processes' wake turns lasts as long as this entry does.
|
|
464
656
|
const hearing = new AbortController();
|
|
657
|
+
let lingering: ReturnType<typeof setTimeout> | null = null;
|
|
465
658
|
const forget = () => {
|
|
466
|
-
if (map.get(conversationId) !== fresh || fresh.pending || fresh.streams.size || fresh.live) return;
|
|
659
|
+
if (map.get(conversationId) !== fresh || fresh.pending || fresh.streams.size || fresh.live || fresh.ended.length || lingering) return;
|
|
467
660
|
map.delete(conversationId);
|
|
468
661
|
hearing.abort();
|
|
469
662
|
};
|
|
@@ -478,12 +671,14 @@ function watchersOf(hai: Hai, conversationId: string): Watchers {
|
|
|
478
671
|
// start. A turn takes over only with a number at least as high as any
|
|
479
672
|
// seen; anything but the current turn's frames is dropped.
|
|
480
673
|
let current: string | null = null;
|
|
674
|
+
let currentNumber: number | undefined;
|
|
481
675
|
let newest = 0;
|
|
482
676
|
const deliver = (event: WireEvent, turn: string, number: number | undefined) => {
|
|
483
677
|
if (event.type === "status" && event.status === "streaming") {
|
|
484
678
|
if (current !== turn) {
|
|
485
679
|
if (number !== undefined && number < newest) return; // an older turn's late start
|
|
486
680
|
current = turn;
|
|
681
|
+
currentNumber = number;
|
|
487
682
|
if (number !== undefined) newest = number;
|
|
488
683
|
fresh.live = true;
|
|
489
684
|
fresh.shown = [];
|
|
@@ -493,6 +688,15 @@ function watchersOf(hai: Hai, conversationId: string): Watchers {
|
|
|
493
688
|
}
|
|
494
689
|
remember(fresh.shown, event);
|
|
495
690
|
if (event.type === "released") {
|
|
691
|
+
// kept a while for a poll that missed the end, then let go
|
|
692
|
+
if (currentNumber !== undefined) {
|
|
693
|
+
const ended = { number: currentNumber, shown: fresh.shown };
|
|
694
|
+
fresh.ended = [...fresh.ended, ended].slice(-ENDED_TURNS_KEPT);
|
|
695
|
+
setTimeout(() => {
|
|
696
|
+
fresh.ended = fresh.ended.filter((turn) => turn !== ended);
|
|
697
|
+
forget();
|
|
698
|
+
}, ENDED_TURN_KEPT_MS).unref?.();
|
|
699
|
+
}
|
|
496
700
|
current = null;
|
|
497
701
|
fresh.live = false;
|
|
498
702
|
fresh.shown = [];
|
|
@@ -506,8 +710,23 @@ function watchersOf(hai: Hai, conversationId: string): Watchers {
|
|
|
506
710
|
streams: new Set(),
|
|
507
711
|
live: false,
|
|
508
712
|
shown: [],
|
|
713
|
+
ended: [],
|
|
509
714
|
forget,
|
|
715
|
+
stay: () => {
|
|
716
|
+
if (lingering) clearTimeout(lingering);
|
|
717
|
+
lingering = null;
|
|
718
|
+
},
|
|
719
|
+
leave: () => {
|
|
720
|
+
if (lingering) clearTimeout(lingering);
|
|
721
|
+
lingering = setTimeout(() => {
|
|
722
|
+
lingering = null;
|
|
723
|
+
forget();
|
|
724
|
+
}, WATCH_LINGER_MS);
|
|
725
|
+
lingering.unref?.();
|
|
726
|
+
},
|
|
510
727
|
ready: Promise.resolve(),
|
|
728
|
+
wakeRead: 0,
|
|
729
|
+
wakeSettled: -1,
|
|
511
730
|
// A wake turn running here: its frames to this process's streams, and,
|
|
512
731
|
// through the store, to every other process's. All but `context`, the
|
|
513
732
|
// whole history, which another tab gets with its own next request.
|
|
@@ -554,8 +773,9 @@ function watchersOf(hai: Hai, conversationId: string): Watchers {
|
|
|
554
773
|
|
|
555
774
|
function forgetWatchers(_hai: Hai, _conversationId: string, watchers: Watchers) {
|
|
556
775
|
// Kept while a wake turn runs, so a stream that joins later still hears it,
|
|
557
|
-
//
|
|
558
|
-
|
|
776
|
+
// while any stream is open or being set up, while an ended turn is kept, and
|
|
777
|
+
// for a while after the last stream closes. `forget` checks them all.
|
|
778
|
+
watchers.leave();
|
|
559
779
|
}
|
|
560
780
|
|
|
561
781
|
/** The first wait before trying a wake turn again on a busy conversation, and the longest. */
|
|
@@ -622,8 +842,11 @@ function openSSE(res: ServerResponse): Emit {
|
|
|
622
842
|
};
|
|
623
843
|
}
|
|
624
844
|
|
|
625
|
-
|
|
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>> {
|
|
626
847
|
const chunks: Buffer[] = [];
|
|
627
848
|
for await (const chunk of req) chunks.push(chunk as Buffer);
|
|
628
|
-
|
|
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>;
|
|
629
852
|
}
|
package/src/runtime.ts
CHANGED
|
@@ -371,10 +371,12 @@ export class Hai {
|
|
|
371
371
|
* An events stream opened for `conversationId`: it hears every signal from
|
|
372
372
|
* now on, starting with each one's latest payload, and counts toward
|
|
373
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.
|
|
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.
|
|
375
377
|
*/
|
|
376
|
-
streamOpened(conversationId: string, emit: Emit): () => void {
|
|
377
|
-
return this.broadcast.opened(conversationId, emit);
|
|
378
|
+
streamOpened(conversationId: string, emit: Emit, replay?: Emit): () => void {
|
|
379
|
+
return this.broadcast.opened(conversationId, emit, replay);
|
|
378
380
|
}
|
|
379
381
|
|
|
380
382
|
/**
|
package/src/signals.ts
CHANGED
|
@@ -53,10 +53,11 @@ export class Broadcast {
|
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
55
|
* An events stream opened for `conversationId`: it hears signals from now
|
|
56
|
-
* on, starting with each one's latest payload,
|
|
57
|
-
* as present. Call what this returns
|
|
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.
|
|
58
59
|
*/
|
|
59
|
-
opened(conversationId: string, emit: Emit): () => void {
|
|
60
|
+
opened(conversationId: string, emit: Emit, replay: Emit = emit): () => void {
|
|
60
61
|
this.start();
|
|
61
62
|
this.streams.add(emit);
|
|
62
63
|
let closed = false;
|
|
@@ -64,7 +65,7 @@ export class Broadcast {
|
|
|
64
65
|
// sent is among them. A live one may come first; the latest is still the
|
|
65
66
|
// latest, so at worst one arrives twice.
|
|
66
67
|
void this.hearing.then(() => {
|
|
67
|
-
if (!closed) for (const event of this.latest.values())
|
|
68
|
+
if (!closed) for (const event of this.latest.values()) replay(event);
|
|
68
69
|
});
|
|
69
70
|
|
|
70
71
|
const wasLeaving = this.leaving.get(conversationId);
|