@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/routes.ts
CHANGED
|
@@ -22,7 +22,7 @@ export function nodeHandler(hai: Hai, basePath = "/hai") {
|
|
|
22
22
|
if (!url.pathname.startsWith(basePath)) return false;
|
|
23
23
|
const route = url.pathname.slice(basePath.length);
|
|
24
24
|
|
|
25
|
-
if (req.method === "GET" && route === "/events" && hai.
|
|
25
|
+
if (req.method === "GET" && route === "/events" && hai.streamsEvents) {
|
|
26
26
|
await streamNotices(hai, req, res, url);
|
|
27
27
|
return true;
|
|
28
28
|
}
|
|
@@ -59,7 +59,7 @@ export function nodeHandler(hai: Hai, basePath = "/hai") {
|
|
|
59
59
|
type: "hello",
|
|
60
60
|
conversationId: conversation.id,
|
|
61
61
|
model: hai.config.model.id,
|
|
62
|
-
...(hai.
|
|
62
|
+
...(hai.streamsEvents ? { events: true as const } : {}),
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
65
|
|
|
@@ -195,6 +195,32 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
|
|
|
195
195
|
// turn, not only the one whose attempt won the lease: another tab shows the
|
|
196
196
|
// reply too, and holds its requests until the turn lets go.
|
|
197
197
|
watchers.streams.add(emit);
|
|
198
|
+
// Signals from here on, starting with each one's latest; and it counts as
|
|
199
|
+
// present. A signal only matters as its latest value, so a reader that has
|
|
200
|
+
// fallen behind is sent just the newest of each once it catches up: a
|
|
201
|
+
// stalled socket holds at most one per signal, never every one sent.
|
|
202
|
+
const behind = new Map<string, WireEvent>();
|
|
203
|
+
let draining = false;
|
|
204
|
+
const flush = () => {
|
|
205
|
+
draining = false;
|
|
206
|
+
const pending = [...behind.values()];
|
|
207
|
+
behind.clear();
|
|
208
|
+
for (const event of pending) emitSignal(event);
|
|
209
|
+
};
|
|
210
|
+
const emitSignal: Emit = (event) => {
|
|
211
|
+
if (event.type !== "signal") return emit(event);
|
|
212
|
+
if (signal.aborted || res.writableEnded) return;
|
|
213
|
+
if (res.writableNeedDrain || behind.size) {
|
|
214
|
+
behind.set(event.name, event);
|
|
215
|
+
if (!draining) {
|
|
216
|
+
draining = true;
|
|
217
|
+
res.once("drain", flush);
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
222
|
+
};
|
|
223
|
+
const closeStream = hai.streamOpened(conversationId, emitSignal);
|
|
198
224
|
// Joining while a wake turn runs: what it has shown so far, starting with
|
|
199
225
|
// its `streaming` status, so the browser holds requests until it lets go,
|
|
200
226
|
// and the reply's later text has a block to land in.
|
|
@@ -232,6 +258,33 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
|
|
|
232
258
|
});
|
|
233
259
|
};
|
|
234
260
|
|
|
261
|
+
// An update notice goes out as the revision it announces: the surface, with
|
|
262
|
+
// how long ago it was stored, and its props. Only the last frame carries the
|
|
263
|
+
// notice's id, so a stream cut off between the two resumes with both. A
|
|
264
|
+
// revision whose payload is gone, swept with an old conversation's, is
|
|
265
|
+
// passed over with its id alone, so a browser resumes after it rather than
|
|
266
|
+
// being sent it again on every reconnect.
|
|
267
|
+
const sendRevision = async (handle: string, replaces: string, seq: number) => {
|
|
268
|
+
const record = await store.getPayload(handle, conversationId);
|
|
269
|
+
if (signal.aborted) return;
|
|
270
|
+
if (!record) return write(`id: ${seq}\n\n`);
|
|
271
|
+
const window = typeof record.staleAfterMs === "number" ? record.staleAfterMs : undefined;
|
|
272
|
+
const open: WireEvent = {
|
|
273
|
+
type: "ui_open",
|
|
274
|
+
handle,
|
|
275
|
+
// no tool call made it: the browser keeps the row of the one it replaces
|
|
276
|
+
toolId: "",
|
|
277
|
+
component: record.component,
|
|
278
|
+
version: record.version,
|
|
279
|
+
mode: record.mode,
|
|
280
|
+
...(window === undefined ? {} : { staleAfterMs: window }),
|
|
281
|
+
ageMs: Math.max(0, Date.now() - record.createdAt),
|
|
282
|
+
replaces,
|
|
283
|
+
};
|
|
284
|
+
await write(`data: ${JSON.stringify(open)}\n\n`);
|
|
285
|
+
await write(`id: ${seq}\ndata: ${JSON.stringify({ type: "ui_props", handle, props: record.props } satisfies WireEvent)}\n\n`);
|
|
286
|
+
};
|
|
287
|
+
|
|
235
288
|
// Everything after `after`, a page at a time, until a page comes back short.
|
|
236
289
|
// A conversation deleted since the stream opened ends it: there is nothing
|
|
237
290
|
// more to wait for, and the browser's reconnect is then refused.
|
|
@@ -243,6 +296,12 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
|
|
|
243
296
|
for (const n of records) {
|
|
244
297
|
if (signal.aborted) return;
|
|
245
298
|
if (n.seq <= after) continue;
|
|
299
|
+
if (n.replaces !== undefined && n.handle !== undefined) {
|
|
300
|
+
await sendRevision(n.handle, n.replaces, n.seq);
|
|
301
|
+
after = n.seq;
|
|
302
|
+
woken ||= n.kind === "wake";
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
246
305
|
const event: WireEvent = {
|
|
247
306
|
type: "notice",
|
|
248
307
|
seq: n.seq,
|
|
@@ -263,8 +322,17 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
|
|
|
263
322
|
|
|
264
323
|
// a heartbeat is only for a stream gone quiet, never one already backed up
|
|
265
324
|
const heartbeat = setInterval(() => res.writableNeedDrain || res.write(": ping\n\n"), HEARTBEAT_MS);
|
|
266
|
-
|
|
325
|
+
// An app that sends no notices has nothing stored to read: the stream is only
|
|
326
|
+
// for signals, and stays open, reading nothing, until the browser leaves.
|
|
327
|
+
const wakes = hai.sendsNotices ? wakeups(store.watch?.(conversationId, signal), signal)[Symbol.asyncIterator]() : null;
|
|
267
328
|
try {
|
|
329
|
+
if (!wakes) {
|
|
330
|
+
await new Promise<void>((resolve) => {
|
|
331
|
+
if (signal.aborted) resolve();
|
|
332
|
+
else signal.addEventListener("abort", () => resolve(), { once: true });
|
|
333
|
+
});
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
268
336
|
await catchUp(first);
|
|
269
337
|
// A wake notice may be waiting from before this browser connected: one
|
|
270
338
|
// that arrived while nobody watched, or while the conversation was busy.
|
|
@@ -281,9 +349,10 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
|
|
|
281
349
|
} finally {
|
|
282
350
|
clearInterval(heartbeat);
|
|
283
351
|
aborter.abort();
|
|
352
|
+
closeStream();
|
|
284
353
|
watchers.streams.delete(emit);
|
|
285
354
|
if (!watchers.streams.size) forgetWatchers(hai, conversationId, watchers);
|
|
286
|
-
await wakes
|
|
355
|
+
await wakes?.return?.();
|
|
287
356
|
res.end();
|
|
288
357
|
}
|
|
289
358
|
}
|
|
@@ -338,7 +407,15 @@ function remember(shown: WireEvent[], event: WireEvent) {
|
|
|
338
407
|
else {
|
|
339
408
|
// A revision of a surface this turn showed takes its place: a stream
|
|
340
409
|
// joining later sees the surface once, as it is now. Its old props go.
|
|
341
|
-
|
|
410
|
+
// It keeps `replaces`, so a browser that did see the old one swaps it in
|
|
411
|
+
// place rather than adding a second, and the old one's tool row, which
|
|
412
|
+
// the browser keeps through a revision too.
|
|
413
|
+
const before = shown[replaced] as Extract<WireEvent, { type: "ui_open" }>;
|
|
414
|
+
copy.toolId = before.toolId;
|
|
415
|
+
// and every handle the surface had before, for a browser that last saw
|
|
416
|
+
// one of those and missed the revisions between
|
|
417
|
+
const earlier = [...(before.alsoReplaces ?? []), ...(before.replaces === undefined ? [] : [before.replaces])];
|
|
418
|
+
if (earlier.length) copy.alsoReplaces = earlier;
|
|
342
419
|
shown[replaced] = copy;
|
|
343
420
|
for (let i = shown.length - 1; i >= 0; i--) {
|
|
344
421
|
const e = shown[i];
|