@vincemakes/kiso-client 0.52.0 → 0.54.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/dist/client.d.ts CHANGED
@@ -16,12 +16,28 @@ export interface ClientOptions {
16
16
  readonly headers?: () => Record<string, string> | Promise<Record<string, string>>;
17
17
  /** Injected for tests or non-global environments. Default: globalThis.fetch. */
18
18
  readonly fetch?: typeof fetch;
19
- /** Reconnect backoff for `events()`: first wait and cap, in ms. */
19
+ /** Reconnect backoff for `events()`: first wait and cap, in ms (defaults 250 and 30 000), jittered to 0.5..1 of the step;
20
+ * `idleMs`: no frame (event or keepalive comment) for this long and the connection is dead (default 35 000). */
20
21
  readonly reconnect?: {
21
22
  readonly initialMs?: number;
22
23
  readonly maxMs?: number;
24
+ readonly idleMs?: number;
23
25
  };
24
26
  }
27
+ /** Where `events()` is: dialing, streaming, waiting to dial again (attempt, and the time of the next try), or turned away for good. */
28
+ export type ConnectionState = {
29
+ readonly kind: "connecting";
30
+ } | {
31
+ readonly kind: "open";
32
+ } | {
33
+ readonly kind: "retrying";
34
+ readonly attempt: number;
35
+ readonly nextAt: number;
36
+ } | {
37
+ readonly kind: "refused";
38
+ readonly status: number;
39
+ readonly code: WireErrorCode;
40
+ };
25
41
  /** A non-2xx reply, carrying the wire's error shape. */
