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
@@ -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);
@@ -713,7 +733,8 @@ const pump = async (s: Session) => {
713
733
  if (buildLanes(s) === null) return;
714
734
  if (s.attach !== "attached" || !transport) {
715
735
  if (
716
- transport?.connection.status === "idle" ||
736
+ (transport?.connection.status === "disconnected" &&
737
+ transport.connection.reason === "idle") ||
717
738
  (transport?.parked && s.pending.length > 0)
718
739
  ) {
719
740
  transport.connection.reconnect();
@@ -861,9 +882,17 @@ const enqueue = (
861
882
  void pump(s);
862
883
  return;
863
884
  }
864
- void transact(s, () =>
865
- enqueueInner(s, command, params, resolve, reject),
866
- ).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(() => {
867
896
  wake(s);
868
897
  void pump(s);
869
898
  });
@@ -927,18 +956,25 @@ const sendCommand = (
927
956
  return result;
928
957
  };
929
958
 
930
- const takeFailed = (
959
+ const findFailed = (
931
960
  s: Session,
932
961
  entry: StatewireClient.FailedCommand,
933
- ): StatewireClient.FailedCommand => {
934
- const failed = s.record.failed ?? [];
935
- const found = failed.find(
962
+ ): StatewireClient.FailedCommand | undefined =>
963
+ (s.record.failed ?? []).find(
936
964
  (f) =>
937
965
  f.seq === entry.seq && f.ts === entry.ts && f.command === entry.command,
938
966
  );
939
- if (found === undefined) {
940
- throw new Error(`statewire: unknown failed command "${entry.command}"`);
941
- }
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);
942
978
  setRecord(s, { ...s.record, failed: failed.filter((f) => f !== found) });
943
979
  s.notify();
944
980
  return found;
@@ -948,7 +984,14 @@ export const discardFailed = (
948
984
  s: Session,
949
985
  entry: StatewireClient.FailedCommand,
950
986
  ): void => {
951
- 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
+ });
952
995
  };
953
996
 
954
997
  export const retryFailed = (
@@ -961,23 +1004,52 @@ export const retryFailed = (
961
1004
  if (isFinished(s)) {
962
1005
  return rejectedSend({ fate: "aborted", cause: "stream-gone" });
963
1006
  }
964
- const found = takeFailed(s, entry);
1007
+ const found = findFailed(s, entry);
1008
+ if (found === undefined) throw unknownFailed(entry);
965
1009
  const params =
966
1010
  found.meta === undefined
967
1011
  ? found.params
968
1012
  : (reviveValue([...found.params], found.meta) as unknown[]);
969
1013
  const result = new Promise<unknown>((resolve, reject) => {
970
- enqueue(
971
- s,
972
- {
973
- method: found.command,
974
- params: [...found.params],
975
- ...(found.meta !== undefined && { meta: found.meta }),
976
- },
977
- params,
978
- resolve,
979
- reject,
980
- );
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);
981
1053
  });
982
1054
  result.catch(() => {});
983
1055
  return result;
@@ -1009,7 +1081,10 @@ const setContext = (s: Session, value: Record<string, unknown>): void => {
1009
1081
  void pump(s);
1010
1082
  return;
1011
1083
  }
1012
- void transact(s, mint).then(() => {
1084
+ void transact(s, () => {
1085
+ if (!s.running || isFinished(s)) return;
1086
+ mint();
1087
+ }).then(() => {
1013
1088
  wake(s);
1014
1089
  void pump(s);
1015
1090
  });
@@ -1080,6 +1155,7 @@ export const useStatewireSession = (
1080
1155
  frameFailures: 0,
1081
1156
  pending: [],
1082
1157
  canonical: undefined,
1158
+ documents: { mode: null, documents: new Map() },
1083
1159
  lastPublished: undefined,
1084
1160
  changed: [],
1085
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
  };
package/src/host/host.ts CHANGED
@@ -136,6 +136,7 @@ export type CommandsHost = {
136
136
  submit?: (call: CommandsSubmission) => Promise<unknown>;
137
137
  /** Registered command names, read live. */
138
138
  methods?: () => string[];
139
+ abort?: () => void;
139
140
  };
140
141
 
141
142
  const commandsHosts = new WeakMap<object, CommandsHost>();
@@ -158,12 +159,15 @@ type QueuedCall = {
158
159
 
159
160
  type ActiveInvocation = CommandsInvocation & {
160
161
  applied: boolean;
162
+ controller: AbortController;
163
+ settled: boolean;
161
164
  origin?: CommandsSubmission;
162
165
  };
163
166
 
164
167
  type CommandsCell = {
165
168
  handlers: Record<string, (...args: never[]) => unknown> | null;
166
169
  queue: QueuedCall[];
170
+ active: Map<ActiveInvocation, QueuedCall>;
167
171
  running: boolean;
168
172
  listener: CommandsListener | null;
169
173
  seq: number;
@@ -178,6 +182,7 @@ const LOCAL_CALLER: StatewireHost.Caller = Object.freeze({
178
182
  get clientId(): string {
179
183
  throw new Error("statewire: in-process dispatch has no client id");
180
184
  },
185
+ auth: undefined,
181
186
  context: null,
182
187
  isConnected: true,
183
188
  });
@@ -202,6 +207,7 @@ const applyInvocation = (cell: CommandsCell, inv: ActiveInvocation) => {
202
207
  if (inv.applied) return;
203
208
  inv.applied = true;
204
209
  cell.listener?.onApplied(inv);
210
+ cell.listener?.onCovered?.(inv);
205
211
  inv.origin?.onApplied?.();
206
212
  };
207
213
 
@@ -211,6 +217,9 @@ const settleInvocation = (
211
217
  call: QueuedCall,
212
218
  settled: Settled,
213
219
  ) => {
220
+ if (inv.settled) return;
221
+ inv.settled = true;
222
+ cell.active.delete(inv);
214
223
  applyInvocation(cell, inv);
215
224
  if (settled.type === "resolved") {
216
225
  const outcome: CommandsOutcome =
@@ -229,13 +238,40 @@ const settleInvocation = (
229
238
  }
230
239
  };
231
240
 
241
+ const abortInvocations = (cell: CommandsCell) => {
242
+ for (const [inv, call] of cell.active) {
243
+ inv.settled = true;
244
+ inv.controller.abort();
245
+ const error = new StatewireSendError({ fate: "crashed", cause: "server" });
246
+ const outcome: CommandsOutcome = { type: "rejected", error };
247
+ cell.listener?.onSettle(inv, outcome);
248
+ inv.origin?.onSettle?.(outcome);
249
+ call.reject(error);
250
+ }
251
+ cell.active.clear();
252
+ };
253
+
232
254
  const isThenable = (value: unknown): value is PromiseLike<unknown> =>
233
255
  typeof value === "object" &&
234
256
  value !== null &&
235
257
  typeof (value as { then?: unknown }).then === "function";
236
258
 
259
+ // A command is an own, enumerable, function-valued property — exactly what
260
+ // `Object.keys` shows, so dispatch, `statewire/commands`, and the reserved-
261
+ // namespace guard share one view; prototype-chain names are not commands.
262
+ const handlerOf = (
263
+ handlers: Record<string, (...args: never[]) => unknown>,
264
+ method: string,
265
+ ): ((...args: never[]) => unknown) | undefined => {
266
+ if (!Object.prototype.propertyIsEnumerable.call(handlers, method)) {
267
+ return undefined;
268
+ }
269
+ const handler = handlers[method];
270
+ return typeof handler === "function" ? handler : undefined;
271
+ };
272
+
237
273
  const dispatch = (cell: CommandsCell, call: QueuedCall) => {
238
- const handler = cell.handlers![call.method];
274
+ const handler = handlerOf(cell.handlers!, call.method);
239
275
  if (handler === undefined) {
240
276
  const error = new StatewireSendError({
241
277
  fate: "rejected",
@@ -252,17 +288,22 @@ const dispatch = (cell: CommandsCell, call: QueuedCall) => {
252
288
  ts: call.ts,
253
289
  seq: ++cell.seq,
254
290
  applied: false,
291
+ controller: new AbortController(),
292
+ settled: false,
255
293
  ...(call.origin !== undefined && { origin: call.origin }),
256
294
  ...(call.origin?.clientId !== undefined && {
257
295
  clientId: call.origin.clientId,
258
296
  }),
259
297
  };
298
+ cell.active.set(inv, call);
260
299
  cell.listener?.onStart(inv);
261
300
  cell.listener?.onPosted?.(inv);
262
- cell.listener?.onCovered?.(inv);
263
301
  const ctx: StatewireHost.Ctx = {
264
302
  caller: call.origin?.caller ?? LOCAL_CALLER,
265
- applied: () => applyInvocation(cell, inv),
303
+ signal: inv.controller.signal,
304
+ applied: () => {
305
+ if (!inv.settled) applyInvocation(cell, inv);
306
+ },
266
307
  };
267
308
  try {
268
309
  const value = (
@@ -332,6 +373,7 @@ export const useStatewireCommands = <THandlers extends Statewire.Commands>(
332
373
  const cell: CommandsCell = {
333
374
  handlers: null,
334
375
  queue: [],
376
+ active: new Map(),
335
377
  running: false,
336
378
  listener: null,
337
379
  seq: 0,
@@ -358,10 +400,17 @@ export const useStatewireCommands = <THandlers extends Statewire.Commands>(
358
400
  }),
359
401
  );
360
402
  commandsHosts.set(proxy, {
403
+ abort: () => abortInvocations(cell),
361
404
  setListener: (listener) => {
362
405
  cell.listener = listener;
363
406
  },
364
- methods: () => (cell.handlers ? Object.keys(cell.handlers) : []),
407
+ methods: () => {
408
+ const handlers = cell.handlers;
409
+ if (!handlers) return [];
410
+ return Object.keys(handlers).filter(
411
+ (method) => handlerOf(handlers, method) !== undefined,
412
+ );
413
+ },
365
414
  submit: (origin) =>
366
415
  new Promise((resolve, reject) => {
367
416
  if (cell.unmounted) {
@@ -394,6 +443,7 @@ export const useStatewireCommands = <THandlers extends Statewire.Commands>(
394
443
  }
395
444
  queueMicrotask(() => {
396
445
  if (!cell.unmounted) return;
446
+ abortInvocations(cell);
397
447
  const queued = cell.queue;
398
448
  cell.queue = [];
399
449
  for (const call of queued) {