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
@@ -8,6 +8,14 @@ import type { Statewire } from "../types";
8
8
  import type { StatewireHost } from "../host";
9
9
  import type { StatewireHostInternal } from "../host-internal";
10
10
  import { encodeValue, reviveValue } from "../wire/meta";
11
+ import {
12
+ COMMAND_CAPACITY_RETRY_MS,
13
+ MAX_COMMANDS_PER_FRAME,
14
+ MAX_CONTEXT_VALUE_BYTES,
15
+ MAX_FRAME_BODY_BYTES,
16
+ MAX_INFLIGHT_COMMANDS_PER_CLIENT,
17
+ MAX_INFLIGHT_COMMANDS_PER_HOST,
18
+ } from "../wire/limits";
11
19
  import { createMacrotaskPoster } from "../util/macrotask";
12
20
  import type { StatewireMetaEntry } from "../wire/meta";
13
21
  import type { StatewirePath } from "../wire/ops";
@@ -15,9 +23,49 @@ import type { StatewireCmd, StatewireEnvelope } from "../client/connection";
15
23
  import { frames as serveFrames, stream as serveStream } from "./http";
16
24
 
17
25
  export const CLIENT_ID_RE = /^[A-Za-z0-9._~-]{1,256}$/;
26
+ export const PRINCIPAL_MISMATCH_MESSAGE =
27
+ "client id is bound to another principal";
28
+
29
+ /** Structural equality over auth values: primitives, Dates, arrays, and plain
30
+ * objects compare by value; anything else by identity. */
31
+ export const authEquals = (a: unknown, b: unknown): boolean => {
32
+ if (Object.is(a, b)) return true;
33
+ if (a instanceof Date && b instanceof Date)
34
+ return a.getTime() === b.getTime();
35
+ if (Array.isArray(a) && Array.isArray(b)) {
36
+ return a.length === b.length && a.every((v, i) => authEquals(v, b[i]));
37
+ }
38
+ if (
39
+ typeof a === "object" &&
40
+ typeof b === "object" &&
41
+ a !== null &&
42
+ b !== null &&
43
+ Object.getPrototypeOf(a) === Object.prototype &&
44
+ Object.getPrototypeOf(b) === Object.prototype
45
+ ) {
46
+ const keysA = Object.keys(a);
47
+ const keysB = Object.keys(b);
48
+ return (
49
+ keysA.length === keysB.length &&
50
+ keysA.every(
51
+ (key) =>
52
+ key in b &&
53
+ authEquals(
54
+ (a as Record<string, unknown>)[key],
55
+ (b as Record<string, unknown>)[key],
56
+ ),
57
+ )
58
+ );
59
+ }
60
+ return false;
61
+ };
62
+
18
63
  const RESULT_TTL_MS = 30_000;
19
64
  const RESULT_LIMIT = 256;
20
65
  const CLIENT_RECORD_LIMIT = 1024;
66
+ const MAX_SUBSCRIBERS = 1024;
67
+ export const SUBSCRIBER_LIMIT_MESSAGE = "stream subscriber limit reached";
68
+ const STREAM_QUEUE_SIZE = 256;
21
69
 
