@haikit/client 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 CHANGED
@@ -123,6 +123,17 @@ mountChat({
123
123
  });
124
124
  ```
125
125
 
126
+ If something on the network holds the stream back, as HTTPS-inspecting
127
+ corporate proxies (Netskope, Zscaler) do, the chat polls instead. The server
128
+ greets every stream with a comment the moment it opens. A stream that brings
129
+ no bytes at all within `eventsFallbackMs` (default 8 s, a `createChat`
130
+ option) is dropped for short polls, whose responses end and so get through.
131
+ Notices, signals and wake turns all arrive by polling, a little later than
132
+ they would stream. Each poll asks the server to hold for `pollHoldMs`
133
+ (default 15 s, the server's maximum) when nothing is new; set it lower
134
+ behind a proxy that drops requests sooner. The browser remembers it had to poll, in `localStorage`
135
+ under `hai:events-poll`, for a day, so its next page polls from the start.
136
+
126
137
  A `wake` notice's turn comes down the same stream: its reply appears under the
127
138
  notice like any other. While it runs, `send` and `interact` wait for it to
128
139
  finish rather than being refused, just as they wait behind this chat's own
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haikit/client",
3
- "version": "0.19.0",
3
+ "version": "0.20.1",
4
4
  "description": "The haikit browser runtime: streams tool payloads into interactive UI surfaces. Plain ESM, no build step, no dependencies.",
5
5
  "keywords": [
6
6
  "haikit",
package/src/index.d.ts CHANGED
@@ -245,4 +245,15 @@ export function createChat(options: {
245
245
  registry: Registry;
246
246
  notices?: NoticeRegistry;
247
247
  signals?: SignalRegistry;
248
+ /**
249
+ * How long the events stream may stay silent as it opens before the client
250
+ * decides something on the way is holding it back, and polls instead.
251
+ * Default 8000 ms.
252
+ */
253
+ eventsFallbackMs?: number;
254
+ /**
255
+ * When polling, how long each poll asks the server to hold if nothing new
256
+ * has come (the server allows at most 15 s). Default 15000 ms.
257
+ */
258
+ pollHoldMs?: number;
248
259
  }): Chat;
package/src/index.js CHANGED
@@ -12,7 +12,14 @@
12
12
  /**
13
13
  * @param {{endpoint?: string, registry: Record<string, {mount: Function}>, notices?: Record<string, {mount: Function}>}} options
14
14
  */
15
- export function createChat({ endpoint = "/hai", registry, notices = {}, signals = {} }) {
15
+ export function createChat({
16
+ endpoint = "/hai",
17
+ registry,
18
+ notices = {},
19
+ signals = {},
20
+ eventsFallbackMs = EVENTS_FALLBACK_MS,
21
+ pollHoldMs = POLL_HOLD_MS,
22
+ }) {
16
23
  const listeners = new Set();
17
24
  const state = {
18
25
  conversationId: null,
@@ -111,6 +118,15 @@ export function createChat({ endpoint = "/hai", registry, notices = {}, signals
111
118
  * so `state.notices` alone would miss them.
112
119
  */
113
120
  let resumeAfter = 0;
121
+ /**
122
+ * Whether the events stream is read by polling: a browser behind something
123
+ * that holds a stream back until it ends switches to it, and remembers that
124
+ * for a day, so its next page skips the wait.
125
+ */
126
+ let polling = rememberedPolling();
127
+ /** The wake turn showing, and the last one seen to end, by number: a poll says the latter. */
128
+ let turnShowing = 0;
129
+ let turnSeen = 0;
114
130
 
115
131
  // ── transport: SSE over POST (EventSource cannot POST) ──────────────
116
132
  function enqueue(path, body) {
@@ -443,17 +459,40 @@ export function createChat({ endpoint = "/hai", registry, notices = {}, signals
443
459
 
444
460
  async function follow(conversationId, gen, signal) {
445
461
  let wait = 1_000;
462
+ // a poll's first answer comes at once, with the latest of each signal
463
+ let firstPoll = true;
446
464
  while (gen === generation && !signal.aborted) {
447
465
  // A connection that delivered a notice, or stayed up a while, was
448
466
  // healthy, and the next drop starts the backoff over. One that drops
449
467
  // straight after it opens was not, however often the server says 200.
450
468
  const opened = Date.now();
451
469
  let delivered = false;
470
+ // This attempt's own abort, so silence can end it without ending the chat.
471
+ const attempt = new AbortController();
472
+ const stop = () => attempt.abort();
473
+ signal.addEventListener("abort", stop, { once: true });
474
+ // The server sends a comment the moment a stream opens. Nothing at all
475
+ // within eventsFallbackMs means something on the way, such as a proxy
476
+ // that inspects traffic, is holding the stream back until it ends,
477
+ // which it never does: poll instead, with responses that end.
478
+ let silent = false;
479
+ const silence = polling
480
+ ? null
481
+ : setTimeout(() => {
482
+ silent = true;
483
+ attempt.abort();
484
+ }, eventsFallbackMs);
485
+
486
+ let ended = false;
452
487
  try {
453
488
  const last = Math.max(resumeAfter, state.notices.at(-1)?.seq ?? 0);
454
- const res = await fetch(`${endpoint}/events?conversationId=${encodeURIComponent(conversationId)}`, {
489
+ // with a fingerprint of the signals it has, so a poll knows if their latest are news
490
+ const query = polling
491
+ ? `&poll=${firstPoll ? "now" : "1"}&wait=${pollHoldMs}&turn=${turnSeen}&signals=${signalsDigest(Object.entries(state.signals))}`
492
+ : "";
493
+ const res = await fetch(`${endpoint}/events?conversationId=${encodeURIComponent(conversationId)}${query}`, {
455
494
  headers: last ? { "last-event-id": String(last) } : {},
456
- signal,
495
+ signal: attempt.signal,
457
496
  });
458
497
  // the server has no such conversation, or sends no notices: stop
459
498
  if (res.status === 404) return;
@@ -462,7 +501,11 @@ export function createChat({ endpoint = "/hai", registry, notices = {}, signals
462
501
  let buffer = "";
463
502
  for (;;) {
464
503
  const { value, done } = await reader.read();
465
- if (done) break;
504
+ clearTimeout(silence);
505
+ if (done) {
506
+ ended = true;
507
+ break;
508
+ }
466
509
  buffer += value;
467
510
  let i;
468
511
  while ((i = buffer.indexOf("\n\n")) >= 0) {
@@ -489,6 +532,22 @@ export function createChat({ endpoint = "/hai", registry, notices = {}, signals
489
532
  }
490
533
  } catch {
491
534
  if (signal.aborted) return finishRemote();
535
+ } finally {
536
+ clearTimeout(silence);
537
+ signal.removeEventListener("abort", stop);
538
+ }
539
+ if (silent) {
540
+ polling = true;
541
+ rememberPolling();
542
+ continue;
543
+ }
544
+ // A poll that ended as polls do: the next one at once, a moment apart.
545
+ // A wake turn it was part way through goes on in the next, which is sent
546
+ // what the turn has shown so far, or that none is running.
547
+ if (polling && ended && gen === generation && !signal.aborted) {
548
+ firstPoll = false;
549
+ await pause(Math.max(0, POLL_GAP_MS - (Date.now() - opened)), signal);
550
+ continue;
492
551
  }
493
552
  // A turn cut off with its connection runs on, but nothing will say when
494
553
  // it ends: stop waiting for it, and give the next request longer instead.
@@ -518,10 +577,13 @@ export function createChat({ endpoint = "/hai", registry, notices = {}, signals
518
577
  */
519
578
  function followTurn(event) {
520
579
  if (event.type === "status" && event.status === "streaming") {
580
+ // the turn's number, so a poll can say which turn it last saw end
581
+ if (typeof event.wake === "number") turnShowing = event.wake;
521
582
  if (!remote) remote = new Promise((resolve) => (endRemote = resolve));
522
583
  // surfaces it shows are stamped after this, as a request's are after it is sent
523
584
  sentAt = Date.now();
524
585
  } else if (event.type === "released") {
586
+ if (turnShowing > turnSeen) turnSeen = turnShowing;
525
587
  finishRemote();
526
588
  }
527
589
  }
@@ -747,6 +809,9 @@ export function createChat({ endpoint = "/hai", registry, notices = {}, signals
747
809
  stopListening();
748
810
  cutOff = false;
749
811
  resumeAfter = 0;
812
+ // turns are numbered per conversation
813
+ turnShowing = 0;
814
+ turnSeen = 0;
750
815
  clearTimeout(timer);
751
816
  for (const surface of state.surfaces.values()) unmount(surface);
752
817
  for (const seq of [...noticeMounts.keys()]) unmountNotice(seq);
@@ -815,6 +880,56 @@ const CUT_OFF_MS = 120_000;
815
880
  /** An events connection that stays up this long was healthy, whatever it delivered. */
816
881
  const HEALTHY_MS = 30_000;
817
882
 
883
+ /** How long the events stream may stay silent as it opens before the client polls instead. */
884
+ const EVENTS_FALLBACK_MS = 8_000;
885
+ /** The least time from one poll's start to the next's. */
886
+ const POLL_GAP_MS = 250;
887
+ /** How long a poll asks the server to hold when nothing new has come: the server allows at most 15 s. */
888
+ const POLL_HOLD_MS = 15_000;
889
+ /** Where a browser remembers it had to poll, and for how long. */
890
+ const POLL_KEY = "hai:events-poll";
891
+ const POLL_REMEMBER_MS = 24 * 60 * 60_000;
892
+
893
+ /**
894
+ * A short fingerprint of signal values, by name, as a poll reports what this
895
+ * browser has. The server computes the same (packages/server/src/routes.ts);
896
+ * change both together.
897
+ */
898
+ function signalsDigest(entries) {
899
+ const text = JSON.stringify([...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
900
+ let hash = 0x811c9dc5;
901
+ for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193);
902
+ return (hash >>> 0).toString(16);
903
+ }
904
+
905
+ function rememberedPolling() {
906
+ try {
907
+ const at = Number(localStorage.getItem(POLL_KEY));
908
+ return Number.isFinite(at) && at > 0 && Date.now() - at < POLL_REMEMBER_MS;
909
+ } catch {
910
+ return false;
911
+ }
912
+ }
913
+
914
+ function rememberPolling() {
915
+ try {
916
+ localStorage.setItem(POLL_KEY, String(Date.now()));
917
+ } catch {}
918
+ }
919
+
920
+ /** Wait `ms`, or until `signal` aborts. */
921
+ function pause(ms, signal) {
922
+ return new Promise((resolve) => {
923
+ const done = () => {
924
+ clearTimeout(timer);
925
+ signal.removeEventListener("abort", done);
926
+ resolve();
927
+ };
928
+ const timer = setTimeout(done, ms);
929
+ signal.addEventListener("abort", done);
930
+ });
931
+ }
932
+
818
933
  /**
819
934
  * Run `fn`, reporting what it throws without letting it stop the caller: the
820
935
  * error surfaces as an uncaught one would, after the caller has finished.