statewire 0.13.1 → 0.15.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 +29 -78
- package/dist/client/connection.d.ts +20 -6
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +120 -52
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts +3 -2
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +10 -4
- package/dist/client/core.js.map +1 -1
- package/dist/client/session.d.ts +2 -0
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +97 -17
- package/dist/client/session.js.map +1 -1
- package/dist/host/adapter.d.ts +1 -1
- package/dist/host/adapter.d.ts.map +1 -1
- package/dist/host/adapter.js +7 -1
- package/dist/host/adapter.js.map +1 -1
- package/dist/host/host.d.ts +1 -0
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js +45 -5
- package/dist/host/host.js.map +1 -1
- package/dist/host/http.d.ts +2 -1
- package/dist/host/http.d.ts.map +1 -1
- package/dist/host/http.js +108 -47
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +33 -8
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +388 -58
- package/dist/host/socket.js.map +1 -1
- package/dist/host/work.d.ts +7 -0
- package/dist/host/work.d.ts.map +1 -0
- package/dist/host/work.js +53 -0
- package/dist/host/work.js.map +1 -0
- package/dist/host-internal.d.ts +42 -4
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/host-internal.js +2 -1
- package/dist/host.d.ts +16 -3
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +2 -1
- package/dist/statewire.d.ts +21 -11
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +2 -2
- package/dist/statewire.js.map +1 -1
- package/dist/transports/sse.d.ts +1 -4
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +47 -56
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/testing.js +2 -2
- package/dist/transports/testing.js.map +1 -1
- package/dist/transports/ws.d.ts +1 -1
- package/dist/transports/ws.d.ts.map +1 -1
- package/dist/transports/ws.js +54 -41
- package/dist/transports/ws.js.map +1 -1
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/wire/documents.d.ts +20 -0
- package/dist/wire/documents.d.ts.map +1 -0
- package/dist/wire/documents.js +74 -0
- package/dist/wire/documents.js.map +1 -0
- package/dist/wire/limits.d.ts +10 -0
- package/dist/wire/limits.d.ts.map +1 -0
- package/dist/wire/limits.js +11 -0
- package/dist/wire/limits.js.map +1 -0
- package/dist/wire/meta.d.ts.map +1 -1
- package/dist/wire/meta.js +1 -1
- package/dist/wire/meta.js.map +1 -1
- package/dist/wire/ops.d.ts.map +1 -1
- package/dist/wire/ops.js +1 -2
- package/dist/wire/ops.js.map +1 -1
- package/dist/wire/version.d.ts +7 -0
- package/dist/wire/version.d.ts.map +1 -0
- package/dist/wire/version.js +8 -0
- package/dist/wire/version.js.map +1 -0
- package/package.json +1 -1
- package/src/client/connection.ts +213 -67
- package/src/client/core.ts +10 -3
- package/src/client/session.ts +106 -30
- package/src/host/adapter.ts +10 -2
- package/src/host/host.ts +54 -4
- package/src/host/http.ts +168 -64
- package/src/host/socket.ts +603 -60
- package/src/host/work.ts +50 -0
- package/src/host-internal.ts +44 -3
- package/src/host.ts +18 -2
- package/src/statewire.ts +33 -10
- package/src/transports/sse.ts +60 -68
- package/src/transports/testing.ts +2 -2
- package/src/transports/ws.ts +29 -48
- package/src/types.ts +1 -0
- package/src/wire/documents.ts +109 -0
- package/src/wire/limits.ts +6 -0
- package/src/wire/meta.ts +6 -1
- package/src/wire/ops.ts +1 -2
- package/src/wire/version.ts +3 -0
package/src/client/connection.ts
CHANGED
|
@@ -2,13 +2,20 @@ import { useEffect, useRef, useState } from "react";
|
|
|
2
2
|
import { flushTapSync } from "@assistant-ui/tap";
|
|
3
3
|
import { reviveOps, reviveValue } from "../wire/meta";
|
|
4
4
|
import { backoff, timing } from "./timing";
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
ConnectionState,
|
|
7
|
+
RemoteLink,
|
|
8
|
+
StatewireFrame,
|
|
9
|
+
StatewireRemoteProps,
|
|
10
|
+
StatewireSubmitResult,
|
|
11
|
+
} from "../transports/sse";
|
|
6
12
|
import type { StatewireClient } from "../statewire";
|
|
7
13
|
import type { StatewireHost } from "../host";
|
|
8
14
|
|
|
9
15
|
export type Episode = {
|
|
10
|
-
cause: "dropped" | "
|
|
16
|
+
cause: "dropped" | "reconnect" | "error" | "unauthorized";
|
|
11
17
|
message?: string;
|
|
18
|
+
code?: string;
|
|
12
19
|
};
|
|
13
20
|
|
|
14
21
|
/** The terminal cmd verdict types; "applied" is the sole non-terminal type. */
|
|
@@ -90,11 +97,13 @@ export type AttemptOutcome = "stop" | "reattach" | "ended" | "idle";
|
|
|
90
97
|
export type Conn = {
|
|
91
98
|
connection: StatewireClient.Connection;
|
|
92
99
|
parked: boolean;
|
|
93
|
-
hasSnapshot: boolean;
|
|
94
100
|
lease: string | null;
|
|
95
101
|
attempt: number;
|
|
96
102
|
authDenials: number;
|
|
103
|
+
authStale: boolean;
|
|
97
104
|
liveAt: number | null;
|
|
105
|
+
retryAt: number;
|
|
106
|
+
error: StatewireClient.ConnectionError | undefined;
|
|
98
107
|
episode: Episode | null;
|
|
99
108
|
awaitingHello: boolean;
|
|
100
109
|
held: StatewireCmd[];
|
|
@@ -102,17 +111,46 @@ export type Conn = {
|
|
|
102
111
|
abort: AbortController | null;
|
|
103
112
|
lifetime: AbortController | null;
|
|
104
113
|
ctx: StatewireRemoteProps;
|
|
114
|
+
onError: (error: StatewireClient.ConnectionError) => void;
|
|
105
115
|
onChange: (connection: StatewireClient.Connection) => void;
|
|
106
116
|
attemptOnce: (abort: AbortController) => Promise<AttemptOutcome>;
|
|
107
117
|
notify: (connection: StatewireClient.Connection) => void;
|
|
108
118
|
reconnect: () => void;
|
|
109
119
|
};
|
|
110
120
|
|
|
121
|
+
export const degradedOf = (state: ConnectionState): boolean =>
|
|
122
|
+
(state.status === "connecting" && state.attempt > 0) ||
|
|
123
|
+
(state.status === "disconnected" &&
|
|
124
|
+
state.reason !== "idle" &&
|
|
125
|
+
state.reason !== "deferred");
|
|
126
|
+
|
|
127
|
+
const sameState = (a: ConnectionState, b: ConnectionState): boolean => {
|
|
128
|
+
if (a.status !== b.status) return false;
|
|
129
|
+
if (a.status === "connecting" && b.status === "connecting") {
|
|
130
|
+
return (
|
|
131
|
+
a.attempt === b.attempt &&
|
|
132
|
+
a.cause === b.cause &&
|
|
133
|
+
a.code === b.code &&
|
|
134
|
+
a.nextRetryAt === b.nextRetryAt &&
|
|
135
|
+
a.lastError === b.lastError
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (a.status === "disconnected" && b.status === "disconnected") {
|
|
139
|
+
return a.reason === b.reason;
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
};
|
|
143
|
+
|
|
111
144
|
const setStatus = (c: Conn, next: ConnectionState) => {
|
|
112
|
-
|
|
113
|
-
c.connection = {
|
|
145
|
+
if (sameState(next, c.connection) && c.connection.error === c.error) return;
|
|
146
|
+
c.connection = {
|
|
147
|
+
...next,
|
|
148
|
+
...(c.error === undefined ? {} : { error: c.error }),
|
|
149
|
+
reconnect: c.reconnect,
|
|
150
|
+
degraded: degradedOf(next),
|
|
151
|
+
};
|
|
114
152
|
c.notify(c.connection);
|
|
115
|
-
|
|
153
|
+
c.onChange(c.connection);
|
|
116
154
|
};
|
|
117
155
|
|
|
118
156
|
const protocolError = (message: string): never => {
|
|
@@ -191,6 +229,14 @@ const validateFin = (value: unknown): value is StatewireHost.Fin => {
|
|
|
191
229
|
protocolError("fin.reason must be a string");
|
|
192
230
|
}
|
|
193
231
|
validateOptionalString(value["message"], "fin.message");
|
|
232
|
+
validateOptionalString(value["code"], "fin.code");
|
|
233
|
+
if (
|
|
234
|
+
value["status"] !== undefined &&
|
|
235
|
+
!isNonnegativeSafeInteger(value["status"])
|
|
236
|
+
)
|
|
237
|
+
protocolError("fin.status must be a nonnegative safe integer");
|
|
238
|
+
if (value["retry"] !== undefined && !isNonnegativeSafeInteger(value["retry"]))
|
|
239
|
+
protocolError("fin.retry must be a nonnegative safe integer");
|
|
194
240
|
return true;
|
|
195
241
|
};
|
|
196
242
|
|
|
@@ -237,18 +283,7 @@ const cmdsOf = (env: StatewireEnvelope): StatewireCmd[] => {
|
|
|
237
283
|
const snapshotOf = (env: StatewireEnvelope): unknown[] => {
|
|
238
284
|
const ops = env.ops;
|
|
239
285
|
if (ops === undefined) return protocolError("hello missing snapshot");
|
|
240
|
-
if (ops.length
|
|
241
|
-
return protocolError("hello snapshot must be one root replace op");
|
|
242
|
-
}
|
|
243
|
-
const op = ops[0];
|
|
244
|
-
if (
|
|
245
|
-
op["op"] !== "replace" ||
|
|
246
|
-
!Array.isArray(op["path"]) ||
|
|
247
|
-
op["path"].length !== 0 ||
|
|
248
|
-
!Object.hasOwn(op, "value")
|
|
249
|
-
) {
|
|
250
|
-
return protocolError("hello snapshot must be one root replace op");
|
|
251
|
-
}
|
|
286
|
+
if (ops.length === 0) return protocolError("hello missing snapshot");
|
|
252
287
|
return reviveOps(ops);
|
|
253
288
|
};
|
|
254
289
|
|
|
@@ -270,11 +305,11 @@ const handleHello = (c: Conn, env: StatewireEnvelope) => {
|
|
|
270
305
|
});
|
|
271
306
|
c.ctx.onIdle(syn.idle ?? false);
|
|
272
307
|
c.awaitingHello = false;
|
|
273
|
-
c.
|
|
308
|
+
c.error = undefined;
|
|
274
309
|
c.liveAt = Date.now();
|
|
275
310
|
c.authDenials = 0;
|
|
276
311
|
c.episode = null;
|
|
277
|
-
setStatus(c, { status: "
|
|
312
|
+
setStatus(c, { status: "connected" });
|
|
278
313
|
};
|
|
279
314
|
|
|
280
315
|
let warnedUnknownSynFact = false;
|
|
@@ -323,50 +358,63 @@ const deliverEnvelope = (c: Conn, env: StatewireEnvelope) => {
|
|
|
323
358
|
if (typeof env.idle === "boolean") c.ctx.onIdle(env.idle);
|
|
324
359
|
};
|
|
325
360
|
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
return "evicted";
|
|
332
|
-
case "error":
|
|
333
|
-
return "error";
|
|
334
|
-
case "unauthorized":
|
|
335
|
-
return "unauthorized";
|
|
336
|
-
default:
|
|
337
|
-
return "error";
|
|
338
|
-
}
|
|
339
|
-
};
|
|
340
|
-
|
|
341
|
-
const settleFin = (c: Conn, fin: StatewireHost.Fin): AttemptOutcome => {
|
|
342
|
-
let finish = fin;
|
|
343
|
-
if (finish.meta !== undefined) {
|
|
344
|
-
const { meta, ...rest } = finish;
|
|
345
|
-
finish = { ...rest, payload: reviveValue(rest.payload, meta) };
|
|
346
|
-
}
|
|
361
|
+
export const settleFin = (c: Conn, fin: StatewireHost.Fin): AttemptOutcome => {
|
|
362
|
+
const finish =
|
|
363
|
+
fin.meta === undefined
|
|
364
|
+
? fin
|
|
365
|
+
: { ...fin, payload: reviveValue(fin.payload, fin.meta) };
|
|
347
366
|
releaseHeld(c);
|
|
367
|
+
c.retryAt = Date.now() + (finish.retry ?? 0);
|
|
348
368
|
if (finish.reason === "idle") {
|
|
349
369
|
c.ctx.onIdle(true);
|
|
350
370
|
c.attempt = 0;
|
|
351
371
|
c.episode = null;
|
|
352
|
-
|
|
372
|
+
c.error = undefined;
|
|
373
|
+
setStatus(c, { status: "disconnected", reason: "idle" });
|
|
353
374
|
return "idle";
|
|
354
375
|
}
|
|
355
|
-
// An auth denial gets one immediate redial; a consecutive repeat parks as gone.
|
|
356
376
|
const denial = finish.reason === "unauthorized";
|
|
357
|
-
const terminal =
|
|
358
|
-
|
|
359
|
-
|
|
377
|
+
const terminal =
|
|
378
|
+
finish.reason === "gone" ||
|
|
379
|
+
finish.reason === "forbidden" ||
|
|
380
|
+
finish.reason === "protocol-error" ||
|
|
381
|
+
(denial && c.authDenials > 0);
|
|
382
|
+
if (denial) {
|
|
383
|
+
c.authDenials += 1;
|
|
384
|
+
c.authStale = true;
|
|
385
|
+
}
|
|
386
|
+
const report =
|
|
387
|
+
finish.reason !== "reconnect" &&
|
|
388
|
+
finish.reason !== "gone" &&
|
|
389
|
+
(!denial || terminal);
|
|
390
|
+
c.error = report
|
|
391
|
+
? Object.assign(new Error(finish.message ?? finish.reason), { fin: finish })
|
|
392
|
+
: undefined;
|
|
393
|
+
if (c.error) c.onError(c.error);
|
|
360
394
|
if (!terminal) {
|
|
361
395
|
c.episode = {
|
|
362
|
-
cause:
|
|
396
|
+
cause:
|
|
397
|
+
finish.reason === "reconnect"
|
|
398
|
+
? "reconnect"
|
|
399
|
+
: denial
|
|
400
|
+
? "unauthorized"
|
|
401
|
+
: "error",
|
|
363
402
|
...(finish.message !== undefined && { message: finish.message }),
|
|
403
|
+
...(finish.code !== undefined && { code: finish.code }),
|
|
364
404
|
};
|
|
365
405
|
if (denial) openGate(c.gate);
|
|
366
406
|
return "reattach";
|
|
367
407
|
}
|
|
408
|
+
c.ctx.onFinish(finish);
|
|
368
409
|
setStatus(c, {
|
|
369
|
-
status: "
|
|
410
|
+
status: "disconnected",
|
|
411
|
+
reason: denial
|
|
412
|
+
? "unauthorized"
|
|
413
|
+
: finish.reason === "forbidden"
|
|
414
|
+
? "forbidden"
|
|
415
|
+
: finish.reason === "protocol-error"
|
|
416
|
+
? "protocol-error"
|
|
417
|
+
: "gone",
|
|
370
418
|
...(finish.message !== undefined && { message: finish.message }),
|
|
371
419
|
...(finish.payload !== undefined && { payload: finish.payload }),
|
|
372
420
|
});
|
|
@@ -385,14 +433,17 @@ export const handleEnvelope = (
|
|
|
385
433
|
}
|
|
386
434
|
deliverEnvelope(c, env);
|
|
387
435
|
if (env.fin !== undefined) return settleFin(c, env.fin);
|
|
388
|
-
if (c.connection.status !== "
|
|
436
|
+
if (c.connection.status !== "connected") {
|
|
389
437
|
c.attempt = 0;
|
|
390
438
|
c.episode = null;
|
|
391
|
-
setStatus(c, { status: "
|
|
439
|
+
setStatus(c, { status: "connected" });
|
|
392
440
|
}
|
|
393
441
|
return null;
|
|
394
442
|
};
|
|
395
443
|
|
|
444
|
+
const dialing = (c: Conn) =>
|
|
445
|
+
setStatus(c, { status: "connecting", attempt: c.attempt });
|
|
446
|
+
|
|
396
447
|
const scheduleRetry = (
|
|
397
448
|
c: Conn,
|
|
398
449
|
failure: string | undefined,
|
|
@@ -404,11 +455,14 @@ const scheduleRetry = (
|
|
|
404
455
|
c.liveAt = null;
|
|
405
456
|
c.attempt += 1;
|
|
406
457
|
c.episode ??= { cause: "dropped" };
|
|
407
|
-
const delay =
|
|
458
|
+
const delay = Math.max(
|
|
459
|
+
immediate ? 0 : backoff(c.attempt),
|
|
460
|
+
c.retryAt - Date.now(),
|
|
461
|
+
);
|
|
408
462
|
setStatus(c, {
|
|
409
|
-
status: "
|
|
410
|
-
...c.episode,
|
|
463
|
+
status: "connecting",
|
|
411
464
|
attempt: c.attempt,
|
|
465
|
+
...c.episode,
|
|
412
466
|
nextRetryAt: Date.now() + delay,
|
|
413
467
|
...(failure !== undefined && { lastError: failure }),
|
|
414
468
|
});
|
|
@@ -417,13 +471,12 @@ const scheduleRetry = (
|
|
|
417
471
|
|
|
418
472
|
const reconnectConn = (c: Conn) => {
|
|
419
473
|
if (!c.lifetime) return;
|
|
420
|
-
if (c.connection.status === "
|
|
474
|
+
if (c.connection.status === "disconnected") {
|
|
475
|
+
// A reconnect out of a park starts a fresh episode: first dial, attempt 0.
|
|
421
476
|
c.attempt = 0;
|
|
422
477
|
c.authDenials = 0;
|
|
423
|
-
c.episode =
|
|
424
|
-
flushTapSync(() =>
|
|
425
|
-
scheduleRetry(c, undefined, true);
|
|
426
|
-
});
|
|
478
|
+
c.episode = null;
|
|
479
|
+
flushTapSync(() => dialing(c));
|
|
427
480
|
}
|
|
428
481
|
openGate(c.gate);
|
|
429
482
|
c.abort?.abort();
|
|
@@ -437,6 +490,12 @@ const run = async (c: Conn, lifetime: AbortController, gate: Gate) => {
|
|
|
437
490
|
c.parked = false;
|
|
438
491
|
}
|
|
439
492
|
while (alive()) {
|
|
493
|
+
if (c.retryAt > Date.now()) {
|
|
494
|
+
await waitGate(gate, c.retryAt - Date.now());
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
// The dial is in flight: attempt alone, no backoff fields.
|
|
498
|
+
flushTapSync(() => dialing(c));
|
|
440
499
|
const abort = new AbortController();
|
|
441
500
|
c.abort = abort;
|
|
442
501
|
c.awaitingHello = true;
|
|
@@ -447,6 +506,27 @@ const run = async (c: Conn, lifetime: AbortController, gate: Gate) => {
|
|
|
447
506
|
outcome = await c.attemptOnce(abort);
|
|
448
507
|
} catch (error) {
|
|
449
508
|
if (!abort.signal.aborted) {
|
|
509
|
+
if (
|
|
510
|
+
error instanceof SyntaxError ||
|
|
511
|
+
(error instanceof Error &&
|
|
512
|
+
/^(statewire: protocol error:|Statewire cannot apply)/.test(
|
|
513
|
+
error.message,
|
|
514
|
+
))
|
|
515
|
+
) {
|
|
516
|
+
outcome = flushTapSync(() =>
|
|
517
|
+
settleFin(c, { reason: "protocol-error", message: error.message }),
|
|
518
|
+
);
|
|
519
|
+
} else if (c.authStale) {
|
|
520
|
+
outcome = flushTapSync(() =>
|
|
521
|
+
settleFin(c, {
|
|
522
|
+
reason: "unauthorized",
|
|
523
|
+
message:
|
|
524
|
+
error instanceof Error
|
|
525
|
+
? error.message
|
|
526
|
+
: "credential refresh failed",
|
|
527
|
+
}),
|
|
528
|
+
);
|
|
529
|
+
}
|
|
450
530
|
failure =
|
|
451
531
|
error instanceof Error && error.message ? error.message : "network";
|
|
452
532
|
}
|
|
@@ -458,6 +538,11 @@ const run = async (c: Conn, lifetime: AbortController, gate: Gate) => {
|
|
|
458
538
|
c.ctx.onDetach();
|
|
459
539
|
});
|
|
460
540
|
if (!alive()) return;
|
|
541
|
+
if (
|
|
542
|
+
c.connection.status === "disconnected" &&
|
|
543
|
+
!["deferred", "idle"].includes(c.connection.reason)
|
|
544
|
+
)
|
|
545
|
+
outcome = "stop";
|
|
461
546
|
if (outcome === "stop" || outcome === "idle") {
|
|
462
547
|
await waitGate(gate);
|
|
463
548
|
continue;
|
|
@@ -469,24 +554,63 @@ const run = async (c: Conn, lifetime: AbortController, gate: Gate) => {
|
|
|
469
554
|
}
|
|
470
555
|
};
|
|
471
556
|
|
|
472
|
-
export const
|
|
557
|
+
export const unsupportedVersion = (c: Conn, version: string): AttemptOutcome =>
|
|
558
|
+
flushTapSync(() =>
|
|
559
|
+
handleEnvelope(c, {
|
|
560
|
+
fin: {
|
|
561
|
+
reason: "protocol-error",
|
|
562
|
+
message: `unsupported protocol version: ${version || '""'}`,
|
|
563
|
+
},
|
|
564
|
+
}),
|
|
565
|
+
) ?? "stop";
|
|
566
|
+
|
|
567
|
+
export const resolveHeaders = async (
|
|
568
|
+
headers: StatewireClient.HeadersOption | undefined,
|
|
569
|
+
staleAuth: boolean,
|
|
570
|
+
): Promise<Headers> =>
|
|
571
|
+
new Headers(
|
|
572
|
+
typeof headers === "function" ? await headers({ staleAuth }) : headers,
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
// The stale flag clears only once the resolver returns: a throwing refresh keeps it for the next attempt.
|
|
576
|
+
export const attachFrame = async (
|
|
577
|
+
c: Conn,
|
|
578
|
+
headers: StatewireClient.HeadersOption | undefined,
|
|
579
|
+
) => {
|
|
580
|
+
const resolved = await resolveHeaders(headers, c.authStale);
|
|
581
|
+
c.authStale = false;
|
|
582
|
+
const lanes = flushTapSync(() => c.ctx.takeAttachLanes());
|
|
583
|
+
return {
|
|
584
|
+
headers: resolved,
|
|
585
|
+
body: JSON.stringify({
|
|
586
|
+
headers: Object.fromEntries(resolved),
|
|
587
|
+
...(lanes ?? {}),
|
|
588
|
+
}),
|
|
589
|
+
};
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
export const useLink = (props: {
|
|
473
593
|
url: string;
|
|
474
594
|
isNew?: boolean | undefined;
|
|
475
595
|
onChange?: ((connection: StatewireClient.Connection) => void) | undefined;
|
|
596
|
+
onError?: ((error: StatewireClient.ConnectionError) => void) | undefined;
|
|
476
597
|
ctx: StatewireRemoteProps;
|
|
477
598
|
attemptOnce: (c: Conn, abort: AbortController) => Promise<AttemptOutcome>;
|
|
478
|
-
|
|
599
|
+
send: (c: Conn, frame: StatewireFrame) => Promise<StatewireSubmitResult>;
|
|
600
|
+
}): RemoteLink => {
|
|
479
601
|
const latest = useRef(props);
|
|
480
602
|
latest.current = props;
|
|
481
603
|
const [, setConnection] = useState<StatewireClient.Connection | null>(null);
|
|
482
604
|
const [c] = useState((): Conn => {
|
|
483
605
|
const conn: Conn = {
|
|
484
606
|
parked: props.isNew ?? false,
|
|
485
|
-
hasSnapshot: false,
|
|
486
607
|
lease: null,
|
|
487
608
|
attempt: 0,
|
|
488
609
|
authDenials: 0,
|
|
610
|
+
authStale: false,
|
|
489
611
|
liveAt: null,
|
|
612
|
+
retryAt: 0,
|
|
613
|
+
error: undefined,
|
|
490
614
|
episode: null,
|
|
491
615
|
awaitingHello: true,
|
|
492
616
|
held: [],
|
|
@@ -496,25 +620,47 @@ export const useStatewireConn = (props: {
|
|
|
496
620
|
get ctx() {
|
|
497
621
|
return latest.current.ctx;
|
|
498
622
|
},
|
|
623
|
+
onError: (error) => latest.current.onError?.(error),
|
|
499
624
|
onChange: (connection) => latest.current.onChange?.(connection),
|
|
500
625
|
attemptOnce: (abort) => latest.current.attemptOnce(conn, abort),
|
|
501
626
|
notify: setConnection,
|
|
502
627
|
reconnect: () => reconnectConn(conn),
|
|
503
|
-
connection: {
|
|
628
|
+
connection: {
|
|
629
|
+
...((props.isNew ?? false)
|
|
630
|
+
? { status: "disconnected", reason: "deferred" }
|
|
631
|
+
: { status: "connecting", attempt: 0 }),
|
|
632
|
+
degraded: false,
|
|
633
|
+
reconnect: () => conn.reconnect(),
|
|
634
|
+
},
|
|
504
635
|
};
|
|
505
636
|
return conn;
|
|
506
637
|
});
|
|
638
|
+
const [link] = useState(
|
|
639
|
+
(): RemoteLink => ({
|
|
640
|
+
get connection() {
|
|
641
|
+
return c.connection;
|
|
642
|
+
},
|
|
643
|
+
get parked() {
|
|
644
|
+
return c.parked;
|
|
645
|
+
},
|
|
646
|
+
send: (frame) => latest.current.send(c, frame),
|
|
647
|
+
}),
|
|
648
|
+
);
|
|
507
649
|
|
|
508
650
|
useEffect(() => {
|
|
509
|
-
c.hasSnapshot = false;
|
|
510
651
|
c.lease = null;
|
|
511
652
|
c.attempt = 0;
|
|
512
653
|
c.authDenials = 0;
|
|
654
|
+
c.authStale = false;
|
|
513
655
|
c.liveAt = null;
|
|
656
|
+
c.retryAt = 0;
|
|
514
657
|
c.episode = null;
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
658
|
+
setStatus(
|
|
659
|
+
c,
|
|
660
|
+
c.parked
|
|
661
|
+
? { status: "disconnected", reason: "deferred" }
|
|
662
|
+
: { status: "connecting", attempt: 0 },
|
|
663
|
+
);
|
|
518
664
|
const lifetime = new AbortController();
|
|
519
665
|
c.lifetime = lifetime;
|
|
520
666
|
c.gate = newGate();
|
|
@@ -527,7 +673,7 @@ export const useStatewireConn = (props: {
|
|
|
527
673
|
};
|
|
528
674
|
}, [c, props.url]);
|
|
529
675
|
|
|
530
|
-
return
|
|
676
|
+
return link;
|
|
531
677
|
};
|
|
532
678
|
|
|
533
679
|
export const classify = (timeout: AbortSignal) =>
|
package/src/client/core.ts
CHANGED
|
@@ -10,8 +10,9 @@ import { StatewireSendError } from "../types";
|
|
|
10
10
|
import type { Statewire } from "../types";
|
|
11
11
|
import type { StatewireClient } from "../statewire";
|
|
12
12
|
|
|
13
|
-
export const
|
|
14
|
-
status: "
|
|
13
|
+
export const CONNECTED_CONNECTION: StatewireClient.Connection = {
|
|
14
|
+
status: "connected",
|
|
15
|
+
degraded: false,
|
|
15
16
|
reconnect: () => {},
|
|
16
17
|
};
|
|
17
18
|
|
|
@@ -41,6 +42,7 @@ type Core = {
|
|
|
41
42
|
notifiedPublished: unknown;
|
|
42
43
|
notifiedOverlayPaths: readonly StatewirePath[];
|
|
43
44
|
notifiedConnection: StatewireClient.Connection | null;
|
|
45
|
+
notifiedError: StatewireClient.ConnectionError | undefined;
|
|
44
46
|
notifiedBusy: boolean;
|
|
45
47
|
notifiedFailed: readonly StatewireClient.FailedCommand[];
|
|
46
48
|
stale: boolean;
|
|
@@ -160,7 +162,7 @@ const recompute = (c: Core) => {
|
|
|
160
162
|
};
|
|
161
163
|
|
|
162
164
|
const getConnection = (c: Core): StatewireClient.Connection =>
|
|
163
|
-
c.value?.connection ??
|
|
165
|
+
c.value?.connection ?? CONNECTED_CONNECTION;
|
|
164
166
|
|
|
165
167
|
const flush = (c: Core, scheduled = false) => {
|
|
166
168
|
if (!c.value) return;
|
|
@@ -198,6 +200,10 @@ const flush = (c: Core, scheduled = false) => {
|
|
|
198
200
|
const connectionChanged = connection !== c.notifiedConnection;
|
|
199
201
|
c.notifiedConnection = connection;
|
|
200
202
|
if (connectionChanged) options.onConnectionChange?.(connection);
|
|
203
|
+
if (connection.error !== c.notifiedError) {
|
|
204
|
+
c.notifiedError = connection.error;
|
|
205
|
+
if (connection.error) options.onError?.(connection.error);
|
|
206
|
+
}
|
|
201
207
|
const busy = c.getBusy();
|
|
202
208
|
const busyChanged = busy !== c.notifiedBusy;
|
|
203
209
|
c.notifiedBusy = busy;
|
|
@@ -296,6 +302,7 @@ export const useStatewireCore = (
|
|
|
296
302
|
notifiedPublished: undefined,
|
|
297
303
|
notifiedOverlayPaths: [],
|
|
298
304
|
notifiedConnection: null,
|
|
305
|
+
notifiedError: undefined,
|
|
299
306
|
notifiedBusy: true,
|
|
300
307
|
notifiedFailed: NO_FAILED,
|
|
301
308
|
stale: false,
|