statewire 0.14.0 → 0.16.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 (100) hide show
  1. package/README.md +29 -80
  2. package/dist/client/connection.d.ts +19 -6
  3. package/dist/client/connection.d.ts.map +1 -1
  4. package/dist/client/connection.js +79 -40
  5. package/dist/client/connection.js.map +1 -1
  6. package/dist/client/core.d.ts +1 -0
  7. package/dist/client/core.d.ts.map +1 -1
  8. package/dist/client/core.js +5 -0
  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 +98 -19
  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 +1 -1
  23. package/dist/host/http.d.ts.map +1 -1
  24. package/dist/host/http.js +75 -47
  25. package/dist/host/http.js.map +1 -1
  26. package/dist/host/socket.d.ts +23 -7
  27. package/dist/host/socket.d.ts.map +1 -1
  28. package/dist/host/socket.js +275 -74
  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 +35 -5
  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/index.d.ts +3 -3
  41. package/dist/index.js +3 -3
  42. package/dist/statewire.d.ts +13 -4
  43. package/dist/statewire.d.ts.map +1 -1
  44. package/dist/statewire.js.map +1 -1
  45. package/dist/transports/sse.d.ts +2 -5
  46. package/dist/transports/sse.d.ts.map +1 -1
  47. package/dist/transports/sse.js +50 -59
  48. package/dist/transports/sse.js.map +1 -1
  49. package/dist/transports/testing.js +4 -4
  50. package/dist/transports/testing.js.map +1 -1
  51. package/dist/transports/ws.d.ts +3 -3
  52. package/dist/transports/ws.d.ts.map +1 -1
  53. package/dist/transports/ws.js +57 -44
  54. package/dist/transports/ws.js.map +1 -1
  55. package/dist/types.d.ts +3 -0
  56. package/dist/types.d.ts.map +1 -1
  57. package/dist/types.js.map +1 -1
  58. package/dist/util/randomId.js +1 -1
  59. package/dist/util/randomId.js.map +1 -1
  60. package/dist/wire/documents.d.ts +20 -0
  61. package/dist/wire/documents.d.ts.map +1 -0
  62. package/dist/wire/documents.js +74 -0
  63. package/dist/wire/documents.js.map +1 -0
  64. package/dist/wire/limits.d.ts +10 -0
  65. package/dist/wire/limits.d.ts.map +1 -0
  66. package/dist/wire/limits.js +11 -0
  67. package/dist/wire/limits.js.map +1 -0
  68. package/dist/wire/meta.d.ts.map +1 -1
  69. package/dist/wire/meta.js +1 -1
  70. package/dist/wire/meta.js.map +1 -1
  71. package/dist/wire/ops.d.ts.map +1 -1
  72. package/dist/wire/ops.js +1 -2
  73. package/dist/wire/ops.js.map +1 -1
  74. package/dist/wire/version.d.ts +7 -0
  75. package/dist/wire/version.d.ts.map +1 -0
  76. package/dist/wire/version.js +8 -0
  77. package/dist/wire/version.js.map +1 -0
  78. package/package.json +1 -1
  79. package/src/client/connection.ts +159 -52
  80. package/src/client/core.ts +6 -0
  81. package/src/client/session.ts +105 -32
  82. package/src/host/adapter.ts +10 -2
  83. package/src/host/host.ts +54 -4
  84. package/src/host/http.ts +132 -66
  85. package/src/host/socket.ts +410 -81
  86. package/src/host/work.ts +50 -0
  87. package/src/host-internal.ts +32 -4
  88. package/src/host.ts +18 -2
  89. package/src/index.ts +2 -2
  90. package/src/statewire.ts +18 -4
  91. package/src/transports/sse.ts +70 -78
  92. package/src/transports/testing.ts +4 -4
  93. package/src/transports/ws.ts +36 -55
  94. package/src/types.ts +1 -0
  95. package/src/util/randomId.ts +3 -3
  96. package/src/wire/documents.ts +109 -0
  97. package/src/wire/limits.ts +6 -0
  98. package/src/wire/meta.ts +6 -1
  99. package/src/wire/ops.ts +1 -2
  100. 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,6 +111,7 @@ 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;
