statewire 0.14.0 → 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 (94) 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 +96 -16
  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/statewire.d.ts +11 -2
  41. package/dist/statewire.d.ts.map +1 -1
  42. package/dist/statewire.js.map +1 -1
  43. package/dist/transports/sse.d.ts +1 -4
  44. package/dist/transports/sse.d.ts.map +1 -1
  45. package/dist/transports/sse.js +47 -56
  46. package/dist/transports/sse.js.map +1 -1
  47. package/dist/transports/testing.js +2 -2
  48. package/dist/transports/testing.js.map +1 -1
  49. package/dist/transports/ws.d.ts +1 -1
  50. package/dist/transports/ws.d.ts.map +1 -1
  51. package/dist/transports/ws.js +54 -41
  52. package/dist/transports/ws.js.map +1 -1
  53. package/dist/types.d.ts +3 -0
  54. package/dist/types.d.ts.map +1 -1
  55. package/dist/types.js.map +1 -1
  56. package/dist/wire/documents.d.ts +20 -0
  57. package/dist/wire/documents.d.ts.map +1 -0
  58. package/dist/wire/documents.js +74 -0
  59. package/dist/wire/documents.js.map +1 -0
  60. package/dist/wire/limits.d.ts +10 -0
  61. package/dist/wire/limits.d.ts.map +1 -0
  62. package/dist/wire/limits.js +11 -0
  63. package/dist/wire/limits.js.map +1 -0
  64. package/dist/wire/meta.d.ts.map +1 -1
  65. package/dist/wire/meta.js +1 -1
  66. package/dist/wire/meta.js.map +1 -1
  67. package/dist/wire/ops.d.ts.map +1 -1
  68. package/dist/wire/ops.js +1 -2
  69. package/dist/wire/ops.js.map +1 -1
  70. package/dist/wire/version.d.ts +7 -0
  71. package/dist/wire/version.d.ts.map +1 -0
  72. package/dist/wire/version.js +8 -0
  73. package/dist/wire/version.js.map +1 -0
  74. package/package.json +1 -1
  75. package/src/client/connection.ts +159 -52
  76. package/src/client/core.ts +6 -0
  77. package/src/client/session.ts +104 -29
  78. package/src/host/adapter.ts +10 -2
  79. package/src/host/host.ts +54 -4
  80. package/src/host/http.ts +132 -66
  81. package/src/host/socket.ts +410 -81
  82. package/src/host/work.ts +50 -0
  83. package/src/host-internal.ts +32 -4
  84. package/src/host.ts +18 -2
  85. package/src/statewire.ts +16 -2
  86. package/src/transports/sse.ts +60 -68
  87. package/src/transports/testing.ts +2 -2
  88. package/src/transports/ws.ts +29 -48
  89. package/src/types.ts +1 -0
  90. package/src/wire/documents.ts +109 -0
  91. package/src/wire/limits.ts +6 -0
  92. package/src/wire/meta.ts +6 -1
  93. package/src/wire/ops.ts +1 -2
  94. 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 {
@@ -103,6 +106,7 @@ type Session = {
103
106
  frameFailures: number;
104
107
  pending: Pending[];
105
108
  canonical: unknown;
109
+ documents: StatewireDocuments.State;
106
110
  lastPublished: unknown;
107
111
  changed: StatewirePath[];
108
112
  source: Statewire.StateSource;
@@ -238,7 +242,7 @@ export const failedCommands = (
238
242
  s: Session,
239
243
  ): readonly StatewireClient.FailedCommand[] => s.record.failed ?? NO_FAILED;
240
244
 
241
- const retainFailed = (
245
+ const retainFailedInner = (
242
246
  s: Session,
243
247
  entry: Pending,
244
248
  failure: StatewireClient.SendFailure,
@@ -260,6 +264,18 @@ const retainFailed = (
260
264
  s.notify();
261
265
  };
262
266
 
267
+ const retainFailed = (
268
+ s: Session,
269
+ entry: Pending,
270
+ failure: StatewireClient.SendFailure,
271
+ ) => {
272
+ if (!s.shared) {
273
+ retainFailedInner(s, entry, failure);
274
+ return;
275
+ }
276
+ void transact(s, () => retainFailedInner(s, entry, failure));
277
+ };
278
+
263
279
  const settleEntry = (
264
280
  s: Session,
265
281
  entry: Pending,
@@ -481,8 +497,9 @@ const handleCmds = (s: Session, cmds: StatewireCmd[]) => {
481
497
  };
482
498
 
483
499
  const ingestOps = (s: Session, ops: unknown[]) => {
484
- const applied = applyOps(s.canonical, ops);
485
- s.canonical = applied.state;
500
+ const applied = applyDocumentOps(s.documents, ops);
501
+ s.documents = applied;
502
+ s.canonical = applied.documents.get(0)?.value;
486
503
  s.changed.push(...applied.changedPaths);
487
504
  };
488
505
 
@@ -546,6 +563,7 @@ const handleHello = (
546
563
  if (syn.record !== undefined && syn.record !== "ephemeral") {
547
564
  protocolError(`unknown record: ${String(syn.record)}`);
548
565
  }
566
+ s.documents = { mode: null, documents: new Map() };
549
567
  ingestOps(s, ops);
550
568
  s.attach = "attached";
551
569
  s.attachEpoch += 1;
@@ -642,7 +660,7 @@ const handleDetach = (s: Session) => {
642
660
  };
643
661
 
644
662
  const handleFinish = (s: Session, { reason }: { reason: string }) => {
645
- if (reason === "evicted" || reason === "error") return;
663
+ if (reason === "reconnect" || reason === "error") return;
646
664
  s.attach = "finished";
647
665
  settleAll(s, "gone");
648
666
  publish(s);
@@ -663,6 +681,7 @@ const buildLanes = (s: Session): Lanes | null => {
663
681
  const ctxEntries: CtxPending[] = [];
664
682
  for (const entry of s.ctxPending) {
665
683
  if (entry.posted) continue;
684
+ if (ctxEntries.length >= MAX_COMMANDS_PER_FRAME) break;
666
685
  if (ctxEntries.length > 0 && entry.seq !== ctxEntries.at(-1)!.seq + 1)
667
686
  break;
668
687
  ctxEntries.push(entry);
@@ -670,6 +689,7 @@ const buildLanes = (s: Session): Lanes | null => {
670
689
  const cmdEntries: Pending[] = [];
671
690
  for (const entry of s.pending) {
672
691
  if (entry.posted) continue;
692
+ if (cmdEntries.length >= MAX_COMMANDS_PER_FRAME) break;
673
693
  if (cmdEntries.length > 0 && entry.seq !== cmdEntries.at(-1)!.seq + 1)
674
694
  break;
675
695
  cmdEntries.push(entry);
@@ -862,9 +882,17 @@ const enqueue = (
862
882
  void pump(s);
863
883
  return;
864
884
  }
865
- void transact(s, () =>
866
- enqueueInner(s, command, params, resolve, reject),
867
- ).then(() => {
885
+ void transact(s, () => {
886
+ if (!s.running) {
887
+ reject(new StatewireSendError({ fate: "aborted", cause: "unmount" }));
888
+ return;
889
+ }
890
+ if (isFinished(s)) {
891
+ reject(new StatewireSendError({ fate: "aborted", cause: "stream-gone" }));
892
+ return;
893
+ }
894
+ enqueueInner(s, command, params, resolve, reject);
895
+ }).then(() => {
868
896
  wake(s);
869
897
  void pump(s);
870
898
  });
@@ -928,18 +956,25 @@ const sendCommand = (
928
956
  return result;
929
957
  };
930
958
 
931
- const takeFailed = (
959
+ const findFailed = (
932
960
  s: Session,
933
961
  entry: StatewireClient.FailedCommand,
934
- ): StatewireClient.FailedCommand => {
935
- const failed = s.record.failed ?? [];
936
- const found = failed.find(
962
+ ): StatewireClient.FailedCommand | undefined =>
963
+ (s.record.failed ?? []).find(
937
964
  (f) =>
938
965
  f.seq === entry.seq && f.ts === entry.ts && f.command === entry.command,
939
966
  );
940
- if (found === undefined) {
941
- throw new Error(`statewire: unknown failed command "${entry.command}"`);
942
- }
967
+
968
+ const unknownFailed = (entry: StatewireClient.FailedCommand) =>
969
+ new Error(`statewire: unknown failed command "${entry.command}"`);
970
+
971
+ const takeFailed = (
972
+ s: Session,
973
+ entry: StatewireClient.FailedCommand,
974
+ ): StatewireClient.FailedCommand => {
975
+ const failed = s.record.failed ?? [];
976
+ const found = findFailed(s, entry);
977
+ if (found === undefined) throw unknownFailed(entry);
943
978
  setRecord(s, { ...s.record, failed: failed.filter((f) => f !== found) });
944
979
  s.notify();
945
980
  return found;
@@ -949,7 +984,14 @@ export const discardFailed = (
949
984
  s: Session,
950
985
  entry: StatewireClient.FailedCommand,
951
986
  ): void => {
952
- takeFailed(s, entry);
987
+ if (!s.shared) {
988
+ takeFailed(s, entry);
989
+ return;
990
+ }
991
+ if (findFailed(s, entry) === undefined) throw unknownFailed(entry);
992
+ void transact(s, () => {
993
+ if (findFailed(s, entry) !== undefined) takeFailed(s, entry);
994
+ });
953
995
  };
954
996
 
955
997
  export const retryFailed = (
@@ -962,23 +1004,52 @@ export const retryFailed = (
962
1004
  if (isFinished(s)) {
963
1005
  return rejectedSend({ fate: "aborted", cause: "stream-gone" });
964
1006
  }
965
- const found = takeFailed(s, entry);
1007
+ const found = findFailed(s, entry);
1008
+ if (found === undefined) throw unknownFailed(entry);
966
1009
  const params =
967
1010
  found.meta === undefined
968
1011
  ? found.params
969
1012
  : (reviveValue([...found.params], found.meta) as unknown[]);
970
1013
  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
- );
1014
+ const retry = () => {
1015
+ if (!s.running) {
1016
+ reject(new StatewireSendError({ fate: "aborted", cause: "unmount" }));
1017
+ return;
1018
+ }
1019
+ if (isFinished(s)) {
1020
+ reject(
1021
+ new StatewireSendError({ fate: "aborted", cause: "stream-gone" }),
1022
+ );
1023
+ return;
1024
+ }
1025
+ const current = findFailed(s, entry);
1026
+ if (current === undefined) {
1027
+ reject(unknownFailed(entry));
1028
+ return;
1029
+ }
1030
+ takeFailed(s, current);
1031
+ enqueueInner(
1032
+ s,
1033
+ {
1034
+ method: current.command,
1035
+ params: [...current.params],
1036
+ ...(current.meta !== undefined && { meta: current.meta }),
1037
+ },
1038
+ params,
1039
+ resolve,
1040
+ reject,
1041
+ );
1042
+ };
1043
+ if (!s.shared) {
1044
+ retry();
1045
+ wake(s);
1046
+ void pump(s);
1047
+ return;
1048
+ }
1049
+ void transact(s, retry).then(() => {
1050
+ wake(s);
1051
+ void pump(s);
1052
+ }, reject);
982
1053
  });
983
1054
  result.catch(() => {});
984
1055
  return result;
@@ -1010,7 +1081,10 @@ const setContext = (s: Session, value: Record<string, unknown>): void => {
1010
1081
  void pump(s);
1011
1082
  return;
1012
1083
  }
1013
- void transact(s, mint).then(() => {
1084
+ void transact(s, () => {
1085
+ if (!s.running || isFinished(s)) return;
1086
+ mint();
1087
+ }).then(() => {
1014
1088
  wake(s);
1015
1089
  void pump(s);
1016
1090
  });
@@ -1081,6 +1155,7 @@ export const useStatewireSession = (
1081
1155
  frameFailures: 0,
1082
1156
  pending: [],
1083
1157
  canonical: undefined,
1158
+ documents: { mode: null, documents: new Map() },
1084
1159
  lastPublished: undefined,
1085
1160
  changed: [],
1086
1161
  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
  };