@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/README.md
CHANGED
|
@@ -205,6 +205,36 @@ the inspector, the same as a digest. It reads the store every two
|
|
|
205
205
|
seconds, and a store with `watch` wakes it the moment a notice lands. The
|
|
206
206
|
two-second read stays even then, so a lost wake-up only delays a notice.
|
|
207
207
|
|
|
208
|
+
**For browsers behind a proxy that holds streams back, the route also
|
|
209
|
+
polls.** Corporate security tools that inspect HTTPS (Netskope, Zscaler and
|
|
210
|
+
the like) often hold a response until it ends, and a stream never ends, so a
|
|
211
|
+
browser behind one hears nothing. The route sends a comment (`: open`) the
|
|
212
|
+
moment a stream opens; a client that hears nothing at all switches to
|
|
213
|
+
polling, with `?poll=1`. A poll sends what's waiting and ends, and responses
|
|
214
|
+
that end get through any proxy:
|
|
215
|
+
|
|
216
|
+
- **What a poll holds for.** It sends any notices after `Last-Event-ID`, each
|
|
217
|
+
signal's latest value, and any frames of a wake turn under way. It ends
|
|
218
|
+
about 250 ms after something new goes out, or after 15 s with nothing new.
|
|
219
|
+
`wait` asks for a shorter hold.
|
|
220
|
+
- **Signals.** The latest values count as new only when the client lacks
|
|
221
|
+
them. The poll's `signals` parameter is a fingerprint of the values the
|
|
222
|
+
client holds.
|
|
223
|
+
- **First poll and wake turns.** `?poll=now` answers at once, for a page's
|
|
224
|
+
first poll. A wake turn that runs across several polls carries on through
|
|
225
|
+
the replay of what it has shown so far. One that ends between two polls is
|
|
226
|
+
kept for a minute (the last five at most): a poll whose `turn` parameter
|
|
227
|
+
says it hasn't seen a turn end is sent all of each such turn, oldest first,
|
|
228
|
+
its last text, `idle` and `released` included. A poll that finds no turn running says so with `released`, so
|
|
229
|
+
the browser never waits for one that has ended.
|
|
230
|
+
- **More than one server.** Each server keeps hearing a conversation for a
|
|
231
|
+
minute after its last stream or poll closes. A browser whose polls move
|
|
232
|
+
between servers still gets a turn that ended on another one. A poll that
|
|
233
|
+
lands on a server that hasn't served that conversation in the past minute
|
|
234
|
+
can miss it; sticky sessions avoid that.
|
|
235
|
+
- **Presence.** A tab polling back to back stays inside the presence grace,
|
|
236
|
+
so it keeps counting.
|
|
237
|
+
|
|
208
238
|
**A `wake` notice starts a turn of its own.** Declare one with `kind: "wake"`,
|
|
209
239
|
and its `model` function must return text, since the turn is the model's
|
|
210
240
|
answer to it:
|
package/dist/routes.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
2
2
|
import type { Hai } from "./runtime.js";
|
|
3
3
|
/**
|
|
4
4
|
* Node http handler for the three routes, and a fourth when the app sends
|
|
5
|
-
* notices. Returns true if it handled the request.
|
|
5
|
+
* notices. Returns true if it handled the request. It never rejects: a request
|
|
6
|
+
* that fails is answered, ended and logged (see `failed`).
|
|
6
7
|
*
|
|
7
8
|
* The three POST routes stream SSE: chat and interact because both can resume
|
|
8
9
|
* the agent loop, start because the init tool it runs can render surfaces.
|
|
@@ -14,4 +15,10 @@ import type { Hai } from "./runtime.js";
|
|
|
14
15
|
* conversation, and where to resume.
|
|
15
16
|
*/
|
|
16
17
|
export declare function nodeHandler(hai: Hai, basePath?: string): (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
|
|
18
|
+
/**
|
|
19
|
+
* A short fingerprint of signal values, by name: what a polling browser says it
|
|
20
|
+
* has, so a poll knows whether the latest values are news to it. The client
|
|
21
|
+
* computes the same (packages/client/src/index.js); change both together.
|
|
22
|
+
*/
|
|
23
|
+
export declare function signalsDigest(entries: [string, unknown][]): string;
|
|
17
24
|
//# sourceMappingURL=routes.d.ts.map
|
package/dist/routes.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAGjE,OAAO,KAAK,EAAE,GAAG,
|
|
1
|
+
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAGjE,OAAO,KAAK,EAAE,GAAG,EAAe,MAAM,cAAc,CAAC;AAErD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,GAAG,EAAE,QAAQ,SAAS,IACxB,KAAK,eAAe,EAAE,KAAK,cAAc,KAAG,OAAO,CAAC,OAAO,CAAC,CA+B1F;AA8GD;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,MAAM,CAKlE"}
|
package/dist/routes.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { isConversationBusy, isStaleLease } from "@haikit/core";
|
|
2
2
|
/**
|
|
3
3
|
* Node http handler for the three routes, and a fourth when the app sends
|
|
4
|
-
* notices. Returns true if it handled the request.
|
|
4
|
+
* notices. Returns true if it handled the request. It never rejects: a request
|
|
5
|
+
* that fails is answered, ended and logged (see `failed`).
|
|
5
6
|
*
|
|
6
7
|
* The three POST routes stream SSE: chat and interact because both can resume
|
|
7
8
|
* the agent loop, start because the init tool it runs can render surfaces.
|
|
@@ -14,99 +15,170 @@ import { isConversationBusy, isStaleLease } from "@haikit/core";
|
|
|
14
15
|
*/
|
|
15
16
|
export function nodeHandler(hai, basePath = "/hai") {
|
|
16
17
|
return async function handle(req, res) {
|
|
17
|
-
|
|
18
|
+
let url;
|
|
19
|
+
try {
|
|
20
|
+
url = new URL(req.url ?? "/", "http://localhost");
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Node accepts targets a URL can't parse, such as `//[`. None is under
|
|
24
|
+
// a base path, which a URL always parses, so it's not ours: the app's.
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
18
27
|
if (!url.pathname.startsWith(basePath))
|
|
19
28
|
return false;
|
|
20
29
|
const route = url.pathname.slice(basePath.length);
|
|
21
30
|
if (req.method === "GET" && route === "/events" && hai.streamsEvents) {
|
|
22
|
-
|
|
31
|
+
try {
|
|
32
|
+
await streamNotices(hai, req, res, url);
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
failed(res, err);
|
|
36
|
+
}
|
|
23
37
|
return true;
|
|
24
38
|
}
|
|
25
39
|
if (req.method !== "POST")
|
|
26
40
|
return false;
|
|
27
41
|
if (route !== "/chat" && route !== "/interact" && route !== "/start")
|
|
28
42
|
return false;
|
|
29
|
-
const body = await readJson(req);
|
|
30
|
-
// Nothing to run before the first message: answer with an empty stream and
|
|
31
|
-
// create no conversation, so an app without init doesn't get one for every
|
|
32
|
-
// page view. Its first message starts the conversation, as it always has.
|
|
33
|
-
if (route === "/start" && !hai.config.init) {
|
|
34
|
-
openSSE(res);
|
|
35
|
-
res.end();
|
|
36
|
-
return true;
|
|
37
|
-
}
|
|
38
|
-
// Before the SSE stream opens, because a rejected load has no stream to
|
|
39
|
-
// report into — and an uncaught throw here would take down the request.
|
|
40
|
-
let conversation;
|
|
41
43
|
try {
|
|
42
|
-
|
|
44
|
+
await respond(hai, route, req, res);
|
|
43
45
|
}
|
|
44
46
|
catch (err) {
|
|
45
|
-
|
|
46
|
-
throw err;
|
|
47
|
-
res.writeHead(409, { "content-type": "application/json" });
|
|
48
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
49
|
-
return true;
|
|
47
|
+
failed(res, err);
|
|
50
48
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
49
|
+
return true;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A request that failed in a way the route has no event for, such as a store
|
|
54
|
+
* error: the browser is told, the response ends, and the error is logged. It
|
|
55
|
+
* is never thrown on to the server, where an unhandled rejection would take
|
|
56
|
+
* the process down and leave the response open.
|
|
57
|
+
*/
|
|
58
|
+
function failed(res, err) {
|
|
59
|
+
console.error("[haikit] request failed:", err);
|
|
60
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
61
|
+
if (!res.headersSent) {
|
|
62
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
63
|
+
res.end(JSON.stringify({ error: message }));
|
|
64
|
+
}
|
|
65
|
+
else if (!res.writableEnded) {
|
|
66
|
+
res.write(`data: ${JSON.stringify({ type: "error", message })}\n\n`);
|
|
67
|
+
res.end();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** One of the three POST routes, once the request is known to be one. */
|
|
71
|
+
async function respond(hai, route, req, res) {
|
|
72
|
+
let body;
|
|
73
|
+
try {
|
|
74
|
+
body = await readJson(req);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
78
|
+
res.end(JSON.stringify({ error: "the request body must be a JSON object" }));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// Nothing to run before the first message: answer with an empty stream and
|
|
82
|
+
// create no conversation, so an app without init doesn't get one for every
|
|
83
|
+
// page view. Its first message starts the conversation, as it always has.
|
|
84
|
+
if (route === "/start" && !hai.config.init) {
|
|
85
|
+
openSSE(res);
|
|
86
|
+
res.end();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// Before the SSE stream opens, because a rejected load has no stream to
|
|
90
|
+
// report into: busy is a 409, and anything else a 500 (see `failed`).
|
|
91
|
+
let conversation;
|
|
92
|
+
try {
|
|
93
|
+
conversation = await hai.config.store.loadConversation(body.conversationId);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
if (!isConversationBusy(err))
|
|
97
|
+
throw err;
|
|
98
|
+
res.writeHead(409, { "content-type": "application/json" });
|
|
99
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const emit = openSSE(res);
|
|
103
|
+
if (route === "/chat" || route === "/start") {
|
|
104
|
+
emit({
|
|
105
|
+
type: "hello",
|
|
106
|
+
conversationId: conversation.id,
|
|
107
|
+
model: hai.config.model.id,
|
|
108
|
+
...(hai.streamsEvents ? { events: true } : {}),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
let superseded = false;
|
|
112
|
+
try {
|
|
113
|
+
if (route === "/chat") {
|
|
114
|
+
await hai.send(conversation, String(body.message ?? ""), emit);
|
|
115
|
+
}
|
|
116
|
+
else if (route === "/start") {
|
|
117
|
+
await hai.start(conversation, emit);
|
|
59
118
|
}
|
|
60
|
-
|
|
119
|
+
else {
|
|
120
|
+
await hai.interact(conversation, { handle: body.handle, action: body.action, value: body.value }, emit);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
if (isStaleLease(err))
|
|
125
|
+
superseded = true;
|
|
126
|
+
emit({ type: "error", message: err.message });
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
// The lease lives exactly as long as the request. Releasing it any
|
|
130
|
+
// earlier — when a turn parks, say — lets the client's next interaction
|
|
131
|
+
// arrive before this request has finished writing, and the two turns
|
|
132
|
+
// interleave on one conversation.
|
|
133
|
+
//
|
|
134
|
+
// Only the expiry is cleared. The token has to survive so the save below
|
|
135
|
+
// can still prove this request is the rightful holder.
|
|
136
|
+
conversation.leaseUntil = null;
|
|
61
137
|
try {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
await hai.start(conversation, emit);
|
|
67
|
-
}
|
|
68
|
-
else {
|
|
69
|
-
await hai.interact(conversation, { handle: body.handle, action: body.action, value: body.value }, emit);
|
|
70
|
-
}
|
|
138
|
+
// A turn that already hit a fence knows its save would be rejected too;
|
|
139
|
+
// attempting it only repeats the same error to the client.
|
|
140
|
+
if (!superseded)
|
|
141
|
+
await hai.config.store.saveConversation(conversation);
|
|
71
142
|
}
|
|
72
143
|
catch (err) {
|
|
73
|
-
|
|
74
|
-
|
|
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))
|
|
150
|
+
throw err;
|
|
75
151
|
emit({ type: "error", message: err.message });
|
|
76
152
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
// earlier — when a turn parks, say — lets the client's next interaction
|
|
80
|
-
// arrive before this request has finished writing, and the two turns
|
|
81
|
-
// interleave on one conversation.
|
|
82
|
-
//
|
|
83
|
-
// Only the expiry is cleared. The token has to survive so the save below
|
|
84
|
-
// can still prove this request is the rightful holder.
|
|
85
|
-
conversation.leaseUntil = null;
|
|
86
|
-
try {
|
|
87
|
-
// A turn that already hit a fence knows its save would be rejected too;
|
|
88
|
-
// attempting it only repeats the same error to the client.
|
|
89
|
-
if (!superseded)
|
|
90
|
-
await hai.config.store.saveConversation(conversation);
|
|
91
|
-
}
|
|
92
|
-
catch (err) {
|
|
93
|
-
// Overtaken while we were slow: another turn already wrote newer state
|
|
94
|
-
// under a fresh token. Dropping this write is the correct outcome, but
|
|
95
|
-
// the client asked for something it is not getting, so say so.
|
|
96
|
-
if (!isStaleLease(err))
|
|
97
|
-
throw err;
|
|
98
|
-
emit({ type: "error", message: err.message });
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
res.end();
|
|
102
|
-
return true;
|
|
103
|
-
};
|
|
153
|
+
}
|
|
154
|
+
res.end();
|
|
104
155
|
}
|
|
105
156
|
/**
|
|
106
157
|
* The longest a notice waits to be read. A store's `watch` makes it sooner;
|
|
107
158
|
* this is what still finds a notice whose wake-up was lost.
|
|
108
159
|
*/
|
|
109
160
|
const POLL_MS = 2_000;
|
|
161
|
+
/**
|
|
162
|
+
* A short fingerprint of signal values, by name: what a polling browser says it
|
|
163
|
+
* has, so a poll knows whether the latest values are news to it. The client
|
|
164
|
+
* computes the same (packages/client/src/index.js); change both together.
|
|
165
|
+
*/
|
|
166
|
+
export function signalsDigest(entries) {
|
|
167
|
+
const text = JSON.stringify([...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
168
|
+
let hash = 0x811c9dc5;
|
|
169
|
+
for (let i = 0; i < text.length; i++)
|
|
170
|
+
hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193);
|
|
171
|
+
return (hash >>> 0).toString(16);
|
|
172
|
+
}
|
|
173
|
+
/** How long a process keeps hearing a conversation's turns after its last stream closes. */
|
|
174
|
+
const WATCH_LINGER_MS = 60_000;
|
|
175
|
+
/** How long a wake turn that has ended is kept, for a poll that missed its end, and how many at most. */
|
|
176
|
+
const ENDED_TURN_KEPT_MS = 60_000;
|
|
177
|
+
const ENDED_TURNS_KEPT = 5;
|
|
178
|
+
/** The longest a poll holds when there is nothing new to send. */
|
|
179
|
+
const POLL_HOLD_MS = 15_000;
|
|
180
|
+
/** How long a poll waits after something new, for what comes with it, before it ends. */
|
|
181
|
+
const POLL_GATHER_MS = 250;
|
|
110
182
|
/** Proxies drop a stream that says nothing for long; a comment keeps it open. */
|
|
111
183
|
const HEARTBEAT_MS = 25_000;
|
|
112
184
|
/** Notices read from the store at a time, so a long backlog goes out page by page. */
|
|
@@ -126,6 +198,18 @@ const PAGE = 100;
|
|
|
126
198
|
async function streamNotices(hai, req, res, url) {
|
|
127
199
|
const { store } = hai.config;
|
|
128
200
|
const conversationId = url.searchParams.get("conversationId") ?? "";
|
|
201
|
+
// A poll, for a browser behind a proxy that holds a stream back until it
|
|
202
|
+
// ends: what's waiting, then the response ends, which any proxy delivers.
|
|
203
|
+
// `poll=now` answers at once; `poll=1` holds until something new arrives,
|
|
204
|
+
// or for `wait` ms (at most POLL_HOLD_MS) if nothing does.
|
|
205
|
+
const poll = url.searchParams.get("poll");
|
|
206
|
+
const polling = poll === "1" || poll === "now";
|
|
207
|
+
// a fingerprint of the signal values the browser already has (signalsDigest)
|
|
208
|
+
const hasSignals = url.searchParams.get("signals");
|
|
209
|
+
// the last wake turn the browser saw end, by its number
|
|
210
|
+
const sawTurn = Number(url.searchParams.get("turn") ?? 0);
|
|
211
|
+
const asked = Number(url.searchParams.get("wait") ?? POLL_HOLD_MS);
|
|
212
|
+
const holdMs = Number.isFinite(asked) ? Math.min(Math.max(asked, 0), POLL_HOLD_MS) : POLL_HOLD_MS;
|
|
129
213
|
const resume = Number(req.headers["last-event-id"] ?? url.searchParams.get("after") ?? 0);
|
|
130
214
|
let after = Number.isSafeInteger(resume) && resume > 0 ? resume : 0;
|
|
131
215
|
const first = await store.getNotices(conversationId, after, PAGE);
|
|
@@ -139,6 +223,7 @@ async function streamNotices(hai, req, res, url) {
|
|
|
139
223
|
// Counted as watching from here, so nothing lets the entry go while this
|
|
140
224
|
// stream is still being set up.
|
|
141
225
|
const watchers = watchersOf(hai, conversationId);
|
|
226
|
+
watchers.stay();
|
|
142
227
|
watchers.pending++;
|
|
143
228
|
try {
|
|
144
229
|
await watchers.ready;
|
|
@@ -159,8 +244,11 @@ async function streamNotices(hai, req, res, url) {
|
|
|
159
244
|
connection: "keep-alive",
|
|
160
245
|
});
|
|
161
246
|
// Sent now, not with the first frame: with nothing to catch up on, that is a
|
|
162
|
-
// heartbeat away, and the browser's request would hang open until then.
|
|
247
|
+
// heartbeat away, and the browser's request would hang open until then. A
|
|
248
|
+
// comment with them, so the browser hears its first bytes at once: one that
|
|
249
|
+
// hears nothing knows something on the way is holding the stream, and polls.
|
|
163
250
|
res.flushHeaders();
|
|
251
|
+
res.write(": open\n\n");
|
|
164
252
|
const aborter = new AbortController();
|
|
165
253
|
const { signal } = aborter;
|
|
166
254
|
res.on("close", () => aborter.abort());
|
|
@@ -178,12 +266,20 @@ async function streamNotices(hai, req, res, url) {
|
|
|
178
266
|
signal.addEventListener("abort", done);
|
|
179
267
|
});
|
|
180
268
|
};
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
269
|
+
// Something new for this browser: a poll ends a moment after it, to gather
|
|
270
|
+
// what comes with it.
|
|
271
|
+
let markFresh = () => { };
|
|
272
|
+
const freshened = new Promise((resolve) => (markFresh = resolve));
|
|
273
|
+
// A frame out, and a stream the browser has left takes no more.
|
|
274
|
+
const send = (event) => {
|
|
184
275
|
if (!signal.aborted && !res.writableEnded)
|
|
185
276
|
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
186
277
|
};
|
|
278
|
+
// A wake turn's events go out as they come, one frame each.
|
|
279
|
+
const emit = (event) => {
|
|
280
|
+
send(event);
|
|
281
|
+
markFresh();
|
|
282
|
+
};
|
|
187
283
|
// Every stream this process has open for the conversation hears a wake
|
|
188
284
|
// turn, not only the one whose attempt won the lease: another tab shows the
|
|
189
285
|
// reply too, and holds its requests until the turn lets go.
|
|
@@ -216,7 +312,23 @@ async function streamNotices(hai, req, res, url) {
|
|
|
216
312
|
}
|
|
217
313
|
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
218
314
|
};
|
|
219
|
-
|
|
315
|
+
// A new signal is news. Each one's latest, sent as the stream opens, is
|
|
316
|
+
// news to a poll only if the browser doesn't have those values yet, such as
|
|
317
|
+
// one sent between two polls: every poll gets them, and a poll that always
|
|
318
|
+
// ended on them would never wait.
|
|
319
|
+
const replayed = [];
|
|
320
|
+
const closeStream = hai.streamOpened(conversationId, (event) => {
|
|
321
|
+
emitSignal(event);
|
|
322
|
+
markFresh();
|
|
323
|
+
}, (event) => {
|
|
324
|
+
emitSignal(event);
|
|
325
|
+
if (!polling || event.type !== "signal")
|
|
326
|
+
return;
|
|
327
|
+
// the latest are sent one after another, at once: compare once all are in
|
|
328
|
+
if (!replayed.length)
|
|
329
|
+
queueMicrotask(() => signalsDigest(replayed) !== hasSignals && markFresh());
|
|
330
|
+
replayed.push([event.name, event.payload]);
|
|
331
|
+
});
|
|
220
332
|
// Joining while a wake turn runs: what it has shown so far, starting with
|
|
221
333
|
// its `streaming` status, so the browser holds requests until it lets go,
|
|
222
334
|
// and the reply's later text has a block to land in.
|
|
@@ -224,16 +336,38 @@ async function streamNotices(hai, req, res, url) {
|
|
|
224
336
|
// freshness from when the surface was stored, not from now.
|
|
225
337
|
if (watchers.live)
|
|
226
338
|
for (const event of watchers.shown)
|
|
227
|
-
|
|
339
|
+
send(aged(event));
|
|
340
|
+
else if (polling) {
|
|
341
|
+
// A turn that ended between two polls, unseen: all it showed, its end
|
|
342
|
+
// included, which is news. Then that no turn is running, which a browser
|
|
343
|
+
// that saw nothing of one needs too, or it would wait for it for ever.
|
|
344
|
+
// every one it missed, oldest first, if more than one ended meanwhile
|
|
345
|
+
const missed = Number.isSafeInteger(sawTurn) ? watchers.ended.filter((turn) => turn.number > sawTurn) : [];
|
|
346
|
+
for (const turn of missed)
|
|
347
|
+
for (const event of turn.shown)
|
|
348
|
+
send(aged(event));
|
|
349
|
+
if (missed.length)
|
|
350
|
+
markFresh();
|
|
351
|
+
send({ type: "released" });
|
|
352
|
+
}
|
|
228
353
|
// Wake turns run beside the notice loop, so a notice never waits behind a
|
|
229
354
|
// turn, nor behind the retries while another request holds the
|
|
230
355
|
// conversation. One attempt at a time; a wake notice that lands meanwhile
|
|
231
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.
|
|
232
364
|
let waking = null;
|
|
233
365
|
let again = false;
|
|
234
366
|
const wake = () => {
|
|
235
367
|
if (!hai.wakes || signal.aborted)
|
|
236
368
|
return;
|
|
369
|
+
if (watchers.wakeRead <= watchers.wakeSettled)
|
|
370
|
+
return;
|
|
237
371
|
if (waking) {
|
|
238
372
|
again = true;
|
|
239
373
|
return;
|
|
@@ -241,12 +375,24 @@ async function streamNotices(hai, req, res, url) {
|
|
|
241
375
|
waking = (async () => {
|
|
242
376
|
do {
|
|
243
377
|
again = false;
|
|
378
|
+
// every wake notice read here so far: what the attempt settles, unless it fails
|
|
379
|
+
const through = watchers.wakeRead;
|
|
244
380
|
let wait = WAKE_RETRY_MS;
|
|
245
|
-
|
|
381
|
+
let outcome = "busy";
|
|
382
|
+
while (!signal.aborted && (outcome = await hai.wake(conversationId, watchers.turn())) === "busy") {
|
|
246
383
|
await pause(wait, signal);
|
|
247
384
|
wait = Math.min(wait * 2, WAKE_RETRY_MAX_MS);
|
|
385
|
+
// Another stream here may have been the one holding it, with a turn
|
|
386
|
+
// that settled these notices: taking the lease again finds nothing.
|
|
387
|
+
if (through <= watchers.wakeSettled)
|
|
388
|
+
break;
|
|
248
389
|
}
|
|
249
|
-
|
|
390
|
+
// A failed turn gives its notices back to wake again, so it settles
|
|
391
|
+
// nothing: the next stream to open tries once more.
|
|
392
|
+
if (outcome !== "busy" && outcome !== "failed")
|
|
393
|
+
watchers.wakeSettled = Math.max(watchers.wakeSettled, through);
|
|
394
|
+
// asked again meanwhile: only if a notice came that this one didn't settle
|
|
395
|
+
} while (again && !signal.aborted && watchers.wakeRead > watchers.wakeSettled);
|
|
250
396
|
})()
|
|
251
397
|
.catch((err) => emit({ type: "error", message: err.message }))
|
|
252
398
|
.finally(() => {
|
|
@@ -301,7 +447,11 @@ async function streamNotices(hai, req, res, url) {
|
|
|
301
447
|
if (n.replaces !== undefined && n.handle !== undefined) {
|
|
302
448
|
await sendRevision(n.handle, n.replaces, n.seq);
|
|
303
449
|
after = n.seq;
|
|
304
|
-
|
|
450
|
+
markFresh();
|
|
451
|
+
if (n.kind === "wake") {
|
|
452
|
+
woken = true;
|
|
453
|
+
watchers.wakeRead = Math.max(watchers.wakeRead, n.seq);
|
|
454
|
+
}
|
|
305
455
|
continue;
|
|
306
456
|
}
|
|
307
457
|
const event = {
|
|
@@ -314,7 +464,11 @@ async function streamNotices(hai, req, res, url) {
|
|
|
314
464
|
};
|
|
315
465
|
await write(`id: ${n.seq}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
316
466
|
after = n.seq;
|
|
317
|
-
|
|
467
|
+
markFresh();
|
|
468
|
+
if (n.kind === "wake") {
|
|
469
|
+
woken = true;
|
|
470
|
+
watchers.wakeRead = Math.max(watchers.wakeRead, n.seq);
|
|
471
|
+
}
|
|
318
472
|
}
|
|
319
473
|
if (records.length < PAGE || signal.aborted)
|
|
320
474
|
break;
|
|
@@ -328,16 +482,15 @@ async function streamNotices(hai, req, res, url) {
|
|
|
328
482
|
// An app that sends no notices has nothing stored to read: the stream is only
|
|
329
483
|
// for signals, and stays open, reading nothing, until the browser leaves.
|
|
330
484
|
const wakes = hai.sendsNotices ? wakeups(store.watch?.(conversationId, signal), signal)[Symbol.asyncIterator]() : null;
|
|
331
|
-
|
|
332
|
-
if (
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
485
|
+
const left = new Promise((resolve) => {
|
|
486
|
+
if (signal.aborted)
|
|
487
|
+
resolve();
|
|
488
|
+
else
|
|
489
|
+
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
490
|
+
});
|
|
491
|
+
const run = async () => {
|
|
492
|
+
if (!wakes)
|
|
493
|
+
return left;
|
|
341
494
|
await catchUp(first);
|
|
342
495
|
// A wake notice may be waiting from before this browser connected: one
|
|
343
496
|
// that arrived while nobody watched, or while the conversation was busy.
|
|
@@ -352,6 +505,24 @@ async function streamNotices(hai, req, res, url) {
|
|
|
352
505
|
next = wakes.next();
|
|
353
506
|
await catchUp();
|
|
354
507
|
}
|
|
508
|
+
};
|
|
509
|
+
try {
|
|
510
|
+
if (!polling)
|
|
511
|
+
return await run();
|
|
512
|
+
// A poll: the same reading, until something new has gone out and what
|
|
513
|
+
// came with it has had a moment to follow, or the hold runs out.
|
|
514
|
+
if (poll === "now")
|
|
515
|
+
markFresh();
|
|
516
|
+
let hold;
|
|
517
|
+
const done = Promise.race([
|
|
518
|
+
left,
|
|
519
|
+
freshened.then(() => new Promise((resolve) => setTimeout(resolve, POLL_GATHER_MS))),
|
|
520
|
+
new Promise((resolve) => (hold = setTimeout(resolve, holdMs))),
|
|
521
|
+
]);
|
|
522
|
+
// a read that fails ends nothing early: the poll answers at its hold
|
|
523
|
+
void run().catch((err) => console.error("[haikit] events poll failed:", err));
|
|
524
|
+
await done;
|
|
525
|
+
clearTimeout(hold);
|
|
355
526
|
}
|
|
356
527
|
finally {
|
|
357
528
|
clearInterval(heartbeat);
|
|
@@ -450,8 +621,9 @@ function watchersOf(hai, conversationId) {
|
|
|
450
621
|
const origin = originOf(hai);
|
|
451
622
|
// Hearing other processes' wake turns lasts as long as this entry does.
|
|
452
623
|
const hearing = new AbortController();
|
|
624
|
+
let lingering = null;
|
|
453
625
|
const forget = () => {
|
|
454
|
-
if (map.get(conversationId) !== fresh || fresh.pending || fresh.streams.size || fresh.live)
|
|
626
|
+
if (map.get(conversationId) !== fresh || fresh.pending || fresh.streams.size || fresh.live || fresh.ended.length || lingering)
|
|
455
627
|
return;
|
|
456
628
|
map.delete(conversationId);
|
|
457
629
|
hearing.abort();
|
|
@@ -466,6 +638,7 @@ function watchersOf(hai, conversationId) {
|
|
|
466
638
|
// start. A turn takes over only with a number at least as high as any
|
|
467
639
|
// seen; anything but the current turn's frames is dropped.
|
|
468
640
|
let current = null;
|
|
641
|
+
let currentNumber;
|
|
469
642
|
let newest = 0;
|
|
470
643
|
const deliver = (event, turn, number) => {
|
|
471
644
|
if (event.type === "status" && event.status === "streaming") {
|
|
@@ -473,6 +646,7 @@ function watchersOf(hai, conversationId) {
|
|
|
473
646
|
if (number !== undefined && number < newest)
|
|
474
647
|
return; // an older turn's late start
|
|
475
648
|
current = turn;
|
|
649
|
+
currentNumber = number;
|
|
476
650
|
if (number !== undefined)
|
|
477
651
|
newest = number;
|
|
478
652
|
fresh.live = true;
|
|
@@ -484,6 +658,15 @@ function watchersOf(hai, conversationId) {
|
|
|
484
658
|
}
|
|
485
659
|
remember(fresh.shown, event);
|
|
486
660
|
if (event.type === "released") {
|
|
661
|
+
// kept a while for a poll that missed the end, then let go
|
|
662
|
+
if (currentNumber !== undefined) {
|
|
663
|
+
const ended = { number: currentNumber, shown: fresh.shown };
|
|
664
|
+
fresh.ended = [...fresh.ended, ended].slice(-ENDED_TURNS_KEPT);
|
|
665
|
+
setTimeout(() => {
|
|
666
|
+
fresh.ended = fresh.ended.filter((turn) => turn !== ended);
|
|
667
|
+
forget();
|
|
668
|
+
}, ENDED_TURN_KEPT_MS).unref?.();
|
|
669
|
+
}
|
|
487
670
|
current = null;
|
|
488
671
|
fresh.live = false;
|
|
489
672
|
fresh.shown = [];
|
|
@@ -498,8 +681,25 @@ function watchersOf(hai, conversationId) {
|
|
|
498
681
|
streams: new Set(),
|
|
499
682
|
live: false,
|
|
500
683
|
shown: [],
|
|
684
|
+
ended: [],
|
|
501
685
|
forget,
|
|
686
|
+
stay: () => {
|
|
687
|
+
if (lingering)
|
|
688
|
+
clearTimeout(lingering);
|
|
689
|
+
lingering = null;
|
|
690
|
+
},
|
|
691
|
+
leave: () => {
|
|
692
|
+
if (lingering)
|
|
693
|
+
clearTimeout(lingering);
|
|
694
|
+
lingering = setTimeout(() => {
|
|
695
|
+
lingering = null;
|
|
696
|
+
forget();
|
|
697
|
+
}, WATCH_LINGER_MS);
|
|
698
|
+
lingering.unref?.();
|
|
699
|
+
},
|
|
502
700
|
ready: Promise.resolve(),
|
|
701
|
+
wakeRead: 0,
|
|
702
|
+
wakeSettled: -1,
|
|
503
703
|
// A wake turn running here: its frames to this process's streams, and,
|
|
504
704
|
// through the store, to every other process's. All but `context`, the
|
|
505
705
|
// whole history, which another tab gets with its own next request.
|
|
@@ -542,8 +742,9 @@ function watchersOf(hai, conversationId) {
|
|
|
542
742
|
}
|
|
543
743
|
function forgetWatchers(_hai, _conversationId, watchers) {
|
|
544
744
|
// Kept while a wake turn runs, so a stream that joins later still hears it,
|
|
545
|
-
//
|
|
546
|
-
|
|
745
|
+
// while any stream is open or being set up, while an ended turn is kept, and
|
|
746
|
+
// for a while after the last stream closes. `forget` checks them all.
|
|
747
|
+
watchers.leave();
|
|
547
748
|
}
|
|
548
749
|
/** The first wait before trying a wake turn again on a busy conversation, and the longest. */
|
|
549
750
|
const WAKE_RETRY_MS = 250;
|
|
@@ -607,10 +808,14 @@ function openSSE(res) {
|
|
|
607
808
|
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
608
809
|
};
|
|
609
810
|
}
|
|
811
|
+
/** The request body, as a JSON object; anything else, or a body that fails to arrive, throws. */
|
|
610
812
|
async function readJson(req) {
|
|
611
813
|
const chunks = [];
|
|
612
814
|
for await (const chunk of req)
|
|
613
815
|
chunks.push(chunk);
|
|
614
|
-
|
|
816
|
+
const body = JSON.parse(Buffer.concat(chunks).toString() || "{}");
|
|
817
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
818
|
+
throw new TypeError("not a JSON object");
|
|
819
|
+
return body;
|
|
615
820
|
}
|
|
616
821
|
//# sourceMappingURL=routes.js.map
|