@@ -110,7 +120,9 @@ export type Conn = {
110
120
 
111
121
  export const degradedOf = (state: ConnectionState): boolean =>
112
122
  (state.status === "connecting" && state.attempt > 0) ||
113
- (state.status === "disconnected" && state.reason === "gone");
123
+ (state.status === "disconnected" &&
124
+ state.reason !== "idle" &&
125
+ state.reason !== "deferred");
114
126
 
115
127
  const sameState = (a: ConnectionState, b: ConnectionState): boolean => {
116
128
  if (a.status !== b.status) return false;
@@ -118,6 +130,7 @@ const sameState = (a: ConnectionState, b: ConnectionState): boolean => {
118
130
  return (
119
131
  a.attempt === b.attempt &&
120
132
  a.cause === b.cause &&
133
+ a.code === b.code &&
121
134
  a.nextRetryAt === b.nextRetryAt &&
122
135
  a.lastError === b.lastError
123
136
  );
@@ -129,9 +142,10 @@ const sameState = (a: ConnectionState, b: ConnectionState): boolean => {
129
142
  };
130
143
 
131
144
  const setStatus = (c: Conn, next: ConnectionState) => {
132
- if (sameState(next, c.connection)) return;
145
+ if (sameState(next, c.connection) && c.connection.error === c.error) return;
133
146
  c.connection = {
134
147
  ...next,
148
+ ...(c.error === undefined ? {} : { error: c.error }),
135
149
  reconnect: c.reconnect,
136
150
  degraded: degradedOf(next),
137
151
  };
@@ -215,6 +229,14 @@ const validateFin = (value: unknown): value is StatewireHost.Fin => {
215
229
  protocolError("fin.reason must be a string");
216
230
  }
217
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");
218
240
  return true;
219
241
  };
220
242
 
@@ -261,18 +283,7 @@ const cmdsOf = (env: StatewireEnvelope): StatewireCmd[] => {
261
283
  const snapshotOf = (env: StatewireEnvelope): unknown[] => {
262
284
  const ops = env.ops;
263
285
  if (ops === undefined) return protocolError("hello missing snapshot");
264
- if (ops.length !== 1 || !isObject(ops[0])) {
265
- return protocolError("hello snapshot must be one root replace op");
266
- }
267
- const op = ops[0];
268
- if (
269
- op["op"] !== "replace" ||
270
- !Array.isArray(op["path"]) ||
271
- op["path"].length !== 0 ||
272
- !Object.hasOwn(op, "value")
273
- ) {
274
- return protocolError("hello snapshot must be one root replace op");
275
- }
286
+ if (ops.length === 0) return protocolError("hello missing snapshot");
276
287
  return reviveOps(ops);
277
288
  };
278
289
 
@@ -294,7 +305,7 @@ const handleHello = (c: Conn, env: StatewireEnvelope) => {
294
305
  });
295
306
  c.ctx.onIdle(syn.idle ?? false);
296
307
  c.awaitingHello = false;
297
- c.hasSnapshot = true;
308
+ c.error = undefined;
298
309
  c.liveAt = Date.now();
299
310
  c.authDenials = 0;
300
311
  c.episode = null;
@@ -347,51 +358,63 @@ const deliverEnvelope = (c: Conn, env: StatewireEnvelope) => {
347
358
  if (typeof env.idle === "boolean") c.ctx.onIdle(env.idle);
348
359
  };
349
360
 
350
- const transientCauseOf = (
351
- reason: StatewireHost.Fin["reason"],
352
- ): Episode["cause"] => {
353
- switch (reason) {
354
- case "evicted":
355
- return "evicted";
356
- case "error":
357
- return "error";
358
- case "unauthorized":
359
- return "unauthorized";
360
- default:
361
- return "error";
362
- }
363
- };
364
-
365
- const settleFin = (c: Conn, fin: StatewireHost.Fin): AttemptOutcome => {
366
- let finish = fin;
367
- if (finish.meta !== undefined) {
368
- const { meta, ...rest } = finish;
369
- finish = { ...rest, payload: reviveValue(rest.payload, meta) };
370
- }
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) };
371
366
  releaseHeld(c);
367
+ c.retryAt = Date.now() + (finish.retry ?? 0);
372
368
  if (finish.reason === "idle") {
373
369
  c.ctx.onIdle(true);
374
370
  c.attempt = 0;
375
371
  c.episode = null;
372
+ c.error = undefined;
376
373
  setStatus(c, { status: "disconnected", reason: "idle" });
377
374
  return "idle";
378
375
  }
379
- // An auth denial gets one immediate redial; a consecutive repeat parks as gone.
380
376
  const denial = finish.reason === "unauthorized";
381
- const terminal = finish.reason === "gone" || (denial && c.authDenials > 0);
382
- if (denial) c.authDenials += 1;
383
- 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);
384
394
  if (!terminal) {
385
395
  c.episode = {
386
- cause: transientCauseOf(finish.reason),
396
+ cause:
397
+ finish.reason === "reconnect"
398
+ ? "reconnect"
399
+ : denial
400
+ ? "unauthorized"
401
+ : "error",
387
402
  ...(finish.message !== undefined && { message: finish.message }),
403
+ ...(finish.code !== undefined && { code: finish.code }),
388
404
  };
389
405
  if (denial) openGate(c.gate);
390
406
  return "reattach";
391
407
  }
408
+ c.ctx.onFinish(finish);
392
409
  setStatus(c, {
393
410
  status: "disconnected",
394
- reason: "gone",
411
+ reason: denial
412
+ ? "unauthorized"
413
+ : finish.reason === "forbidden"
414
+ ? "forbidden"
415
+ : finish.reason === "protocol-error"
416
+ ? "protocol-error"
417
+ : "gone",
395
418
  ...(finish.message !== undefined && { message: finish.message }),
396
419
  ...(finish.payload !== undefined && { payload: finish.payload }),
397
420
  });
@@ -432,8 +455,10 @@ const scheduleRetry = (
432
455
  c.liveAt = null;
433
456
  c.attempt += 1;
434
457
  c.episode ??= { cause: "dropped" };
435
- if (immediate) return 0;
436
- const delay = backoff(c.attempt);
458
+ const delay = Math.max(
459
+ immediate ? 0 : backoff(c.attempt),
460
+ c.retryAt - Date.now(),
461
+ );
437
462
  setStatus(c, {
438
463
  status: "connecting",
439
464
  attempt: c.attempt,
@@ -465,6 +490,10 @@ const run = async (c: Conn, lifetime: AbortController, gate: Gate) => {
465
490
  c.parked = false;
466
491
  }
467
492
  while (alive()) {
493
+ if (c.retryAt > Date.now()) {
494
+ await waitGate(gate, c.retryAt - Date.now());
495
+ continue;
496
+ }
468
497
  // The dial is in flight: attempt alone, no backoff fields.
469
498
  flushTapSync(() => dialing(c));
470
499
  const abort = new AbortController();
@@ -477,6 +506,27 @@ const run = async (c: Conn, lifetime: AbortController, gate: Gate) => {
477
506
  outcome = await c.attemptOnce(abort);
478
507
  } catch (error) {
479
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
+ }
480
530
  failure =
481
531
  error instanceof Error && error.message ? error.message : "network";
482
532
  }
@@ -488,6 +538,11 @@ const run = async (c: Conn, lifetime: AbortController, gate: Gate) => {
488
538
  c.ctx.onDetach();
489
539
  });
490
540
  if (!alive()) return;
541
+ if (
542
+ c.connection.status === "disconnected" &&
543
+ !["deferred", "idle"].includes(c.connection.reason)
544
+ )
545
+ outcome = "stop";
491
546
  if (outcome === "stop" || outcome === "idle") {
492
547
  await waitGate(gate);
493
548
  continue;
@@ -499,24 +554,63 @@ const run = async (c: Conn, lifetime: AbortController, gate: Gate) => {
499
554
  }
500
555
  };
501
556
 
502
- 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: {
503
593
  url: string;
504
594
  isNew?: boolean | undefined;
505
595
  onChange?: ((connection: StatewireClient.Connection) => void) | undefined;
596
+ onError?: ((error: StatewireClient.ConnectionError) => void) | undefined;
506
597
  ctx: StatewireRemoteProps;
507
598
  attemptOnce: (c: Conn, abort: AbortController) => Promise<AttemptOutcome>;
508
- }): Conn => {
599
+ send: (c: Conn, frame: StatewireFrame) => Promise<StatewireSubmitResult>;
600
+ }): RemoteLink => {
509
601
  const latest = useRef(props);
510
602
  latest.current = props;
511
603
  const [, setConnection] = useState<StatewireClient.Connection | null>(null);
512
604
  const [c] = useState((): Conn => {
513
605
  const conn: Conn = {
514
606
  parked: props.isNew ?? false,
515
- hasSnapshot: false,
516
607
  lease: null,
517
608
  attempt: 0,
518
609
  authDenials: 0,
610
+ authStale: false,
519
611
  liveAt: null,
612
+ retryAt: 0,
613
+ error: undefined,
520
614
  episode: null,
521
615
  awaitingHello: true,
522
616
  held: [],
@@ -526,6 +620,7 @@ export const useStatewireConn = (props: {
526
620
  get ctx() {
527
621
  return latest.current.ctx;
528
622
  },
623
+ onError: (error) => latest.current.onError?.(error),
529
624
  onChange: (connection) => latest.current.onChange?.(connection),
530
625
  attemptOnce: (abort) => latest.current.attemptOnce(conn, abort),
531
626
  notify: setConnection,
@@ -540,13 +635,25 @@ export const useStatewireConn = (props: {
540
635
  };
541
636
  return conn;
542
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
+ );
543
649
 
544
650
  useEffect(() => {
545
- c.hasSnapshot = false;
546
651
  c.lease = null;
547
652
  c.attempt = 0;
548
653
  c.authDenials = 0;
654
+ c.authStale = false;
549
655
  c.liveAt = null;
656
+ c.retryAt = 0;
550
657
  c.episode = null;
551
658
  setStatus(
552
659
  c,
@@ -566,7 +673,7 @@ export const useStatewireConn = (props: {
566
673
  };
567
674
  }, [c, props.url]);
568
675
 
569
- return c;
676
+ return link;
570
677
  };
571
678
 
572
679
  export const classify = (timeout: AbortSignal) =>
@@ -42,6 +42,7 @@ type Core = {
42
42
  notifiedPublished: unknown;
43
43
  notifiedOverlayPaths: readonly StatewirePath[];
44
44
  notifiedConnection: StatewireClient.Connection | null;
45
+ notifiedError: StatewireClient.ConnectionError | undefined;
45
46
  notifiedBusy: boolean;
46
47
  notifiedFailed: readonly StatewireClient.FailedCommand[];
47
48
  stale: boolean;
@@ -199,6 +200,10 @@ const flush = (c: Core, scheduled = false) => {
199
200
  const connectionChanged = connection !== c.notifiedConnection;
200
201
  c.notifiedConnection = connection;
201
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
+ }
202
207
  const busy = c.getBusy();
203
208
  const busyChanged = busy !== c.notifiedBusy;
204
209
  c.notifiedBusy = busy;
@@ -297,6 +302,7 @@ export const useStatewireCore = (
297
302
  notifiedPublished: undefined,
298
303
  notifiedOverlayPaths: [],
299
304
  notifiedConnection: null,
305
+ notifiedError: undefined,
300
306
  notifiedBusy: true,
301
307
  notifiedFailed: NO_FAILED,
302
308
  stale: false,
@@ -1,6 +1,9 @@
1
+ import { applyDocumentOps } from "../wire/documents";
2
+ import type { StatewireDocuments } from "../wire/documents";
1
3
  import { useContext, useEffect, useState } from "react";
2
- import { applyOps } from "../wire/ops";
4
+
3
5
  import type { StatewirePath } from "../wire/ops";
6
+ import { MAX_COMMANDS_PER_FRAME } from "../wire/limits";
4
7
  import { TERMINAL } from "./connection";
5
8
  import type { StatewireCmd, StatewireSyn } from "./connection";
6
9
  import {
@@ -19,7 +22,7 @@ import {
19
22
  } from "./sessionStore";
20
23
  import { backoff } from "./timing";
21
24
  import { createMethodProxy } from "../util/methodProxy";
22
- import { randomId } from "../util/randomId";
25
+ import { randomId as newClientId } from "../util/randomId";
23
26
  import { StatewireSendError } from "../types";
24
27
  import type {
25
28
  RemoteLink,
@@ -57,8 +60,6 @@ const protocolError = (message: string): never => {
57
60
  throw new Error(`statewire: protocol error: ${message}`);
58
61
  };
59
62
 
60
- const newClientId = () => `c-${randomId()}`;
61
-
62
63
  const rejectedSend = (
63
64
  failure: StatewireClient.SendFailure,
64
65
  ): Promise<unknown> => {
@@ -103,6 +104,7 @@ type Session = {
103
104
  frameFailures: number;
104
105
  pending: Pending[];
105
106
  canonical: unknown;
107
+ documents: StatewireDocuments.State;
106
108
  lastPublished: unknown;
107
109
  changed: StatewirePath[];
108
110
  source: Statewire.StateSource;
@@ -238,7 +240,7 @@ export const failedCommands = (
238
240
  s: Session,
239
241
  ): readonly StatewireClient.FailedCommand[] => s.record.failed ?? NO_FAILED;
240
242
 
241
- const retainFailed = (
243
+ const retainFailedInner = (
242
244
  s: Session,
243
245
  entry: Pending,
244
246
  failure: StatewireClient.SendFailure,
@@ -260,6 +262,18 @@ const retainFailed = (
260
262
  s.notify();
261
263
  };
262
264
 
265
+ const retainFailed = (
266
+ s: Session,
267
+ entry: Pending,
268
+ failure: StatewireClient.SendFailure,
269
+ ) => {
270
+ if (!s.shared) {
271
+ retainFailedInner(s, entry, failure);
272
+ return;
273
+ }
274
+ void transact(s, () => retainFailedInner(s, entry, failure));
275
+ };
276
+
263
277
  const settleEntry = (
264
278
  s: Session,
265
279
  entry: Pending,
@@ -481,8 +495,9 @@ const handleCmds = (s: Session, cmds: StatewireCmd[]) => {
481
495
  };
482
496
 
483
497
  const ingestOps = (s: Session, ops: unknown[]) => {
484
- const applied = applyOps(s.canonical, ops);
485
- s.canonical = applied.state;
498
+ const applied = applyDocumentOps(s.documents, ops);
499
+ s.documents = applied;
500
+ s.canonical = applied.documents.get(0)?.value;
486
501
  s.changed.push(...applied.changedPaths);
487
502
  };
488
503
 
@@ -546,6 +561,7 @@ const handleHello = (
546
561
  if (syn.record !== undefined && syn.record !== "ephemeral") {
547
562
  protocolError(`unknown record: ${String(syn.record)}`);
548
563
  }
564
+ s.documents = { mode: null, documents: new Map() };
549
565
  ingestOps(s, ops);
550
566
  s.attach = "attached";
551
567
  s.attachEpoch += 1;
@@ -642,7 +658,7 @@ const handleDetach = (s: Session) => {
642
658
  };
643
659
 
644
660
  const handleFinish = (s: Session, { reason }: { reason: string }) => {
645
- if (reason === "evicted" || reason === "error") return;
661
+ if (reason === "reconnect" || reason === "error") return;
646
662
  s.attach = "finished";
647
663
  settleAll(s, "gone");
648
664
  publish(s);
@@ -663,6 +679,7 @@ const buildLanes = (s: Session): Lanes | null => {
663
679
  const ctxEntries: CtxPending[] = [];
664
680
  for (const entry of s.ctxPending) {
665
681
  if (entry.posted) continue;
682
+ if (ctxEntries.length >= MAX_COMMANDS_PER_FRAME) break;
666
683
  if (ctxEntries.length > 0 && entry.seq !== ctxEntries.at(-1)!.seq + 1)
667
684
  break;
668
685
  ctxEntries.push(entry);
@@ -670,6 +687,7 @@ const buildLanes = (s: Session): Lanes | null => {
670
687
  const cmdEntries: Pending[] = [];
671
688
  for (const entry of s.pending) {
672
689
  if (entry.posted) continue;
690
+ if (cmdEntries.length >= MAX_COMMANDS_PER_FRAME) break;
673
691
  if (cmdEntries.length > 0 && entry.seq !== cmdEntries.at(-1)!.seq + 1)
674
692
  break;
675
693
  cmdEntries.push(entry);
@@ -862,9 +880,17 @@ const enqueue = (
862
880
  void pump(s);
863
881
  return;
864
882
  }
865
- void transact(s, () =>
866
- enqueueInner(s, command, params, resolve, reject),
867
- ).then(() => {
883
+ void transact(s, () => {
884
+ if (!s.running) {
885
+ reject(new StatewireSendError({ fate: "aborted", cause: "unmount" }));
886
+ return;
887
+ }
888
+ if (isFinished(s)) {
889
+ reject(new StatewireSendError({ fate: "aborted", cause: "stream-gone" }));
890
+ return;
891
+ }
892
+ enqueueInner(s, command, params, resolve, reject);
893
+ }).then(() => {
868
894
  wake(s);
869
895
  void pump(s);
870
896
  });
@@ -928,18 +954,25 @@ const sendCommand = (
928
954
  return result;
929
955
  };
930
956
 
931
- const takeFailed = (
957
+ const findFailed = (
932
958
  s: Session,
933
959
  entry: StatewireClient.FailedCommand,
934
- ): StatewireClient.FailedCommand => {
935
- const failed = s.record.failed ?? [];
936
- const found = failed.find(
960
+ ): StatewireClient.FailedCommand | undefined =>
961
+ (s.record.failed ?? []).find(
937
962
  (f) =>
938
963
  f.seq === entry.seq && f.ts === entry.ts && f.command === entry.command,
939
964
  );
940
- if (found === undefined) {
941
- throw new Error(`statewire: unknown failed command "${entry.command}"`);
942
- }
965
+
966
+ const unknownFailed = (entry: StatewireClient.FailedCommand) =>
967
+ new Error(`statewire: unknown failed command "${entry.command}"`);
968
+
969
+ const takeFailed = (
970
+ s: Session,
971
+ entry: StatewireClient.FailedCommand,
972
+ ): StatewireClient.FailedCommand => {
973
+ const failed = s.record.failed ?? [];
974
+ const found = findFailed(s, entry);
975
+ if (found === undefined) throw unknownFailed(entry);
943
976
  setRecord(s, { ...s.record, failed: failed.filter((f) => f !== found) });
944
977
  s.notify();
945
978
  return found;
@@ -949,7 +982,14 @@ export const discardFailed = (
949
982
  s: Session,
950
983
  entry: StatewireClient.FailedCommand,
951
984
  ): void => {
952
- takeFailed(s, entry);
985
+ if (!s.shared) {
986
+ takeFailed(s, entry);
987
+ return;
988
+ }
989
+ if (findFailed(s, entry) === undefined) throw unknownFailed(entry);
990
+ void transact(s, () => {
991
+ if (findFailed(s, entry) !== undefined) takeFailed(s, entry);
992
+ });
953
993
  };
954
994
 
955
995
  export const retryFailed = (
@@ -962,23 +1002,52 @@ export const retryFailed = (
962
1002
  if (isFinished(s)) {
963
1003
  return rejectedSend({ fate: "aborted", cause: "stream-gone" });
964
1004
  }
965
- const found = takeFailed(s, entry);
1005
+ const found = findFailed(s, entry);
1006
+ if (found === undefined) throw unknownFailed(entry);
966
1007
  const params =
967
1008
  found.meta === undefined
968
1009
  ? found.params
969
1010
  : (reviveValue([...found.params], found.meta) as unknown[]);
970
1011
  const result = new Promise<unknown>((resolve, reject) => {
971
- enqueue(
972
- s,
973
- {
974
- method: found.command,
975
- params: [...found.params],
976
- ...(found.meta !== undefined && { meta: found.meta }),
977
- },
978
- params,
979
- resolve,
980
- reject,
981
- );
1012
+ const retry = () => {
1013
+ if (!s.running) {
1014
+ reject(new StatewireSendError({ fate: "aborted", cause: "unmount" }));
1015
+ return;
1016
+ }
1017
+ if (isFinished(s)) {
1018
+ reject(
1019
+ new StatewireSendError({ fate: "aborted", cause: "stream-gone" }),
1020
+ );
1021
+ return;
1022
+ }
1023
+ const current = findFailed(s, entry);
1024
+ if (current === undefined) {
1025
+ reject(unknownFailed(entry));
1026
+ return;
1027
+ }
1028
+ takeFailed(s, current);
1029
+ enqueueInner(
1030
+ s,
1031
+ {
1032
+ method: current.command,
1033
+ params: [...current.params],
1034
+ ...(current.meta !== undefined && { meta: current.meta }),
1035
+ },
1036
+ params,
1037
+ resolve,
1038
+ reject,
1039
+ );
1040
+ };
1041
+ if (!s.shared) {
1042
+ retry();
1043
+ wake(s);
1044
+ void pump(s);
1045
+ return;
1046
+ }
1047
+ void transact(s, retry).then(() => {
1048
+ wake(s);
1049
+ void pump(s);
1050
+ }, reject);
982
1051
  });
983
1052
  result.catch(() => {});
984
1053
  return result;
@@ -1010,7 +1079,10 @@ const setContext = (s: Session, value: Record<string, unknown>): void => {
1010
1079
  void pump(s);
1011
1080
  return;
1012
1081
  }
1013
- void transact(s, mint).then(() => {
1082
+ void transact(s, () => {
1083
+ if (!s.running || isFinished(s)) return;
1084
+ mint();
1085
+ }).then(() => {
1014
1086
  wake(s);
1015
1087
  void pump(s);
1016
1088
  });
@@ -1081,6 +1153,7 @@ export const useStatewireSession = (
1081
1153
  frameFailures: 0,
1082
1154
  pending: [],
1083
1155
  canonical: undefined,
1156
+ documents: { mode: null, documents: new Map() },
1084
1157
  lastPublished: undefined,
1085
1158
  changed: [],
1086
1159
  source: {
@@ -1,3 +1,4 @@
1
+ import { CLIENT_ID_RE } from "./socket";
1
2
  import type { StatewireHostInternal } from "../host-internal";
2
3
 
3
4
  export const CLIENT_QUERY_PARAM = "client";
@@ -20,14 +21,21 @@ export const clientIdFromUrl = (url: string | null | undefined): string => {
20
21
  `statewire: url is missing the "${CLIENT_QUERY_PARAM}" query parameter`,
21
22
  );
22
23
  }
24
+ if (!CLIENT_ID_RE.test(clientId))
25
+ throw new Error("statewire: invalid client id");
23
26
  return clientId;
24
27
  };
25
28
 
26
29
  export const textFrameOrClose = (
27
- socket: Pick<StatewireHostInternal.Socket, "close">,
30
+ socket: Pick<StatewireHostInternal.Socket, "send" | "close">,
28
31
  data: unknown,
29
32
  ): string | null => {
30
33
  if (typeof data === "string") return data;
31
- socket.close(1008, "text frames only");
34
+ socket.send(
35
+ JSON.stringify({
36
+ fin: { reason: "protocol-error", message: "malformed frame" },
37
+ }),
38
+ );
39
+ socket.close(1008, "malformed frame");
32
40
  return null;
33
41
  };