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.
Files changed (95) hide show
  1. package/README.md +29 -78
  2. package/dist/client/connection.d.ts +20 -6
  3. package/dist/client/connection.d.ts.map +1 -1
  4. package/dist/client/connection.js +120 -52
  5. package/dist/client/connection.js.map +1 -1
  6. package/dist/client/core.d.ts +3 -2
  7. package/dist/client/core.d.ts.map +1 -1
  8. package/dist/client/core.js +10 -4
  9. package/dist/client/core.js.map +1 -1
  10. package/dist/client/session.d.ts +2 -0
  11. package/dist/client/session.d.ts.map +1 -1
  12. package/dist/client/session.js +97 -17
  13. package/dist/client/session.js.map +1 -1
  14. package/dist/host/adapter.d.ts +1 -1
  15. package/dist/host/adapter.d.ts.map +1 -1
  16. package/dist/host/adapter.js +7 -1
  17. package/dist/host/adapter.js.map +1 -1
  18. package/dist/host/host.d.ts +1 -0
  19. package/dist/host/host.d.ts.map +1 -1
  20. package/dist/host/host.js +45 -5
  21. package/dist/host/host.js.map +1 -1
  22. package/dist/host/http.d.ts +2 -1
  23. package/dist/host/http.d.ts.map +1 -1
  24. package/dist/host/http.js +108 -47
  25. package/dist/host/http.js.map +1 -1
  26. package/dist/host/socket.d.ts +33 -8
  27. package/dist/host/socket.d.ts.map +1 -1
  28. package/dist/host/socket.js +388 -58
  29. package/dist/host/socket.js.map +1 -1
  30. package/dist/host/work.d.ts +7 -0
  31. package/dist/host/work.d.ts.map +1 -0
  32. package/dist/host/work.js +53 -0
  33. package/dist/host/work.js.map +1 -0
  34. package/dist/host-internal.d.ts +42 -4
  35. package/dist/host-internal.d.ts.map +1 -1
  36. package/dist/host-internal.js +2 -1
  37. package/dist/host.d.ts +16 -3
  38. package/dist/host.d.ts.map +1 -1
  39. package/dist/host.js +2 -1
  40. package/dist/statewire.d.ts +21 -11
  41. package/dist/statewire.d.ts.map +1 -1
  42. package/dist/statewire.js +2 -2
  43. package/dist/statewire.js.map +1 -1
  44. package/dist/transports/sse.d.ts +1 -4
  45. package/dist/transports/sse.d.ts.map +1 -1
  46. package/dist/transports/sse.js +47 -56
  47. package/dist/transports/sse.js.map +1 -1
  48. package/dist/transports/testing.js +2 -2
  49. package/dist/transports/testing.js.map +1 -1
  50. package/dist/transports/ws.d.ts +1 -1
  51. package/dist/transports/ws.d.ts.map +1 -1
  52. package/dist/transports/ws.js +54 -41
  53. package/dist/transports/ws.js.map +1 -1
  54. package/dist/types.d.ts +3 -0
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/types.js.map +1 -1
  57. package/dist/wire/documents.d.ts +20 -0
  58. package/dist/wire/documents.d.ts.map +1 -0
  59. package/dist/wire/documents.js +74 -0
  60. package/dist/wire/documents.js.map +1 -0
  61. package/dist/wire/limits.d.ts +10 -0
  62. package/dist/wire/limits.d.ts.map +1 -0
  63. package/dist/wire/limits.js +11 -0
  64. package/dist/wire/limits.js.map +1 -0
  65. package/dist/wire/meta.d.ts.map +1 -1
  66. package/dist/wire/meta.js +1 -1
  67. package/dist/wire/meta.js.map +1 -1
  68. package/dist/wire/ops.d.ts.map +1 -1
  69. package/dist/wire/ops.js +1 -2
  70. package/dist/wire/ops.js.map +1 -1
  71. package/dist/wire/version.d.ts +7 -0
  72. package/dist/wire/version.d.ts.map +1 -0
  73. package/dist/wire/version.js +8 -0
  74. package/dist/wire/version.js.map +1 -0
  75. package/package.json +1 -1
  76. package/src/client/connection.ts +213 -67
  77. package/src/client/core.ts +10 -3
  78. package/src/client/session.ts +106 -30
  79. package/src/host/adapter.ts +10 -2
  80. package/src/host/host.ts +54 -4
  81. package/src/host/http.ts +168 -64
  82. package/src/host/socket.ts +603 -60
  83. package/src/host/work.ts +50 -0
  84. package/src/host-internal.ts +44 -3
  85. package/src/host.ts +18 -2
  86. package/src/statewire.ts +33 -10
  87. package/src/transports/sse.ts +60 -68
  88. package/src/transports/testing.ts +2 -2
  89. package/src/transports/ws.ts +29 -48
  90. package/src/types.ts +1 -0
  91. package/src/wire/documents.ts +109 -0
  92. package/src/wire/limits.ts +6 -0
  93. package/src/wire/meta.ts +6 -1
  94. package/src/wire/ops.ts +1 -2
  95. package/src/wire/version.ts +3 -0
@@ -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 { ConnectionState, StatewireRemoteProps } from "../transports/sse";
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" | "evicted" | "error" | "unauthorized";
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
- const changed = next.status !== c.connection.status;
113
- c.connection = { ...next, reconnect: c.reconnect };
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
- if (changed) c.onChange(c.connection);
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 !== 1 || !isObject(ops[0])) {
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.hasSnapshot = true;
308
+ c.error = undefined;
274
309
  c.liveAt = Date.now();
275
310
  c.authDenials = 0;
276
311
  c.episode = null;
277
- setStatus(c, { status: "live" });
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 transientCauseOf = (
327
- reason: StatewireHost.Fin["reason"],
328
- ): Episode["cause"] => {
329
- switch (reason) {
330
- case "evicted":
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
- setStatus(c, { status: "idle" });
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 = finish.reason === "gone" || (denial && c.authDenials > 0);
358
- if (denial) c.authDenials += 1;
359
- c.ctx.onFinish(terminal ? finish : { ...finish, reason: "error" });
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: transientCauseOf(finish.reason),
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: "gone",
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 !== "live") {
436
+ if (c.connection.status !== "connected") {
389
437
  c.attempt = 0;
390
438
  c.episode = null;
391
- setStatus(c, { status: "live" });
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 = immediate ? 0 : backoff(c.attempt);
458
+ const delay = Math.max(
459
+ immediate ? 0 : backoff(c.attempt),
460
+ c.retryAt - Date.now(),
461
+ );
408
462
  setStatus(c, {
409
- status: "reconnecting",
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 === "gone" || c.connection.status === "idle") {
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 = { cause: "dropped" };
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 useStatewireConn = (props: {
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
- }): Conn => {
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: { status: "connecting", reconnect: () => conn.reconnect() },
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
- if (c.connection.status !== "connecting") {
516
- setStatus(c, { status: "connecting" });
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 c;
676
+ return link;
531
677
  };
532
678
 
533
679
  export const classify = (timeout: AbortSignal) =>
@@ -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 LIVE_CONNECTION: StatewireClient.Connection = {
14
- status: "live",
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 ?? LIVE_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,