22
70
  type WireOp = {
23
71
  op: string;
@@ -42,6 +90,8 @@ export type ClientRec = {
42
90
  context: Record<string, unknown> | null;
43
91
  results: Map<number, { ts: number; cmd: StatewireCmd }>;
44
92
  inflight: Map<number, Admission>;
93
+ /** The auth captured at the record's first attach; later attaches must match. */
94
+ principal: unknown;
45
95
  };
46
96
 
47
97
  export type Conn = {
@@ -51,12 +101,15 @@ export type Conn = {
51
101
  sawFrame: boolean;
52
102
  lastSentAt: number;
53
103
  lease: string | null;
104
+ /** The awaited `authorize` return captured at this attach's authorization. */
105
+ auth: unknown;
54
106
  };
55
107
 
56
108
  type PendingConn = {
57
109
  socket: StatewireHostInternal.Socket;
58
110
  open: boolean;
59
111
  authorizing: boolean;
112
+ queued: string[];
60
113
  timer: ReturnType<typeof setTimeout> | null;
61
114
  connection: StatewireHostInternal.SocketConnection | null;
62
115
  };
@@ -81,7 +134,6 @@ type CmdStatement = {
81
134
  };
82
135
 
83
136
  export type ParsedFrame = {
84
- headers: Record<string, string> | null;
85
137
  ctx: CtxStatement[];
86
138
  cmd: CmdStatement[];
87
139
  };
@@ -107,6 +159,8 @@ const parseLane = (value: unknown): Record<string, unknown>[] => {
107
159
  return statements;
108
160
  };
109
161
 
162
+ const frameEncoder = new TextEncoder();
163
+
110
164
  /** v1 ctx statements state the context whole: one root replace with an object value. */
111
165
  const contextValue = (ops: unknown): Record<string, unknown> => {
112
166
  if (!Array.isArray(ops) || ops.length !== 1) {
@@ -131,46 +185,42 @@ const contextValue = (ops: unknown): Record<string, unknown> => {
131
185
  return value;
132
186
  };
133
187
 
134
- export const parseFrameValue = (
135
- frame: unknown,
136
- first: boolean,
137
- ): ParsedFrame => {
188
+ export const parseFrameValue = (frame: unknown): ParsedFrame => {
138
189
  if (!isPlainObject(frame)) return violation("malformed frame");
139
190
  const keys = Object.keys(frame);
140
191
  if (keys.length === 0) return violation("malformed frame");
141
- const unknown = keys
142
- .filter((key) => key !== "headers" && key !== "ctx" && key !== "cmd")
143
- .sort();
192
+ const unknown = keys.filter((key) => key !== "ctx" && key !== "cmd").sort();
144
193
  if (unknown.length > 0)
145
194
  return violation(`unknown frame key: '${unknown[0]}'`);
146
- let headers: Record<string, string> | null = null;
147
- if (frame["headers"] !== undefined) {
148
- if (!first) return violation("mid-session headers");
149
- const raw = frame["headers"];
150
- if (
151
- !isPlainObject(raw) ||
152
- Object.values(raw).some((value) => typeof value !== "string")
153
- ) {
154
- return violation("malformed headers");
155
- }
156
- headers = raw as Record<string, string>;
157
- }
158
195
  const ctx: CtxStatement[] = [];
159
196
  if (frame["ctx"] !== undefined) {
160
- for (const statement of parseLane(frame["ctx"])) {
197
+ const lane = frame["ctx"];
198
+ if (Array.isArray(lane) && lane.length > MAX_COMMANDS_PER_FRAME) {
199
+ return violation("too many context statements");
200
+ }
201
+ for (const statement of parseLane(lane)) {
202
+ let value: Record<string, unknown>;
161
203
  try {
162
- ctx.push({
163
- seq: statement["seq"] as number,
164
- value: contextValue(statement["ops"]),
165
- });
204
+ value = contextValue(statement["ops"]);
166
205
  } catch {
167
206
  return violation("invalid-context");
168
207
  }
208
+ if (
209
+ frameEncoder.encode(JSON.stringify(value)).byteLength >
210
+ MAX_CONTEXT_VALUE_BYTES
211
+ ) {
212
+ return violation("oversized-context");
213
+ }
214
+ ctx.push({ seq: statement["seq"] as number, value });
169
215
  }
170
216
  }
171
217
  const cmd: CmdStatement[] = [];
172
218
  if (frame["cmd"] !== undefined) {
173
- for (const statement of parseLane(frame["cmd"])) {
219
+ const lane = frame["cmd"];
220
+ if (Array.isArray(lane) && lane.length > MAX_COMMANDS_PER_FRAME) {
221
+ return violation("too many commands");
222
+ }
223
+ for (const statement of parseLane(lane)) {
174
224
  const method = statement["method"];
175
225
  if (typeof method !== "string" || !("params" in statement)) {
176
226
  return violation("malformed statement");
@@ -183,19 +233,23 @@ export const parseFrameValue = (
183
233
  });
184
234
  }
185
235
  }
186
- return { headers, ctx, cmd };
236
+ return { ctx, cmd };
187
237
  };
188
238
 
189
- export const parseFrame = (raw: string, first: boolean): ParsedFrame => {
239
+ export const parseFrame = (raw: string): ParsedFrame => {
190
240
  let frame: unknown;
191
241
  try {
192
242
  frame = JSON.parse(raw);
193
243
  } catch {
194
244
  return violation("malformed frame");
195
245
  }
196
- return parseFrameValue(frame, first);
246
+ return parseFrameValue(frame);
197
247
  };
198
248
 
249
+ const frameIsOversized = (raw: string): boolean =>
250
+ raw.length > MAX_FRAME_BODY_BYTES ||
251
+ frameEncoder.encode(raw).byteLength > MAX_FRAME_BODY_BYTES;
252
+
199
253
  // ─── verdict encoding ───────────────────────────────────
200
254
 
201
255
  const encodePayload = (
@@ -213,6 +267,8 @@ const cmdOf = (adm: Admission, outcome: CommandsOutcome): StatewireCmd => {
213
267
  const failure =
214
268
  outcome.error instanceof StatewireSendError ? outcome.error.failure : null;
215
269
  switch (failure?.fate) {
270
+ case "crashed":
271
+ return { seq: adm.seq, type: "crashed" };
216
272
  case "failed":
217
273
  return {
218
274
  seq: adm.seq,
@@ -251,13 +307,41 @@ const cmdOf = (adm: Admission, outcome: CommandsOutcome): StatewireCmd => {
251
307
  }
252
308
  };
253
309
 
310
+ const parseInitialFrame = (raw: string) => {
311
+ let value: unknown;
312
+ try {
313
+ value = JSON.parse(raw);
314
+ } catch {
315
+ return violation("malformed frame");
316
+ }
317
+ if (!isPlainObject(value)) return violation("malformed frame");
318
+ const { headers, ...lanes } = value;
319
+ if (headers === undefined) throw new Response(null, { status: 401 });
320
+ if (
321
+ !isPlainObject(headers) ||
322
+ Object.values(headers).some((v) => typeof v !== "string")
323
+ ) {
324
+ return violation("malformed headers");
325
+ }
326
+ return {
327
+ headers: headers as Record<string, string>,
328
+ frame: Object.keys(lanes).length
329
+ ? parseFrameValue(lanes)
330
+ : { ctx: [], cmd: [] },
331
+ };
332
+ };
333
+
254
334
  // ─── host ───────────────────────────────────────────────
255
335
 
256
336
  export type SocketHostState = {
257
337
  heartbeatMs: number;
258
338
  wsInitTimeoutMs: number;
339
+ maxSubscribers: number;
340
+ maxInflightCommands: number;
341
+ streamQueueSize: number;
259
342
  clientRecordLimit: number;
260
343
  finOnIdle: boolean;
344
+ pushback: (() => number | null) | undefined;
261
345
  authorize: StatewireHostInternal.SocketHostOptions["authorize"];
262
346
  clients: Map<string, ClientRec>;
263
347
  conns: Set<Conn>;
@@ -273,6 +357,7 @@ export type SocketHostState = {
273
357
  stateHost: StatewireHost.StateHost | null;
274
358
  commandsHost: CommandsHost | null;
275
359
  current: Statewire<unknown> | null;
360
+ persistence: StatewireHostInternal.SocketHostPersistence | undefined;
276
361
  post: () => void;
277
362
  connect: (
278
363
  clientId: string,
@@ -358,6 +443,11 @@ export const sendTo = (h: SocketHostState, conn: Conn, data: string) => {
358
443
  }
359
444
  };
360
445
 
446
+ export const atSubscriberLimit = (h: SocketHostState): boolean =>
447
+ h.conns.size +
448
+ [...h.pendingConns].filter((conn) => conn.authorizing).length >=
449
+ h.maxSubscribers;
450
+
361
451
  const ensureHeartbeat = (h: SocketHostState) => {
362
452
  if (h.heartbeatTimer !== null || h.heartbeatMs <= 0) return;
363
453
  h.heartbeatTimer = setInterval(() => {
@@ -380,6 +470,7 @@ const setHeartbeatMs = (h: SocketHostState, ms: number) => {
380
470
  };
381
471
 
382
472
  const idleLevel = (h: SocketHostState): boolean => {
473
+ if (h.current?.work?.isIdle === false) return false;
383
474
  for (const rec of h.clients.values()) {
384
475
  if (rec.inflight.size > 0) return false;
385
476
  }
@@ -463,6 +554,24 @@ const queueCmd = (h: SocketHostState, clientId: string, cmd: StatewireCmd) => {
463
554
  scheduleFlush(h);
464
555
  };
465
556
 
557
+ const notifyClientRecords = (h: SocketHostState) => {
558
+ h.persistence?.onClientRecordsChange?.(
559
+ [...h.clients.values()].map((rec) => ({
560
+ id: rec.id,
561
+ lastSeq: admittedThrough(rec),
562
+ ctxSeq: rec.ctxSeq,
563
+ touched: rec.touched,
564
+ context: rec.context,
565
+ results: [...rec.results].map(([seq, entry]) => ({
566
+ seq,
567
+ ts: entry.ts,
568
+ cmd: entry.cmd,
569
+ })),
570
+ principal: rec.principal,
571
+ })),
572
+ );
573
+ };
574
+
466
575
  const retain = (rec: ClientRec, cmd: StatewireCmd) => {
467
576
  const now = Date.now();
468
577
  rec.results.set(cmd.seq, { ts: now, cmd });
@@ -493,6 +602,7 @@ const reemit = (h: SocketHostState, rec: ClientRec, seq: number) => {
493
602
  return;
494
603
  }
495
604
  rec.results.delete(seq);
605
+ notifyClientRecords(h);
496
606
  }
497
607
  const adm = rec.inflight.get(seq);
498
608
  if (adm) {
@@ -515,6 +625,7 @@ const settleCmd = (
515
625
  rec.lastSeq = Math.max(rec.lastSeq, adm.seq);
516
626
  adm.applied = true;
517
627
  queueCmd(h, rec.id, cmd);
628
+ notifyClientRecords(h);
518
629
  };
519
630
 
520
631
  const settleWire = (
@@ -527,8 +638,10 @@ const settleWire = (
527
638
  const callerOf = (
528
639
  h: SocketHostState,
529
640
  rec: ClientRec,
641
+ auth: unknown,
530
642
  ): StatewireHost.Caller => ({
531
643
  clientId: rec.id,
644
+ auth,
532
645
  get context() {
533
646
  return rec.context;
534
647
  },
@@ -558,6 +671,7 @@ const admitCmd = (
558
671
  h: SocketHostState,
559
672
  rec: ClientRec,
560
673
  statement: CmdStatement,
674
+ auth: unknown,
561
675
  ) => {
562
676
  const adm: Admission = {
563
677
  seq: statement.seq,
@@ -567,6 +681,7 @@ const admitCmd = (
567
681
  settled: false,
568
682
  };
569
683
  rec.inflight.set(statement.seq, adm);
684
+ notifyClientRecords(h);
570
685
  if (!Array.isArray(statement.params)) {
571
686
  rejectStatement(h, rec, adm, "params must be an array");
572
687
  return;
@@ -603,11 +718,12 @@ const admitCmd = (
603
718
  method: statement.method,
604
719
  params,
605
720
  clientId: rec.id,
606
- caller: callerOf(h, rec),
721
+ caller: callerOf(h, rec, auth),
607
722
  onApplied: () => {
608
723
  if (adm.settled || adm.applied) return;
609
724
  adm.applied = true;
610
725
  rec.lastSeq = Math.max(rec.lastSeq, adm.seq);
726
+ notifyClientRecords(h);
611
727
  scheduleFlush(h);
612
728
  },
613
729
  onSettle: (outcome) => settleWire(h, rec, adm, outcome),
@@ -621,12 +737,18 @@ const admitCmd = (
621
737
  // {syn} stating both watermarks, nothing consumed. A gap is never a close.
622
738
  export type FrameAdmission =
623
739
  | { type: "ok" }
624
- | { type: "refused"; syn: { syn: { seq: number; ctxSeq: number } } };
740
+ | {
741
+ type: "refused";
742
+ syn: {
743
+ syn: { seq: number; ctxSeq: number; retry?: number };
744
+ };
745
+ };
625
746
 
626
747
  export const admitFrame = (
627
748
  h: SocketHostState,
628
749
  rec: ClientRec,
629
750
  frame: ParsedFrame,
751
+ auth: unknown,
630
752
  ): FrameAdmission => {
631
753
  rec.touched = Date.now();
632
754
  const through = admittedThrough(rec);
@@ -639,40 +761,100 @@ export const admitFrame = (
639
761
  (ctxNew.length > 0 && ctxNew[0]!.seq !== rec.ctxSeq + 1) ||
640
762
  (cmdNew.length > 0 && cmdNew[0]!.seq !== through + 1)
641
763
  ) {
764
+ notifyClientRecords(h);
642
765
  return {
643
766
  type: "refused",
644
767
  syn: { syn: { seq: through, ctxSeq: rec.ctxSeq } },
645
768
  };
646
769
  }
770
+ if (cmdNew.length > 0) {
771
+ let hostCommandLoad = 0;
772
+ for (const client of h.clients.values()) {
773
+ hostCommandLoad += client.inflight.size;
774
+ }
775
+ if (
776
+ rec.inflight.size + cmdNew.length > MAX_INFLIGHT_COMMANDS_PER_CLIENT ||
777
+ hostCommandLoad + cmdNew.length > h.maxInflightCommands
778
+ ) {
779
+ return {
780
+ type: "refused",
781
+ syn: {
782
+ syn: {
783
+ seq: through,
784
+ ctxSeq: rec.ctxSeq,
785
+ retry: COMMAND_CAPACITY_RETRY_MS,
786
+ },
787
+ },
788
+ };
789
+ }
790
+ }
791
+ if ((ctxNew.length || cmdNew.length) && h.pushback) {
792
+ const retry = h.pushback();
793
+ if (retry !== null) {
794
+ if (!Number.isSafeInteger(retry) || retry < 0)
795
+ throw new TypeError(
796
+ "pushback must return a nonnegative safe integer or null",
797
+ );
798
+ return {
799
+ type: "refused",
800
+ syn: { syn: { seq: through, ctxSeq: rec.ctxSeq, retry } },
801
+ };
802
+ }
803
+ }
647
804
  for (const statement of ctxNew) {
648
805
  rec.context = statement.value;
649
806
  rec.ctxSeq = statement.seq;
650
807
  }
651
- for (const statement of cmdNew) admitCmd(h, rec, statement);
808
+ for (const statement of cmdNew) admitCmd(h, rec, statement, auth);
652
809
  if (cmdNew.length > 0) {
653
810
  // Memory host: admitted means retained — dur rides the admitting flush.
654
811
  queueCmd(h, rec.id, { seq: cmdNew.at(-1)!.seq, dur: true });
655
812
  }
813
+ notifyClientRecords(h);
656
814
  scheduleFlush(h);
657
815
  return { type: "ok" };
658
816
  };
659
817
 
660
818
  const message = (h: SocketHostState, conn: Conn, data: string) => {
661
819
  if (!conn.open || h.disposed || h.finished !== null) return;
820
+ if (frameIsOversized(data)) {
821
+ sendTo(
822
+ h,
823
+ conn,
824
+ JSON.stringify({
825
+ fin: {
826
+ reason: "protocol-error",
827
+ message: "frame too large",
828
+ },
829
+ }),
830
+ );
831
+ closeConn(h, conn, 1009, "frame too large");
832
+ return;
833
+ }
662
834
  const first = !conn.sawFrame;
663
835
  conn.sawFrame = true;
664
836
  let frame: ParsedFrame;
665
837
  try {
666
- frame = parseFrame(data, first);
838
+ frame = first ? parseInitialFrame(data).frame : parseFrame(data);
667
839
  } catch (error) {
668
840
  const reason = violationReason(error);
669
841
  if (reason === null) throw error;
842
+ sendTo(
843
+ h,
844
+ conn,
845
+ JSON.stringify({
846
+ fin: {
847
+ reason: "protocol-error",
848
+ message: reason,
849
+ },
850
+ }),
851
+ );
670
852
  closeConn(h, conn, 1008, reason);
671
853
  return;
672
854
  }
673
855
  if (frame.ctx.length === 0 && frame.cmd.length === 0) return;
674
856
  const rec = h.clients.get(conn.clientId)!;
675
- const admission = admitFrame(h, rec, frame);
857
+ const admission = admitFrame(h, rec, frame, conn.auth);
676
858
  if (admission.type === "refused") {
677
859
  sendTo(h, conn, JSON.stringify(admission.syn));
678
860
  }
@@ -705,6 +887,28 @@ const sendPending = (pending: PendingConn, data: string): boolean => {
705
887
  }
706
888
  };
707
889
 
890
+ const refuseSubscriberLimit = (socket: StatewireHostInternal.Socket): null => {
891
+ try {
892
+ socket.send(
893
+ JSON.stringify({
894
+ fin: {
895
+ reason: "reconnect",
896
+ code: "overloaded",
897
+ message: SUBSCRIBER_LIMIT_MESSAGE,
898
+ },
899
+ }),
900
+ );
901
+ } catch {
902
+ /* peer already gone */
903
+ }
904
+ try {
905
+ socket.close(1013, SUBSCRIBER_LIMIT_MESSAGE);
906
+ } catch {
907
+ /* peer already gone */
908
+ }
909
+ return null;
910
+ };
911
+
708
912
  const rejectPendingAuthorization = (
709
913
  h: SocketHostState,
710
914
  pending: PendingConn,
@@ -721,7 +925,12 @@ const rejectPendingAuthorization = (
721
925
  pending,
722
926
  JSON.stringify({
723
927
  fin: {
724
- reason: unauthorized ? "unauthorized" : "error",
928
+ reason:
929
+ status === 403
930
+ ? "forbidden"
931
+ : unauthorized
932
+ ? "unauthorized"
933
+ : "error",
725
934
  message,
726
935
  ...(status !== undefined && { status }),
727
936
  },
@@ -732,7 +941,15 @@ const rejectPendingAuthorization = (
732
941
 
733
942
  const normalizedHeaders = (
734
943
  headers: Record<string, string>,
735
- ): Record<string, string> => Object.fromEntries(new Headers(headers));
944
+ ): Record<string, string> => {
945
+ const seen = new Set<string>();
946
+ for (const name of Object.keys(headers)) {
947
+ const key = name.toLowerCase();
948
+ if (seen.has(key)) throw new TypeError("duplicate header name");
949
+ seen.add(key);
950
+ }
951
+ return Object.fromEntries(new Headers(headers));
952
+ };
736
953
 
737
954
  const connectAuthorized = (
738
955
  h: SocketHostState,
@@ -741,12 +958,13 @@ const connectAuthorized = (
741
958
  options: StatewireHostInternal.SocketConnectOptions,
742
959
  ): StatewireHostInternal.SocketConnection | null => {
743
960
  const authorize = h.authorize;
744
- if (authorize === undefined || options.authorized === true) {
961
+ if (options.authorized === true) {
745
962
  const registration = register(
746
963
  h,
747
964
  clientId,
748
965
  socket,
749
966
  options.authorized === true,
967
+ options.auth,
750
968
  );
751
969
  return registration?.connection ?? null;
752
970
  }
@@ -773,6 +991,7 @@ const connectAuthorized = (
773
991
  socket,
774
992
  open: true,
775
993
  authorizing: false,
994
+ queued: [],
776
995
  timer: null,
777
996
  connection: null,
778
997
  };
@@ -795,69 +1014,95 @@ const connectAuthorized = (
795
1014
  return;
796
1015
  }
797
1016
  if (!pending.open || h.disposed || h.finished !== null) return;
1017
+ if (frameIsOversized(data)) {
1018
+ sendPending(
1019
+ pending,
1020
+ JSON.stringify({
1021
+ fin: {
1022
+ reason: "protocol-error",
1023
+ message: "frame too large",
1024
+ },
1025
+ }),
1026
+ );
1027
+ closePending(h, pending, 1009, "frame too large");
1028
+ return;
1029
+ }
798
1030
  if (pending.authorizing) {
799
- closePending(h, pending, 1008, "authorization pending");
1031
+ pending.queued.push(data);
800
1032
  return;
801
1033
  }
802
- let frame: ParsedFrame;
1034
+ let initial: ReturnType<typeof parseInitialFrame>;
803
1035
  try {
804
- frame = parseFrame(data, true);
1036
+ initial = parseInitialFrame(data);
805
1037
  } catch (error) {
1038
+ if (error instanceof Response) {
1039
+ rejectPendingAuthorization(h, pending, error);
1040
+ return;
1041
+ }
806
1042
  const reason = violationReason(error);
807
1043
  if (reason === null) throw error;
808
1044
  sendPending(
809
1045
  pending,
810
1046
  JSON.stringify({
811
1047
  fin: {
812
- reason: "error",
813
- message: `protocol violation: ${reason}`,
1048
+ reason: "protocol-error",
1049
+ message: reason,
814
1050
  },
815
1051
  }),
816
1052
  );
817
1053
  closePending(h, pending, 1008, reason);
818
1054
  return;
819
1055
  }
820
- if (frame.headers === null) {
821
- rejectPendingAuthorization(
822
- h,
823
- pending,
824
- new Response(null, { status: 401 }),
825
- );
826
- return;
827
- }
1056
+ const frame = initial.frame;
828
1057
  let headers: Record<string, string>;
829
1058
  try {
830
- headers = normalizedHeaders(frame.headers);
1059
+ headers = normalizedHeaders(initial.headers);
831
1060
  } catch {
832
1061
  sendPending(
833
1062
  pending,
834
1063
  JSON.stringify({
835
1064
  fin: {
836
- reason: "error",
837
- message: "protocol violation: malformed headers",
1065
+ reason: "protocol-error",
1066
+ message: "malformed headers",
838
1067
  },
839
1068
  }),
840
1069
  );
841
1070
  closePending(h, pending, 1008, "malformed headers");
842
1071
  return;
843
1072
  }
1073
+ if (atSubscriberLimit(h)) {
1074
+ sendPending(
1075
+ pending,
1076
+ JSON.stringify({
1077
+ fin: {
1078
+ reason: "reconnect",
1079
+ code: "overloaded",
1080
+ message: SUBSCRIBER_LIMIT_MESSAGE,
1081
+ },
1082
+ }),
1083
+ );
1084
+ closePending(h, pending, 1013, SUBSCRIBER_LIMIT_MESSAGE);
1085
+ return;
1086
+ }
844
1087
  pending.authorizing = true;
845
1088
  Promise.resolve()
846
- .then(() => authorize({ headers }))
847
- .then(() => {
1089
+ .then(() => authorize?.({ headers }))
1090
+ .then((auth) => {
848
1091
  if (!pending.open || h.disposed || h.finished !== null) return;
849
- options.onAuthorized?.();
1092
+ options.onAuthorized?.(auth);
850
1093
  pending.open = false;
851
1094
  h.pendingConns.delete(pending);
852
1095
  if (pending.timer !== null) clearTimeout(pending.timer);
853
- const registration = register(h, clientId, socket, true);
1096
+ const registration = register(h, clientId, socket, true, auth);
854
1097
  if (registration === null) return;
855
1098
  pending.connection = registration.connection;
856
- if (frame.ctx.length === 0 && frame.cmd.length === 0) return;
857
- const admission = admitFrame(h, registration.rec, frame);
858
- if (admission.type === "refused") {
859
- sendTo(h, registration.conn, JSON.stringify(admission.syn));
1099
+ if (frame.ctx.length || frame.cmd.length) {
1100
+ const admission = admitFrame(h, registration.rec, frame, auth);
1101
+ if (admission.type === "refused")
1102
+ sendTo(h, registration.conn, JSON.stringify(admission.syn));
860
1103
  }
1104
+ for (const data of pending.queued.splice(0))
1105
+ registration.connection.message(data);
861
1106
  })
862
1107
  .catch((error: unknown) => {
863
1108
  if (pending.open) rejectPendingAuthorization(h, pending, error);
@@ -873,14 +1118,17 @@ const connectAuthorized = (
873
1118
  const gcClients = (h: SocketHostState) => {
874
1119
  let excess = h.clients.size - h.clientRecordLimit;
875
1120
  if (excess <= 0) return;
1121
+ let changed = false;
876
1122
  const live = new Set([...h.conns].map((conn) => conn.clientId));
877
1123
  const oldest = [...h.clients.values()].sort((a, b) => a.touched - b.touched);
878
1124
  for (const rec of oldest) {
879
- if (excess <= 0) return;
1125
+ if (excess <= 0) break;
880
1126
  if (live.has(rec.id) || rec.inflight.size > 0) continue;
881
1127
  h.clients.delete(rec.id);
1128
+ changed = true;
882
1129
  excess -= 1;
883
1130
  }
1131
+ if (changed) notifyClientRecords(h);
884
1132
  };
885
1133
 
886
1134
  export type Registration = {
@@ -894,6 +1142,7 @@ export const register = (
894
1142
  clientId: string,
895
1143
  socket: StatewireHostInternal.Socket,
896
1144
  sawFrame = false,
1145
+ auth: unknown = undefined,
897
1146
  ): Registration | null => {
898
1147
  const refuse = (code: number, reason: string) => {
899
1148
  try {
@@ -915,22 +1164,42 @@ export const register = (
915
1164
  refuse(1000, "finished");
916
1165
  return null;
917
1166
  }
918
- const known = h.clients.has(clientId);
919
- const rec = known
920
- ? h.clients.get(clientId)!
921
- : (() => {
922
- const created: ClientRec = {
923
- id: clientId,
924
- lastSeq: 0,
925
- ctxSeq: 0,
926
- touched: Date.now(),
927
- context: null,
928
- results: new Map(),
929
- inflight: new Map(),
930
- };
931
- h.clients.set(clientId, created);
932
- return created;
933
- })();
1167
+ if (atSubscriberLimit(h)) {
1168
+ return refuseSubscriberLimit(socket);
1169
+ }
1170
+ const known = h.clients.get(clientId);
1171
+ if (known !== undefined && !authEquals(known.principal, auth)) {
1172
+ try {
1173
+ socket.send(
1174
+ JSON.stringify({
1175
+ fin: {
1176
+ reason: "forbidden",
1177
+ message: PRINCIPAL_MISMATCH_MESSAGE,
1178
+ status: 403,
1179
+ },
1180
+ }),
1181
+ );
1182
+ } catch {
1183
+ /* peer already gone */
1184
+ }
1185
+ return refuse(1008, PRINCIPAL_MISMATCH_MESSAGE);
1186
+ }
1187
+ const rec =
1188
+ known ??
1189
+ (() => {
1190
+ const created: ClientRec = {
1191
+ id: clientId,
1192
+ lastSeq: 0,
1193
+ ctxSeq: 0,
1194
+ touched: Date.now(),
1195
+ context: null,
1196
+ results: new Map(),
1197
+ inflight: new Map(),
1198
+ principal: auth,
1199
+ };
1200
+ h.clients.set(clientId, created);
1201
+ return created;
1202
+ })();
934
1203
  rec.touched = Date.now();
935
1204
  const conn: Conn = {
936
1205
  clientId,
@@ -939,9 +1208,11 @@ export const register = (
939
1208
  sawFrame,
940
1209
  lastSentAt: 0,
941
1210
  lease: null,
1211
+ auth,
942
1212
  };
943
1213
  h.conns.add(conn);
944
1214
  gcClients(h);
1215
+ notifyClientRecords(h);
945
1216
  ensureHeartbeat(h);
946
1217
  const { value, meta } = encodeValue(h.stateHost!.snapshot());
947
1218
  const hello: StatewireEnvelope = {
@@ -978,7 +1249,14 @@ const connect = (
978
1249
  };
979
1250
 
980
1251
  const finish = (h: SocketHostState, fin: StatewireHost.Fin) => {
1252
+ if (
1253
+ fin.retry !== undefined &&
1254
+ (!Number.isSafeInteger(fin.retry) || fin.retry < 0)
1255
+ ) {
1256
+ throw new Error("statewire: retry must be a nonnegative safe integer");
1257
+ }
981
1258
  if (h.finished !== null || h.disposed) return;
1259
+ h.commandsHost?.abort?.();
982
1260
  h.finished = fin;
983
1261
  for (const pending of [...h.pendingConns]) {
984
1262
  sendPending(pending, JSON.stringify({ fin }));
@@ -1001,9 +1279,26 @@ const suspend = (h: SocketHostState) => {
1001
1279
  h.stateHost?.setOnOp(null);
1002
1280
  };
1003
1281
 
1282
+ const restoreClientRecords = (
1283
+ records: readonly StatewireHostInternal.ClientRecord[] = [],
1284
+ ): Map<string, ClientRec> =>
1285
+ new Map(
1286
+ records.map((record) => [
1287
+ record.id,
1288
+ {
1289
+ ...record,
1290
+ results: new Map(
1291
+ record.results.map(({ seq, ts, cmd }) => [seq, { ts, cmd }]),
1292
+ ),
1293
+ inflight: new Map(),
1294
+ },
1295
+ ]),
1296
+ );
1297
+
1004
1298
  const useSocketHost = (
1005
1299
  transport: ResourceElement<Statewire>,
1006
1300
  options?: StatewireHostInternal.SocketHostOptions,
1301
+ persistence?: StatewireHostInternal.SocketHostPersistence,
1007
1302
  ): Pick<
1008
1303
  StatewireHostInternal.SocketHost,
1009
1304
  "connect" | "finish" | "stream" | "frames"
@@ -1012,10 +1307,14 @@ const useSocketHost = (
1012
1307
  const host: SocketHostState = {
1013
1308
  heartbeatMs: 15_000,
1014
1309
  wsInitTimeoutMs: 5_000,
1310
+ maxSubscribers: MAX_SUBSCRIBERS,
1311
+ maxInflightCommands: MAX_INFLIGHT_COMMANDS_PER_HOST,
1312
+ streamQueueSize: STREAM_QUEUE_SIZE,
1015
1313
  clientRecordLimit: CLIENT_RECORD_LIMIT,
1016
1314
  finOnIdle: false,
1315
+ pushback: undefined,
1017
1316
  authorize: undefined,
1018
- clients: new Map(),
1317
+ clients: restoreClientRecords(persistence?.clientRecords),
1019
1318
  conns: new Set(),
1020
1319
  pendingConns: new Set(),
1021
1320
  pendingOps: [],
@@ -1029,6 +1328,7 @@ const useSocketHost = (
1029
1328
  stateHost: null,
1030
1329
  commandsHost: null,
1031
1330
  current: null,
1331
+ persistence,
1032
1332
  post: createMacrotaskPoster(() => {
1033
1333
  host.flushScheduled = false;
1034
1334
  flush(host);
@@ -1042,7 +1342,32 @@ const useSocketHost = (
1042
1342
  return host;
1043
1343
  });
1044
1344
  setHeartbeatMs(h, options?.heartbeatIntervalMs ?? 15_000);
1345
+ h.pushback = options?.pushback;
1045
1346
  h.wsInitTimeoutMs = options?.wsInitTimeoutMs ?? 5_000;
1347
+ const maxSubscribers =
1348
+ options?.maxSubscribers === undefined
1349
+ ? MAX_SUBSCRIBERS
1350
+ : options.maxSubscribers;
1351
+ if (!Number.isInteger(maxSubscribers) || maxSubscribers < 1) {
1352
+ throw new TypeError("maxSubscribers must be a positive integer");
1353
+ }
1354
+ h.maxSubscribers = maxSubscribers;
1355
+ const maxInflightCommands =
1356
+ options?.maxInflightCommands === undefined
1357
+ ? MAX_INFLIGHT_COMMANDS_PER_HOST
1358
+ : options.maxInflightCommands;
1359
+ if (!Number.isInteger(maxInflightCommands) || maxInflightCommands < 1) {
1360
+ throw new TypeError("maxInflightCommands must be a positive integer");
1361
+ }
1362
+ h.maxInflightCommands = maxInflightCommands;
1363
+ const streamQueueSize =
1364
+ options?.streamQueueSize === undefined
1365
+ ? STREAM_QUEUE_SIZE
1366
+ : options.streamQueueSize;
1367
+ if (!Number.isInteger(streamQueueSize) || streamQueueSize < 1) {
1368
+ throw new TypeError("streamQueueSize must be a positive integer");
1369
+ }
1370
+ h.streamQueueSize = streamQueueSize;
1046
1371
  h.clientRecordLimit = options?.clientRecordLimit ?? CLIENT_RECORD_LIMIT;
1047
1372
  h.finOnIdle = options?.finOnIdle ?? false;
1048
1373
  h.authorize = options?.authorize;
@@ -1050,6 +1375,7 @@ const useSocketHost = (
1050
1375
  attach(h, value);
1051
1376
  useEffect(() => {
1052
1377
  attach(h, value);
1378
+ scheduleFlush(h);
1053
1379
  });
1054
1380
  useEffect(() => {
1055
1381
  resume(h);
@@ -1069,8 +1395,9 @@ const useSocketHost = (
1069
1395
  export const StatewireSocketHost = (
1070
1396
  transport: ResourceElement<Statewire>,
1071
1397
  options?: StatewireHostInternal.SocketHostOptions,
1398
+ persistence?: StatewireHostInternal.SocketHostPersistence,
1072
1399
  ): StatewireHostInternal.SocketHost => {
1073
- const SocketHostRoot = () => useSocketHost(transport, options);
1400
+ const SocketHostRoot = () => useSocketHost(transport, options, persistence);
1074
1401
  const root = createTapRoot(SocketHostRoot);
1075
1402
  let disposed = false;
1076
1403
  return {
@@ -1084,7 +1411,9 @@ export const StatewireSocketHost = (
1084
1411
  dispose: () => {
1085
1412
  if (disposed) return;
1086
1413
  disposed = true;
1087
- flushTapSync(() => root.getValue().finish({ reason: "evicted" }));
1414
+ flushTapSync(() =>
1415
+ root.getValue().finish({ reason: "reconnect", code: "evicted" }),
1416
+ );
1088
1417
  root.unmount();
1089
1418
  },
1090
1419
  };