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
@@ -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,6 +101,17 @@ 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;
106
+ };
107
+
108
+ type PendingConn = {
109
+ socket: StatewireHostInternal.Socket;
110
+ open: boolean;
111
+ authorizing: boolean;
112
+ queued: string[];
113
+ timer: ReturnType<typeof setTimeout> | null;
114
+ connection: StatewireHostInternal.SocketConnection | null;
54
115
  };
55
116
 
56
117
  // ─── frame parsing ──────────────────────────────────────
@@ -73,7 +134,6 @@ type CmdStatement = {
73
134
  };
74
135
 
75
136
  export type ParsedFrame = {
76
- headers: Record<string, string> | null;
77
137
  ctx: CtxStatement[];
78
138
  cmd: CmdStatement[];
79
139
  };
@@ -99,6 +159,8 @@ const parseLane = (value: unknown): Record<string, unknown>[] => {
99
159
  return statements;
100
160
  };
101
161
 
162
+ const frameEncoder = new TextEncoder();
163
+
102
164
  /** v1 ctx statements state the context whole: one root replace with an object value. */
103
165
  const contextValue = (ops: unknown): Record<string, unknown> => {
104
166
  if (!Array.isArray(ops) || ops.length !== 1) {
@@ -123,46 +185,42 @@ const contextValue = (ops: unknown): Record<string, unknown> => {
123
185
  return value;
124
186
  };
125
187
 
126
- export const parseFrameValue = (
127
- frame: unknown,
128
- first: boolean,
129
- ): ParsedFrame => {
188
+ export const parseFrameValue = (frame: unknown): ParsedFrame => {
130
189
  if (!isPlainObject(frame)) return violation("malformed frame");
131
190
  const keys = Object.keys(frame);
132
191
  if (keys.length === 0) return violation("malformed frame");
133
- const unknown = keys
134
- .filter((key) => key !== "headers" && key !== "ctx" && key !== "cmd")
135
- .sort();
192
+ const unknown = keys.filter((key) => key !== "ctx" && key !== "cmd").sort();
136
193
  if (unknown.length > 0)
137
194
  return violation(`unknown frame key: '${unknown[0]}'`);
138
- let headers: Record<string, string> | null = null;
139
- if (frame["headers"] !== undefined) {
140
- if (!first) return violation("mid-session headers");
141
- const raw = frame["headers"];
142
- if (
143
- !isPlainObject(raw) ||
144
- Object.values(raw).some((value) => typeof value !== "string")
145
- ) {
146
- return violation("malformed headers");
147
- }
148
- headers = raw as Record<string, string>;
149
- }
150
195
  const ctx: CtxStatement[] = [];
151
196
  if (frame["ctx"] !== undefined) {
152
- 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>;
153
203
  try {
154
- ctx.push({
155
- seq: statement["seq"] as number,
156
- value: contextValue(statement["ops"]),
157
- });
204
+ value = contextValue(statement["ops"]);
158
205
  } catch {
159
206
  return violation("invalid-context");
160
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 });
161
215
  }
162
216
  }
163
217
  const cmd: CmdStatement[] = [];
164
218
  if (frame["cmd"] !== undefined) {
165
- 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)) {
166
224
  const method = statement["method"];
167
225
  if (typeof method !== "string" || !("params" in statement)) {
168
226
  return violation("malformed statement");
@@ -175,19 +233,23 @@ export const parseFrameValue = (
175
233
  });
176
234
  }
177
235
  }
178
- return { headers, ctx, cmd };
236
+ return { ctx, cmd };
179
237
  };
180
238
 