26
42
  export declare class ClientError extends Error {
27
43
  readonly status: number;
@@ -53,6 +69,10 @@ export interface EventsOptions {
53
69
  readonly until?: (event: ClientEvent) => boolean;
54
70
  /** Called before each reconnect with the attempt number and the wait. */
55
71
  readonly onReconnect?: (attempt: number, waitMs: number) => void;
72
+ /** Called on every change of the connection's state. A refusal (a 4xx) is reported here and ends the stream. */
73
+ readonly onState?: (state: ConnectionState) => void;
74
+ /** While true no reconnect is tried (a hidden tab); `retryNow()` or the next check ends the wait. */
75
+ readonly paused?: () => boolean;
56
76
  }
57
77
  export interface RunStreamOptions extends Omit<EventsOptions, "until"> {
58
78
  readonly source?: WireSource;
@@ -61,6 +81,8 @@ export interface RunStreamOptions extends Omit<EventsOptions, "until"> {
61
81
  export declare class SessionClient {
62
82
  #private;
63
83
  readonly id: string;
84
+ /** Cut a sleeping reconnect wait short and dial now (the browser came back online, the tab became visible, a button). */
85
+ retryNow(): void;
64
86
  constructor(options: ClientOptions, sessionId: string);
65
87
  /** Start a turn; refusals arrive as ClientError (in_flight, open_run, draining, forbidden). */
66
88
  run(input: WireInput, options?: {
package/dist/client.js CHANGED
@@ -15,6 +15,11 @@ export class ClientError extends Error {
15
15
  export class SessionClient {
16
16
  #options;
17
17
  id;
18
+ #wake = null;
19
+ /** Cut a sleeping reconnect wait short and dial now (the browser came back online, the tab became visible, a button). */
20
+ retryNow() {
21
+ this.#wake?.();
22
+ }
18
23
  constructor(options, sessionId) {
19
24
  this.#options = options;
20
25
  this.id = sessionId;
@@ -102,29 +107,40 @@ export class SessionClient {
102
107
  let lastSeq = options.after ?? -1;
103
108
  let attempt = 0;
104
109
  const initial = this.#options.reconnect?.initialMs ?? 250;
105
- const cap = this.#options.reconnect?.maxMs ?? 5_000;
110
+ const cap = this.#options.reconnect?.maxMs ?? 30_000;
111
+ const idle = this.#options.reconnect?.idleMs ?? 35_000;
112
+ const report = options.onState ?? (() => { });
106
113
  for (;;) {
107
114
  if (options.signal?.aborted)
108
115
  return;
109
- let res;
116
+ report({ kind: "connecting" });
117
+ // the watchdog: no frame for `idle` aborts this connection, and the loop below dials again
118
+ const conn = new AbortController();
119
+ const forward = () => conn.abort();
120
+ options.signal?.addEventListener("abort", forward, { once: true });
121
+ let dog;
122
+ const feed = () => {
123
+ clearTimeout(dog);
124
+ dog = setTimeout(forward, idle);
125
+ };
126
+ feed();
110
127
  try {
111
- res = await this.#request("GET", "events", undefined, {
112
- headers: { accept: "text/event-stream", "last-event-id": String(lastSeq) },
113
- ...(options.signal !== undefined ? { signal: options.signal } : {}),
114
- });
115
- }
116
- catch (err) {
117
- if (options.signal?.aborted)
118
- return;
119
- res = null;
120
- void err;
121
- }
122
- if (res !== null) {
123
- if (res.status !== 200 || res.body === null)
124
- await this.#json(res, []); // a refusal is final: forbidden, not_found
125
- attempt = 0;
126
- try {
128
+ const res = await this.#request("GET", "events", undefined, { headers: { accept: "text/event-stream", "last-event-id": String(lastSeq) }, signal: conn.signal });
129
+ if (res.status >= 400 && res.status < 500) {
130
+ try {
131
+ await this.#json(res, []);
132
+ }
133
+ catch (err) {
134
+ if (err instanceof ClientError)
135
+ report({ kind: "refused", status: err.status, code: err.code });
136
+ }
137
+ return; // a refusal is final, and now it is said
138
+ }
139
+ if (res.status === 200 && res.body !== null) {
140
+ attempt = 0;
141
+ report({ kind: "open" });
127
142
  for await (const frame of readSse(res.body)) {
143
+ feed();
128
144
  if (options.signal?.aborted)
129
145
  return;
130
146
  const ev = toClientEvent(frame);
@@ -140,18 +156,41 @@ export class SessionClient {
140
156
  return;
141
157
  }
142
158
  }
143
- catch {
144
- // the connection dropped mid-stream — fall through to reconnect
145
- }
159
+ }
160
+ catch {
161
+ // the connection dropped or went silent — fall through to reconnect
162
+ }
163
+ finally {
164
+ clearTimeout(dog);
165
+ options.signal?.removeEventListener("abort", forward);
146
166
  }
147
167
  if (options.signal?.aborted)
148
168
  return;
149
169
  attempt += 1;
150
- const wait = Math.min(cap, initial * 2 ** (attempt - 1));
170
+ const wait = (0.5 + Math.random() / 2) * Math.min(cap, initial * 2 ** (attempt - 1));
151
171
  options.onReconnect?.(attempt, wait);
152
- await sleep(wait, options.signal);
172
+ report({ kind: "retrying", attempt, nextAt: Date.now() + wait });
173
+ await this.#nap(wait, options.signal);
174
+ while (options.paused?.() === true && options.signal?.aborted !== true)
175
+ await this.#nap(1_000, options.signal);
153
176
  }
154
177
  }
178
+ #nap(ms, signal) {
179
+ return new Promise((resolve) => {
180
+ const done = () => {
181
+ clearTimeout(timer);
182
+ signal?.removeEventListener("abort", done);
183
+ this.#wake = null;
184
+ resolve();
185
+ };
186
+ const timer = setTimeout(done, ms);
187
+ this.#wake = done;
188
+ if (signal?.aborted)
189
+ done();
190
+ else
191
+ signal?.addEventListener("abort", done, { once: true });
192
+ });
193
+ }
155
194
  }
156
195
  export class KisoClient {
157
196
  #options;
@@ -182,16 +221,3 @@ function toClientEvent(frame) {
182
221
  function isWireError(value) {
183
222
  return typeof value === "object" && value !== null && typeof value.code === "string" && typeof value.message === "string";
184
223
  }
185
- function sleep(ms, signal) {
186
- return new Promise((resolve) => {
187
- if (signal?.aborted)
188
- return resolve();
189
- const timer = setTimeout(done, ms);
190
- function done() {
191
- clearTimeout(timer);
192
- signal?.removeEventListener("abort", done);
193
- resolve();
194
- }
195
- signal?.addEventListener("abort", done, { once: true });
196
- });
197
- }
package/dist/index.d.ts CHANGED
@@ -6,6 +6,6 @@
6
6
  * event. Browser and Node; depends on `@vincemakes/kiso-protocol` alone.
7
7
  */
8
8
  export { ClientError, createClient, KisoClient, SessionClient } from "./client.js";
9
- export type { ClientEvent, ClientOptions, EventsOptions, RunStreamOptions } from "./client.js";
9
+ export type { ClientEvent, ClientOptions, ConnectionState, EventsOptions, RunStreamOptions } from "./client.js";
10
10
  export { parseSseBlock, readSse } from "./sse.js";
11
11
  export type { SseFrame } from "./sse.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-client",
3
- "version": "0.52.0",
3
+ "version": "0.54.0",
4
4
  "description": "kiso client — the typed client for a hosted kiso session over the wire protocol: run, resume, abort, approve, the snapshot, and a stream that reconnects with Last-Event-ID and never repeats an event. Browser and Node; depends on the protocol package only.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,13 +21,13 @@
21
21
  "test": "vitest run"
22
22
  },
23
23
  "dependencies": {
24
- "@vincemakes/kiso-protocol": "0.52.0"
24
+ "@vincemakes/kiso-protocol": "0.54.0"
25
25
  },
26
26
  "devDependencies": {
27
- "@vincemakes/kiso-core": "0.52.0",
28
- "@vincemakes/kiso-evals": "0.52.0",
29
- "@vincemakes/kiso-runtime": "0.52.0",
30
- "@vincemakes/kiso-server": "0.52.0",
27
+ "@vincemakes/kiso-core": "0.54.0",
28
+ "@vincemakes/kiso-evals": "0.54.0",
29
+ "@vincemakes/kiso-runtime": "0.54.0",
30
+ "@vincemakes/kiso-server": "0.54.0",
31
31
  "@types/node": "^26.1.2",
32
32
  "typescript": "^5.7.2",
33
33
  "vitest": "^3.0.0"