181
- export const parseFrame = (raw: string, first: boolean): ParsedFrame => {
239
+ export const parseFrame = (raw: string): ParsedFrame => {
182
240
  let frame: unknown;
183
241
  try {
184
242
  frame = JSON.parse(raw);
185
243
  } catch {
186
244
  return violation("malformed frame");
187
245
  }
188
- return parseFrameValue(frame, first);
246
+ return parseFrameValue(frame);
189
247
  };
190
248
 
249
+ const frameIsOversized = (raw: string): boolean =>
250
+ raw.length > MAX_FRAME_BODY_BYTES ||
251
+ frameEncoder.encode(raw).byteLength > MAX_FRAME_BODY_BYTES;
252
+
191
253
  // ─── verdict encoding ───────────────────────────────────
192
254
 
193
255
  const encodePayload = (
@@ -205,6 +267,8 @@ const cmdOf = (adm: Admission, outcome: CommandsOutcome): StatewireCmd => {
205
267
  const failure =
206
268
  outcome.error instanceof StatewireSendError ? outcome.error.failure : null;
207
269
  switch (failure?.fate) {
270
+ case "crashed":
271
+ return { seq: adm.seq, type: "crashed" };
208
272
  case "failed":
209
273
  return {
210
274
  seq: adm.seq,
@@ -243,15 +307,45 @@ const cmdOf = (adm: Admission, outcome: CommandsOutcome): StatewireCmd => {
243
307
  }
244
308
  };
245
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
+
246
334
  // ─── host ───────────────────────────────────────────────
247
335
 
248
336
  export type SocketHostState = {
249
337
  heartbeatMs: number;
338
+ wsInitTimeoutMs: number;
339
+ maxSubscribers: number;
340
+ maxInflightCommands: number;
341
+ streamQueueSize: number;
250
342
  clientRecordLimit: number;
251
343
  finOnIdle: boolean;
344
+ pushback: (() => number | null) | undefined;
252
345
  authorize: StatewireHostInternal.SocketHostOptions["authorize"];
253
346
  clients: Map<string, ClientRec>;
254
347
  conns: Set<Conn>;
348
+ pendingConns: Set<PendingConn>;
255
349
  pendingOps: WireOp[];
256
350
  pendingApplied: Map<string, number[]>;
257
351
  pendingCmds: Map<string, StatewireCmd[]>;
@@ -263,10 +357,12 @@ export type SocketHostState = {
263
357
  stateHost: StatewireHost.StateHost | null;
264
358
  commandsHost: CommandsHost | null;
265
359
  current: Statewire<unknown> | null;
360
+ persistence: StatewireHostInternal.SocketHostPersistence | undefined;
266
361
  post: () => void;
267
362
  connect: (
268
363
  clientId: string,
269
364
  socket: StatewireHostInternal.Socket,
365
+ options?: StatewireHostInternal.SocketConnectOptions,
270
366
  ) => StatewireHostInternal.SocketConnection | null;
271
367
  finish: (fin: StatewireHost.Fin) => void;
272
368
  stream: (request: Request) => Promise<Response>;
@@ -347,6 +443,11 @@ export const sendTo = (h: SocketHostState, conn: Conn, data: string) => {
347
443
  }
348
444
  };
349
445
 
446
+ export const atSubscriberLimit = (h: SocketHostState): boolean =>
447
+ h.conns.size +
448
+ [...h.pendingConns].filter((conn) => conn.authorizing).length >=
449
+ h.maxSubscribers;
450
+
350
451
  const ensureHeartbeat = (h: SocketHostState) => {
351
452
  if (h.heartbeatTimer !== null || h.heartbeatMs <= 0) return;
352
453
  h.heartbeatTimer = setInterval(() => {
@@ -369,6 +470,7 @@ const setHeartbeatMs = (h: SocketHostState, ms: number) => {
369
470
  };
370
471
 
371
472
  const idleLevel = (h: SocketHostState): boolean => {
473
+ if (h.current?.work?.isIdle === false) return false;
372
474
  for (const rec of h.clients.values()) {
373
475
  if (rec.inflight.size > 0) return false;
374
476
  }
@@ -452,6 +554,24 @@ const queueCmd = (h: SocketHostState, clientId: string, cmd: StatewireCmd) => {
452
554
  scheduleFlush(h);
453
555
  };
454
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
+
455
575
  const retain = (rec: ClientRec, cmd: StatewireCmd) => {
456
576
  const now = Date.now();
457
577
  rec.results.set(cmd.seq, { ts: now, cmd });
@@ -482,6 +602,7 @@ const reemit = (h: SocketHostState, rec: ClientRec, seq: number) => {
482
602
  return;
483
603
  }
484
604
  rec.results.delete(seq);
605
+ notifyClientRecords(h);
485
606
  }
486
607
  const adm = rec.inflight.get(seq);
487
608
  if (adm) {
@@ -504,6 +625,7 @@ const settleCmd = (
504
625
  rec.lastSeq = Math.max(rec.lastSeq, adm.seq);
505
626
  adm.applied = true;
506
627
  queueCmd(h, rec.id, cmd);
628
+ notifyClientRecords(h);
507
629
  };
508
630
 
509
631
  const settleWire = (
@@ -516,8 +638,10 @@ const settleWire = (
516
638
  const callerOf = (
517
639
  h: SocketHostState,
518
640
  rec: ClientRec,
641
+ auth: unknown,
519
642
  ): StatewireHost.Caller => ({
520
643
  clientId: rec.id,
644
+ auth,
521
645
  get context() {
522
646
  return rec.context;
523
647
  },
@@ -547,6 +671,7 @@ const admitCmd = (
547
671
  h: SocketHostState,
548
672
  rec: ClientRec,
549
673
  statement: CmdStatement,
674
+ auth: unknown,
550
675
  ) => {
551
676
  const adm: Admission = {
552
677
  seq: statement.seq,
@@ -556,6 +681,7 @@ const admitCmd = (
556
681
  settled: false,
557
682
  };
558
683
  rec.inflight.set(statement.seq, adm);
684
+ notifyClientRecords(h);
559
685
  if (!Array.isArray(statement.params)) {
560
686
  rejectStatement(h, rec, adm, "params must be an array");
561
687
  return;
@@ -592,11 +718,12 @@ const admitCmd = (
592
718
  method: statement.method,
593
719
  params,
594
720
  clientId: rec.id,
595
- caller: callerOf(h, rec),
721
+ caller: callerOf(h, rec, auth),
596
722
  onApplied: () => {
597
723
  if (adm.settled || adm.applied) return;
598
724
  adm.applied = true;
599
725
  rec.lastSeq = Math.max(rec.lastSeq, adm.seq);
726
+ notifyClientRecords(h);
600
727
  scheduleFlush(h);
601
728
  },
602
729
  onSettle: (outcome) => settleWire(h, rec, adm, outcome),
@@ -610,12 +737,18 @@ const admitCmd = (
610
737
  // {syn} stating both watermarks, nothing consumed. A gap is never a close.
611
738
  export type FrameAdmission =
612
739
  | { type: "ok" }
613
- | { type: "refused"; syn: { syn: { seq: number; ctxSeq: number } } };
740
+ | {
741
+ type: "refused";
742
+ syn: {
743
+ syn: { seq: number; ctxSeq: number; retry?: number };
744
+ };
745
+ };
614
746
 
615
747
  export const admitFrame = (
616
748
  h: SocketHostState,
617
749
  rec: ClientRec,
618
750
  frame: ParsedFrame,
751
+ auth: unknown,
619
752
  ): FrameAdmission => {
620
753
  rec.touched = Date.now();
621
754
  const through = admittedThrough(rec);
@@ -628,56 +761,374 @@ export const admitFrame = (
628
761
  (ctxNew.length > 0 && ctxNew[0]!.seq !== rec.ctxSeq + 1) ||
629
762
  (cmdNew.length > 0 && cmdNew[0]!.seq !== through + 1)
630
763
  ) {
764
+ notifyClientRecords(h);
631
765
  return {
632
766
  type: "refused",
633
767
  syn: { syn: { seq: through, ctxSeq: rec.ctxSeq } },
634
768
  };
635
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
+ }
636
804
  for (const statement of ctxNew) {
637
805
  rec.context = statement.value;
638
806
  rec.ctxSeq = statement.seq;
639
807
  }
640
- for (const statement of cmdNew) admitCmd(h, rec, statement);
808
+ for (const statement of cmdNew) admitCmd(h, rec, statement, auth);
641
809
  if (cmdNew.length > 0) {
642
810
  // Memory host: admitted means retained — dur rides the admitting flush.
643
811
  queueCmd(h, rec.id, { seq: cmdNew.at(-1)!.seq, dur: true });
644
812
  }
813
+ notifyClientRecords(h);
645
814
  scheduleFlush(h);
646
815
  return { type: "ok" };
647
816
  };
648
817
 
649
818
  const message = (h: SocketHostState, conn: Conn, data: string) => {
650
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
+ }
651
834
  const first = !conn.sawFrame;
652
835
  conn.sawFrame = true;
653
836
  let frame: ParsedFrame;
654
837
  try {
655
- frame = parseFrame(data, first);
838
+ frame = first ? parseInitialFrame(data).frame : parseFrame(data);
656
839
  } catch (error) {
657
840
  const reason = violationReason(error);
658
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
+ );
659
852
  closeConn(h, conn, 1008, reason);
660
853
  return;
661
854
  }
662
855
  if (frame.ctx.length === 0 && frame.cmd.length === 0) return;
663
856
  const rec = h.clients.get(conn.clientId)!;
664
- const admission = admitFrame(h, rec, frame);
857
+ const admission = admitFrame(h, rec, frame, conn.auth);
665
858
  if (admission.type === "refused") {
666
859
  sendTo(h, conn, JSON.stringify(admission.syn));
667
860
  }
668
861
  };
669
862
 
863
+ const closePending = (
864
+ h: SocketHostState,
865
+ pending: PendingConn,
866
+ code?: number,
867
+ reason?: string,
868
+ ) => {
869
+ if (!pending.open) return;
870
+ pending.open = false;
871
+ h.pendingConns.delete(pending);
872
+ if (pending.timer !== null) clearTimeout(pending.timer);
873
+ try {
874
+ pending.socket.close(code, reason);
875
+ } catch {
876
+ /* peer already gone */
877
+ }
878
+ };
879
+
880
+ const sendPending = (pending: PendingConn, data: string): boolean => {
881
+ if (!pending.open) return false;
882
+ try {
883
+ pending.socket.send(data);
884
+ return true;
885
+ } catch {
886
+ return false;
887
+ }
888
+ };
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
+
912
+ const rejectPendingAuthorization = (
913
+ h: SocketHostState,
914
+ pending: PendingConn,
915
+ error: unknown,
916
+ ) => {
917
+ const response = error instanceof Response ? error : null;
918
+ const unauthorized = response?.status === 401 || response?.status === 403;
919
+ const status = response?.status;
920
+ const message = unauthorized
921
+ ? "unauthorized"
922
+ : response?.statusText ||
923
+ (status === undefined ? "authorization failed" : `HTTP ${status}`);
924
+ sendPending(
925
+ pending,
926
+ JSON.stringify({
927
+ fin: {
928
+ reason:
929
+ status === 403
930
+ ? "forbidden"
931
+ : unauthorized
932
+ ? "unauthorized"
933
+ : "error",
934
+ message,
935
+ ...(status !== undefined && { status }),
936
+ },
937
+ }),
938
+ );
939
+ closePending(h, pending, unauthorized ? 1008 : 1011, message);
940
+ };
941
+
942
+ const normalizedHeaders = (
943
+ headers: Record<string, string>,
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
+ };
953
+
954
+ const connectAuthorized = (
955
+ h: SocketHostState,
956
+ clientId: string,
957
+ socket: StatewireHostInternal.Socket,
958
+ options: StatewireHostInternal.SocketConnectOptions,
959
+ ): StatewireHostInternal.SocketConnection | null => {
960
+ const authorize = h.authorize;
961
+ if (options.authorized === true) {
962
+ const registration = register(
963
+ h,
964
+ clientId,
965
+ socket,
966
+ options.authorized === true,
967
+ options.auth,
968
+ );
969
+ return registration?.connection ?? null;
970
+ }
971
+ const refuse = (code: number, reason: string) => {
972
+ try {
973
+ socket.close(code, reason);
974
+ } catch {
975
+ /* peer already gone */
976
+ }
977
+ return null;
978
+ };
979
+ if (h.disposed) return refuse(1001, "disposed");
980
+ if (!CLIENT_ID_RE.test(clientId)) return refuse(1008, "invalid client id");
981
+ flush(h);
982
+ if (h.finished !== null) {
983
+ try {
984
+ socket.send(JSON.stringify({ fin: h.finished }));
985
+ } catch {
986
+ /* peer already gone */
987
+ }
988
+ return refuse(1000, "finished");
989
+ }
990
+ const pending: PendingConn = {
991
+ socket,
992
+ open: true,
993
+ authorizing: false,
994
+ queued: [],
995
+ timer: null,
996
+ connection: null,
997
+ };
998
+ h.pendingConns.add(pending);
999
+ pending.timer = setTimeout(
1000
+ () =>
1001
+ rejectPendingAuthorization(
1002
+ h,
1003
+ pending,
1004
+ new Response(null, { status: 401 }),
1005
+ ),
1006
+ h.wsInitTimeoutMs,
1007
+ );
1008
+ (pending.timer as unknown as { unref?: () => void }).unref?.();
1009
+ return {
1010
+ clientId,
1011
+ message: (data) => {
1012
+ if (pending.connection !== null) {
1013
+ pending.connection.message(data);
1014
+ return;
1015
+ }
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
+ }
1030
+ if (pending.authorizing) {
1031
+ pending.queued.push(data);
1032
+ return;
1033
+ }
1034
+ let initial: ReturnType<typeof parseInitialFrame>;
1035
+ try {
1036
+ initial = parseInitialFrame(data);
1037
+ } catch (error) {
1038
+ if (error instanceof Response) {
1039
+ rejectPendingAuthorization(h, pending, error);
1040
+ return;
1041
+ }
1042
+ const reason = violationReason(error);
1043
+ if (reason === null) throw error;
1044
+ sendPending(
1045
+ pending,
1046
+ JSON.stringify({
1047
+ fin: {
1048
+ reason: "protocol-error",
1049
+ message: reason,
1050
+ },
1051
+ }),
1052
+ );
1053
+ closePending(h, pending, 1008, reason);
1054
+ return;
1055
+ }
1056
+ const frame = initial.frame;
1057
+ let headers: Record<string, string>;
1058
+ try {
1059
+ headers = normalizedHeaders(initial.headers);
1060
+ } catch {
1061
+ sendPending(
1062
+ pending,
1063
+ JSON.stringify({
1064
+ fin: {
1065
+ reason: "protocol-error",
1066
+ message: "malformed headers",
1067
+ },
1068
+ }),
1069
+ );
1070
+ closePending(h, pending, 1008, "malformed headers");
1071
+ return;
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
+ }
1087
+ pending.authorizing = true;
1088
+ Promise.resolve()
1089
+ .then(() => authorize?.({ headers }))
1090
+ .then((auth) => {
1091
+ if (!pending.open || h.disposed || h.finished !== null) return;
1092
+ options.onAuthorized?.(auth);
1093
+ pending.open = false;
1094
+ h.pendingConns.delete(pending);
1095
+ if (pending.timer !== null) clearTimeout(pending.timer);
1096
+ const registration = register(h, clientId, socket, true, auth);
1097
+ if (registration === null) return;
1098
+ pending.connection = registration.connection;
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));
1103
+ }
1104
+ for (const data of pending.queued.splice(0))
1105
+ registration.connection.message(data);
1106
+ })
1107
+ .catch((error: unknown) => {
1108
+ if (pending.open) rejectPendingAuthorization(h, pending, error);
1109
+ });
1110
+ },
1111
+ close: () => {
1112
+ if (pending.connection !== null) pending.connection.close();
1113
+ else closePending(h, pending);
1114
+ },
1115
+ };
1116
+ };
1117
+
670
1118
  const gcClients = (h: SocketHostState) => {
671
1119
  let excess = h.clients.size - h.clientRecordLimit;
672
1120
  if (excess <= 0) return;
1121
+ let changed = false;
673
1122
  const live = new Set([...h.conns].map((conn) => conn.clientId));
674
1123
  const oldest = [...h.clients.values()].sort((a, b) => a.touched - b.touched);
675
1124
  for (const rec of oldest) {
676
- if (excess <= 0) return;
1125
+ if (excess <= 0) break;
677
1126
  if (live.has(rec.id) || rec.inflight.size > 0) continue;
678
1127
  h.clients.delete(rec.id);
1128
+ changed = true;
679
1129
  excess -= 1;
680
1130
  }
1131
+ if (changed) notifyClientRecords(h);
681
1132
  };
682
1133
 
683
1134
  export type Registration = {
@@ -690,6 +1141,8 @@ export const register = (
690
1141
  h: SocketHostState,
691
1142
  clientId: string,
692
1143
  socket: StatewireHostInternal.Socket,
1144
+ sawFrame = false,
1145
+ auth: unknown = undefined,
693
1146
  ): Registration | null => {
694
1147
  const refuse = (code: number, reason: string) => {
695
1148
  try {
@@ -711,33 +1164,55 @@ export const register = (
711
1164
  refuse(1000, "finished");
712
1165
  return null;
713
1166
  }
714
- const known = h.clients.has(clientId);
715
- const rec = known
716
- ? h.clients.get(clientId)!
717
- : (() => {
718
- const created: ClientRec = {
719
- id: clientId,
720
- lastSeq: 0,
721
- ctxSeq: 0,
722
- touched: Date.now(),
723
- context: null,
724
- results: new Map(),
725
- inflight: new Map(),
726
- };
727
- h.clients.set(clientId, created);
728
- return created;
729
- })();
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
+ })();
730
1203
  rec.touched = Date.now();
731
1204
  const conn: Conn = {
732
1205
  clientId,
733
1206
  socket,
734
1207
  open: true,
735
- sawFrame: false,
1208
+ sawFrame,
736
1209
  lastSentAt: 0,
737
1210
  lease: null,
1211
+ auth,
738
1212
  };
739
1213
  h.conns.add(conn);
740
1214
  gcClients(h);
1215
+ notifyClientRecords(h);
741
1216
  ensureHeartbeat(h);
742
1217
  const { value, meta } = encodeValue(h.stateHost!.snapshot());
743
1218
  const hello: StatewireEnvelope = {
@@ -768,14 +1243,25 @@ const connect = (
768
1243
  h: SocketHostState,
769
1244
  clientId: string,
770
1245
  socket: StatewireHostInternal.Socket,
1246
+ options: StatewireHostInternal.SocketConnectOptions = {},
771
1247
  ): StatewireHostInternal.SocketConnection | null => {
772
- const registration = register(h, clientId, socket);
773
- return registration?.connection ?? null;
1248
+ return connectAuthorized(h, clientId, socket, options);
774
1249
  };
775
1250
 
776
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
+ }
777
1258
  if (h.finished !== null || h.disposed) return;
1259
+ h.commandsHost?.abort?.();
778
1260
  h.finished = fin;
1261
+ for (const pending of [...h.pendingConns]) {
1262
+ sendPending(pending, JSON.stringify({ fin }));
1263
+ closePending(h, pending, 1000, fin.reason);
1264
+ }
779
1265
  flush(h, fin);
780
1266
  };
781
1267
 
@@ -793,9 +1279,26 @@ const suspend = (h: SocketHostState) => {
793
1279
  h.stateHost?.setOnOp(null);
794
1280
  };
795
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
+
796
1298
  const useSocketHost = (
797
1299
  transport: ResourceElement<Statewire>,
798
1300
  options?: StatewireHostInternal.SocketHostOptions,
1301
+ persistence?: StatewireHostInternal.SocketHostPersistence,
799
1302
  ): Pick<
800
1303
  StatewireHostInternal.SocketHost,
801
1304
  "connect" | "finish" | "stream" | "frames"
@@ -803,11 +1306,17 @@ const useSocketHost = (
803
1306
  const [h] = useState((): SocketHostState => {
804
1307
  const host: SocketHostState = {
805
1308
  heartbeatMs: 15_000,
1309
+ wsInitTimeoutMs: 5_000,
1310
+ maxSubscribers: MAX_SUBSCRIBERS,
1311
+ maxInflightCommands: MAX_INFLIGHT_COMMANDS_PER_HOST,
1312
+ streamQueueSize: STREAM_QUEUE_SIZE,
806
1313
  clientRecordLimit: CLIENT_RECORD_LIMIT,
807
1314
  finOnIdle: false,
1315
+ pushback: undefined,
808
1316
  authorize: undefined,
809
- clients: new Map(),
1317
+ clients: restoreClientRecords(persistence?.clientRecords),
810
1318
  conns: new Set(),
1319
+ pendingConns: new Set(),
811
1320
  pendingOps: [],
812
1321
  pendingApplied: new Map(),
813
1322
  pendingCmds: new Map(),
@@ -819,11 +1328,13 @@ const useSocketHost = (
819
1328
  stateHost: null,
820
1329
  commandsHost: null,
821
1330
  current: null,
1331
+ persistence,
822
1332
  post: createMacrotaskPoster(() => {
823
1333
  host.flushScheduled = false;
824
1334
  flush(host);
825
1335
  }),
826
- connect: (clientId, socket) => connect(host, clientId, socket),
1336
+ connect: (clientId, socket, connectOptions) =>
1337
+ connect(host, clientId, socket, connectOptions),
827
1338
  finish: (fin) => finish(host, fin),
828
1339
  stream: (request) => serveStream(host, request),
829
1340
  frames: (request) => serveFrames(host, request),
@@ -831,6 +1342,32 @@ const useSocketHost = (
831
1342
  return host;
832
1343
  });
833
1344
  setHeartbeatMs(h, options?.heartbeatIntervalMs ?? 15_000);
1345
+ h.pushback = options?.pushback;
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;
834
1371
  h.clientRecordLimit = options?.clientRecordLimit ?? CLIENT_RECORD_LIMIT;
835
1372
  h.finOnIdle = options?.finOnIdle ?? false;
836
1373
  h.authorize = options?.authorize;
@@ -838,6 +1375,7 @@ const useSocketHost = (
838
1375
  attach(h, value);
839
1376
  useEffect(() => {
840
1377
  attach(h, value);
1378
+ scheduleFlush(h);
841
1379
  });
842
1380
  useEffect(() => {
843
1381
  resume(h);
@@ -857,20 +1395,25 @@ const useSocketHost = (
857
1395
  export const StatewireSocketHost = (
858
1396
  transport: ResourceElement<Statewire>,
859
1397
  options?: StatewireHostInternal.SocketHostOptions,
1398
+ persistence?: StatewireHostInternal.SocketHostPersistence,
860
1399
  ): StatewireHostInternal.SocketHost => {
861
- const SocketHostRoot = () => useSocketHost(transport, options);
1400
+ const SocketHostRoot = () => useSocketHost(transport, options, persistence);
862
1401
  const root = createTapRoot(SocketHostRoot);
863
1402
  let disposed = false;
864
1403
  return {
865
- connect: (clientId, socket) =>
866
- flushTapSync(() => root.getValue().connect(clientId, socket)),
1404
+ connect: (clientId, socket, connectOptions) =>
1405
+ flushTapSync(() =>
1406
+ root.getValue().connect(clientId, socket, connectOptions),
1407
+ ),
867
1408
  stream: (request) => flushTapSync(() => root.getValue().stream(request)),
868
1409
  frames: (request) => flushTapSync(() => root.getValue().frames(request)),
869
1410
  finish: (fin) => flushTapSync(() => root.getValue().finish(fin)),
870
1411
  dispose: () => {
871
1412
  if (disposed) return;
872
1413
  disposed = true;
873
- flushTapSync(() => root.getValue().finish({ reason: "evicted" }));
1414
+ flushTapSync(() =>
1415
+ root.getValue().finish({ reason: "reconnect", code: "evicted" }),
1416
+ );
874
1417
  root.unmount();
875
1418
  },
876
1419
  };