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
@@ -1,15 +1,33 @@
1
1
  import { StatewireSendError } from "../types.js";
2
2
  import { getCommandsHost, getStateHost } from "./host.js";
3
3
  import { encodeValue, reviveValue } from "../wire/meta.js";
4
+ import { COMMAND_CAPACITY_RETRY_MS, MAX_INFLIGHT_COMMANDS_PER_HOST } from "../wire/limits.js";
4
5
  import { createMacrotaskPoster } from "../util/macrotask.js";
5
6
  import { frames, stream } from "./http.js";
6
7
  import { useEffect, useState } from "@assistant-ui/tap/react-shim";
7
8
  import { createTapRoot, flushTapSync, useResource } from "@assistant-ui/tap";
8
9
  //#region src/host/socket.ts
9
10
  const CLIENT_ID_RE = /^[A-Za-z0-9._~-]{1,256}$/;
11
+ const PRINCIPAL_MISMATCH_MESSAGE = "client id is bound to another principal";
12
+ /** Structural equality over auth values: primitives, Dates, arrays, and plain
13
+ * objects compare by value; anything else by identity. */
14
+ const authEquals = (a, b) => {
15
+ if (Object.is(a, b)) return true;
16
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
17
+ if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => authEquals(v, b[i]));
18
+ if (typeof a === "object" && typeof b === "object" && a !== null && b !== null && Object.getPrototypeOf(a) === Object.prototype && Object.getPrototypeOf(b) === Object.prototype) {
19
+ const keysA = Object.keys(a);
20
+ const keysB = Object.keys(b);
21
+ return keysA.length === keysB.length && keysA.every((key) => key in b && authEquals(a[key], b[key]));
22
+ }
23
+ return false;
24
+ };
10
25
  const RESULT_TTL_MS = 3e4;
11
26
  const RESULT_LIMIT = 256;
12
27
  const CLIENT_RECORD_LIMIT = 1024;
28
+ const MAX_SUBSCRIBERS = 1024;
29
+ const SUBSCRIBER_LIMIT_MESSAGE = "stream subscriber limit reached";
30
+ const STREAM_QUEUE_SIZE = 256;
13
31
  const violation = (reason) => {
14
32
  throw Object.assign(new Error(reason), { name: "StatewireViolation" });
15
33
  };
@@ -30,6 +48,7 @@ const parseLane = (value) => {
30
48
  }
31
49
  return statements;
32
50
  };
51
+ const frameEncoder = new TextEncoder();
33
52
  /** v1 ctx statements state the context whole: one root replace with an object value. */
34
53
  const contextValue = (ops) => {
35
54
  if (!Array.isArray(ops) || ops.length !== 1) throw new Error("context must be a single-op array");
@@ -39,54 +58,60 @@ const contextValue = (ops) => {
39
58
  if (!isPlainObject(value)) throw new Error("context value must be an object");
40
59
  return value;
41
60
  };
42
- const parseFrameValue = (frame, first) => {
61
+ const parseFrameValue = (frame) => {
43
62
  if (!isPlainObject(frame)) return violation("malformed frame");
44
63
  const keys = Object.keys(frame);
45
64
  if (keys.length === 0) return violation("malformed frame");
46
- const unknown = keys.filter((key) => key !== "headers" && key !== "ctx" && key !== "cmd").sort();
65
+ const unknown = keys.filter((key) => key !== "ctx" && key !== "cmd").sort();
47
66
  if (unknown.length > 0) return violation(`unknown frame key: '${unknown[0]}'`);
48
- let headers = null;
49
- if (frame["headers"] !== void 0) {
50
- if (!first) return violation("mid-session headers");
51
- const raw = frame["headers"];
52
- if (!isPlainObject(raw) || Object.values(raw).some((value) => typeof value !== "string")) return violation("malformed headers");
53
- headers = raw;
54
- }
55
67
  const ctx = [];
56
- if (frame["ctx"] !== void 0) for (const statement of parseLane(frame["ctx"])) try {
57
- ctx.push({
58
- seq: statement["seq"],
59
- value: contextValue(statement["ops"])
60
- });
61
- } catch {
62
- return violation("invalid-context");
68
+ if (frame["ctx"] !== void 0) {
69
+ const lane = frame["ctx"];
70
+ if (Array.isArray(lane) && lane.length > 256) return violation("too many context statements");
71
+ for (const statement of parseLane(lane)) {
72
+ let value;
73
+ try {
74
+ value = contextValue(statement["ops"]);
75
+ } catch {
76
+ return violation("invalid-context");
77
+ }
78
+ if (frameEncoder.encode(JSON.stringify(value)).byteLength > 65536) return violation("oversized-context");
79
+ ctx.push({
80
+ seq: statement["seq"],
81
+ value
82
+ });
83
+ }
63
84
  }
64
85
  const cmd = [];
65
- if (frame["cmd"] !== void 0) for (const statement of parseLane(frame["cmd"])) {
66
- const method = statement["method"];
67
- if (typeof method !== "string" || !("params" in statement)) return violation("malformed statement");
68
- cmd.push({
69
- seq: statement["seq"],
70
- method,
71
- params: statement["params"],
72
- ...statement["meta"] !== void 0 && { meta: statement["meta"] }
73
- });
86
+ if (frame["cmd"] !== void 0) {
87
+ const lane = frame["cmd"];
88
+ if (Array.isArray(lane) && lane.length > 256) return violation("too many commands");
89
+ for (const statement of parseLane(lane)) {
90
+ const method = statement["method"];
91
+ if (typeof method !== "string" || !("params" in statement)) return violation("malformed statement");
92
+ cmd.push({
93
+ seq: statement["seq"],
94
+ method,
95
+ params: statement["params"],
96
+ ...statement["meta"] !== void 0 && { meta: statement["meta"] }
97
+ });
98
+ }
74
99
  }
75
100
  return {
76
- headers,
77
101
  ctx,
78
102
  cmd
79
103
  };
80
104
  };
81
- const parseFrame = (raw, first) => {
105
+ const parseFrame = (raw) => {
82
106
  let frame;
83
107
  try {
84
108
  frame = JSON.parse(raw);
85
109
  } catch {
86
110
  return violation("malformed frame");
87
111
  }
88
- return parseFrameValue(frame, first);
112
+ return parseFrameValue(frame);
89
113
  };
114
+ const frameIsOversized = (raw) => raw.length > 8388608 || frameEncoder.encode(raw).byteLength > 8388608;
90
115
  const encodePayload = (payload) => {
91
116
  if (payload === void 0) return {};
92
117
  const { value, meta } = encodeValue(payload);
@@ -103,6 +128,10 @@ const cmdOf = (adm, outcome) => {
103
128
  };
104
129
  const failure = outcome.error instanceof StatewireSendError ? outcome.error.failure : null;
105
130
  switch (failure?.fate) {
131
+ case "crashed": return {
132
+ seq: adm.seq,
133
+ type: "crashed"
134
+ };
106
135
  case "failed": return {
107
136
  seq: adm.seq,
108
137
  type: "failed",
@@ -131,6 +160,25 @@ const cmdOf = (adm, outcome) => {
131
160
  };
132
161
  }
133
162
  };
163
+ const parseInitialFrame = (raw) => {
164
+ let value;
165
+ try {
166
+ value = JSON.parse(raw);
167
+ } catch {
168
+ return violation("malformed frame");
169
+ }
170
+ if (!isPlainObject(value)) return violation("malformed frame");
171
+ const { headers, ...lanes } = value;
172
+ if (headers === void 0) throw new Response(null, { status: 401 });
173
+ if (!isPlainObject(headers) || Object.values(headers).some((v) => typeof v !== "string")) return violation("malformed headers");
174
+ return {
175
+ headers,
176
+ frame: Object.keys(lanes).length ? parseFrameValue(lanes) : {
177
+ ctx: [],
178
+ cmd: []
179
+ }
180
+ };
181
+ };
134
182
  const scheduleFlush = (h) => {
135
183
  if (h.flushScheduled || h.disposed) return;
136
184
  h.flushScheduled = true;
@@ -181,6 +229,7 @@ const sendTo = (h, conn, data) => {
181
229
  closeConn(h, conn);
182
230
  }
183
231
  };
232
+ const atSubscriberLimit = (h) => h.conns.size + [...h.pendingConns].filter((conn) => conn.authorizing).length >= h.maxSubscribers;
184
233
  const ensureHeartbeat = (h) => {
185
234
  if (h.heartbeatTimer !== null || h.heartbeatMs <= 0) return;
186
235
  h.heartbeatTimer = setInterval(() => {
@@ -199,6 +248,7 @@ const setHeartbeatMs = (h, ms) => {
199
248
  }
200
249
  };
201
250
  const idleLevel = (h) => {
251
+ if (h.current?.work?.isIdle === false) return false;
202
252
  for (const rec of h.clients.values()) if (rec.inflight.size > 0) return false;
203
253
  return true;
204
254
  };
@@ -254,6 +304,21 @@ const queueCmd = (h, clientId, cmd) => {
254
304
  else h.pendingCmds.set(clientId, [cmd]);
255
305
  scheduleFlush(h);
256
306
  };
307
+ const notifyClientRecords = (h) => {
308
+ h.persistence?.onClientRecordsChange?.([...h.clients.values()].map((rec) => ({
309
+ id: rec.id,
310
+ lastSeq: admittedThrough(rec),
311
+ ctxSeq: rec.ctxSeq,
312
+ touched: rec.touched,
313
+ context: rec.context,
314
+ results: [...rec.results].map(([seq, entry]) => ({
315
+ seq,
316
+ ts: entry.ts,
317
+ cmd: entry.cmd
318
+ })),
319
+ principal: rec.principal
320
+ })));
321
+ };
257
322
  const retain = (rec, cmd) => {
258
323
  const now = Date.now();
259
324
  rec.results.set(cmd.seq, {
@@ -274,6 +339,7 @@ const reemit = (h, rec, seq) => {
274
339
  return;
275
340
  }
276
341
  rec.results.delete(seq);
342
+ notifyClientRecords(h);
277
343
  }
278
344
  const adm = rec.inflight.get(seq);
279
345
  if (adm) {
@@ -293,10 +359,12 @@ const settleCmd = (h, rec, adm, cmd) => {
293
359
  rec.lastSeq = Math.max(rec.lastSeq, adm.seq);
294
360
  adm.applied = true;
295
361
  queueCmd(h, rec.id, cmd);
362
+ notifyClientRecords(h);
296
363
  };
297
364
  const settleWire = (h, rec, adm, outcome) => settleCmd(h, rec, adm, cmdOf(adm, outcome));
298
- const callerOf = (h, rec) => ({
365
+ const callerOf = (h, rec, auth) => ({
299
366
  clientId: rec.id,
367
+ auth,
300
368
  get context() {
301
369
  return rec.context;
302
370
  },
@@ -312,7 +380,7 @@ const rejectStatement = (h, rec, adm, message) => settleCmd(h, rec, adm, {
312
380
  code: "invalid-params",
313
381
  message
314
382
  });
315
- const admitCmd = (h, rec, statement) => {
383
+ const admitCmd = (h, rec, statement, auth) => {
316
384
  const adm = {
317
385
  seq: statement.seq,
318
386
  method: statement.method,
@@ -321,6 +389,7 @@ const admitCmd = (h, rec, statement) => {
321
389
  settled: false
322
390
  };
323
391
  rec.inflight.set(statement.seq, adm);
392
+ notifyClientRecords(h);
324
393
  if (!Array.isArray(statement.params)) {
325
394
  rejectStatement(h, rec, adm, "params must be an array");
326
395
  return;
@@ -349,11 +418,12 @@ const admitCmd = (h, rec, statement) => {
349
418
  method: statement.method,
350
419
  params,
351
420
  clientId: rec.id,
352
- caller: callerOf(h, rec),
421
+ caller: callerOf(h, rec, auth),
353
422
  onApplied: () => {
354
423
  if (adm.settled || adm.applied) return;
355
424
  adm.applied = true;
356
425
  rec.lastSeq = Math.max(rec.lastSeq, adm.seq);
426
+ notifyClientRecords(h);
357
427
  scheduleFlush(h);
358
428
  },
359
429
  onSettle: (outcome) => settleWire(h, rec, adm, outcome)
@@ -364,47 +434,89 @@ const admitCmd = (h, rec, statement) => {
364
434
  });
365
435
  });
366
436
  };
367
- const admitFrame = (h, rec, frame) => {
437
+ const admitFrame = (h, rec, frame, auth) => {
368
438
  rec.touched = Date.now();
369
439
  const through = admittedThrough(rec);
370
440
  for (const statement of frame.cmd) if (statement.seq <= through) reemit(h, rec, statement.seq);
371
441
  const ctxNew = frame.ctx.filter((s) => s.seq > rec.ctxSeq);
372
442
  const cmdNew = frame.cmd.filter((s) => s.seq > through);
373
- if (ctxNew.length > 0 && ctxNew[0].seq !== rec.ctxSeq + 1 || cmdNew.length > 0 && cmdNew[0].seq !== through + 1) return {
374
- type: "refused",
375
- syn: { syn: {
376
- seq: through,
377
- ctxSeq: rec.ctxSeq
378
- } }
379
- };
443
+ if (ctxNew.length > 0 && ctxNew[0].seq !== rec.ctxSeq + 1 || cmdNew.length > 0 && cmdNew[0].seq !== through + 1) {
444
+ notifyClientRecords(h);
445
+ return {
446
+ type: "refused",
447
+ syn: { syn: {
448
+ seq: through,
449
+ ctxSeq: rec.ctxSeq
450
+ } }
451
+ };
452
+ }
453
+ if (cmdNew.length > 0) {
454
+ let hostCommandLoad = 0;
455
+ for (const client of h.clients.values()) hostCommandLoad += client.inflight.size;
456
+ if (rec.inflight.size + cmdNew.length > 256 || hostCommandLoad + cmdNew.length > h.maxInflightCommands) return {
457
+ type: "refused",
458
+ syn: { syn: {
459
+ seq: through,
460
+ ctxSeq: rec.ctxSeq,
461
+ retry: COMMAND_CAPACITY_RETRY_MS
462
+ } }
463
+ };
464
+ }
465
+ if ((ctxNew.length || cmdNew.length) && h.pushback) {
466
+ const retry = h.pushback();
467
+ if (retry !== null) {
468
+ if (!Number.isSafeInteger(retry) || retry < 0) throw new TypeError("pushback must return a nonnegative safe integer or null");
469
+ return {
470
+ type: "refused",
471
+ syn: { syn: {
472
+ seq: through,
473
+ ctxSeq: rec.ctxSeq,
474
+ retry
475
+ } }
476
+ };
477
+ }
478
+ }
380
479
  for (const statement of ctxNew) {
381
480
  rec.context = statement.value;
382
481
  rec.ctxSeq = statement.seq;
383
482
  }
384
- for (const statement of cmdNew) admitCmd(h, rec, statement);
483
+ for (const statement of cmdNew) admitCmd(h, rec, statement, auth);
385
484
  if (cmdNew.length > 0) queueCmd(h, rec.id, {
386
485
  seq: cmdNew.at(-1).seq,
387
486
  dur: true
388
487
  });
488
+ notifyClientRecords(h);
389
489
  scheduleFlush(h);
390
490
  return { type: "ok" };
391
491
  };
392
492
  const message = (h, conn, data) => {
393
493
  if (!conn.open || h.disposed || h.finished !== null) return;
494
+ if (frameIsOversized(data)) {
495
+ sendTo(h, conn, JSON.stringify({ fin: {
496
+ reason: "protocol-error",
497
+ message: "frame too large"
498
+ } }));
499
+ closeConn(h, conn, 1009, "frame too large");
500
+ return;
501
+ }
394
502
  const first = !conn.sawFrame;
395
503
  conn.sawFrame = true;
396
504
  let frame;
397
505
  try {
398
- frame = parseFrame(data, first);
506
+ frame = first ? parseInitialFrame(data).frame : parseFrame(data);
399
507
  } catch (error) {
400
508
  const reason = violationReason(error);
401
509
  if (reason === null) throw error;
510
+ sendTo(h, conn, JSON.stringify({ fin: {
511
+ reason: "protocol-error",
512
+ message: reason
513
+ } }));
402
514
  closeConn(h, conn, 1008, reason);
403
515
  return;
404
516
  }
405
517
  if (frame.ctx.length === 0 && frame.cmd.length === 0) return;
406
518
  const rec = h.clients.get(conn.clientId);
407
- const admission = admitFrame(h, rec, frame);
519
+ const admission = admitFrame(h, rec, frame, conn.auth);
408
520
  if (admission.type === "refused") sendTo(h, conn, JSON.stringify(admission.syn));
409
521
  };
410
522
  const closePending = (h, pending, code, reason) => {
@@ -425,22 +537,43 @@ const sendPending = (pending, data) => {
425
537
  return false;
426
538
  }
427
539
  };
540
+ const refuseSubscriberLimit = (socket) => {
541
+ try {
542
+ socket.send(JSON.stringify({ fin: {
543
+ reason: "reconnect",
544
+ code: "overloaded",
545
+ message: SUBSCRIBER_LIMIT_MESSAGE
546
+ } }));
547
+ } catch {}
548
+ try {
549
+ socket.close(1013, SUBSCRIBER_LIMIT_MESSAGE);
550
+ } catch {}
551
+ return null;
552
+ };
428
553
  const rejectPendingAuthorization = (h, pending, error) => {
429
554
  const response = error instanceof Response ? error : null;
430
555
  const unauthorized = response?.status === 401 || response?.status === 403;
431
556
  const status = response?.status;
432
557
  const message = unauthorized ? "unauthorized" : response?.statusText || (status === void 0 ? "authorization failed" : `HTTP ${status}`);
433
558
  sendPending(pending, JSON.stringify({ fin: {
434
- reason: unauthorized ? "unauthorized" : "error",
559
+ reason: status === 403 ? "forbidden" : unauthorized ? "unauthorized" : "error",
435
560
  message,
436
561
  ...status !== void 0 && { status }
437
562
  } }));
438
563
  closePending(h, pending, unauthorized ? 1008 : 1011, message);
439
564
  };
440
- const normalizedHeaders = (headers) => Object.fromEntries(new Headers(headers));
565
+ const normalizedHeaders = (headers) => {
566
+ const seen = /* @__PURE__ */ new Set();
567
+ for (const name of Object.keys(headers)) {
568
+ const key = name.toLowerCase();
569
+ if (seen.has(key)) throw new TypeError("duplicate header name");
570
+ seen.add(key);
571
+ }
572
+ return Object.fromEntries(new Headers(headers));
573
+ };
441
574
  const connectAuthorized = (h, clientId, socket, options) => {
442
575
  const authorize = h.authorize;
443
- if (authorize === void 0 || options.authorized === true) return register(h, clientId, socket, options.authorized === true)?.connection ?? null;
576
+ if (options.authorized === true) return register(h, clientId, socket, options.authorized === true, options.auth)?.connection ?? null;
444
577
  const refuse = (code, reason) => {
445
578
  try {
446
579
  socket.close(code, reason);
@@ -460,6 +593,7 @@ const connectAuthorized = (h, clientId, socket, options) => {
460
593
  socket,
461
594
  open: true,
462
595
  authorizing: false,
596
+ queued: [],
463
597
  timer: null,
464
598
  connection: null
465
599
  };
@@ -474,51 +608,71 @@ const connectAuthorized = (h, clientId, socket, options) => {
474
608
  return;
475
609
  }
476
610
  if (!pending.open || h.disposed || h.finished !== null) return;
611
+ if (frameIsOversized(data)) {
612
+ sendPending(pending, JSON.stringify({ fin: {
613
+ reason: "protocol-error",
614
+ message: "frame too large"
615
+ } }));
616
+ closePending(h, pending, 1009, "frame too large");
617
+ return;
618
+ }
477
619
  if (pending.authorizing) {
478
- closePending(h, pending, 1008, "authorization pending");
620
+ pending.queued.push(data);
479
621
  return;
480
622
  }
481
- let frame;
623
+ let initial;
482
624
  try {
483
- frame = parseFrame(data, true);
625
+ initial = parseInitialFrame(data);
484
626
  } catch (error) {
627
+ if (error instanceof Response) {
628
+ rejectPendingAuthorization(h, pending, error);
629
+ return;
630
+ }
485
631
  const reason = violationReason(error);
486
632
  if (reason === null) throw error;
487
633
  sendPending(pending, JSON.stringify({ fin: {
488
- reason: "error",
489
- message: `protocol violation: ${reason}`
634
+ reason: "protocol-error",
635
+ message: reason
490
636
  } }));
491
637
  closePending(h, pending, 1008, reason);
492
638
  return;
493
639
  }
494
- if (frame.headers === null) {
495
- rejectPendingAuthorization(h, pending, new Response(null, { status: 401 }));
496
- return;
497
- }
640
+ const frame = initial.frame;
498
641
  let headers;
499
642
  try {
500
- headers = normalizedHeaders(frame.headers);
643
+ headers = normalizedHeaders(initial.headers);
501
644
  } catch {
502
645
  sendPending(pending, JSON.stringify({ fin: {
503
- reason: "error",
504
- message: "protocol violation: malformed headers"
646
+ reason: "protocol-error",
647
+ message: "malformed headers"
505
648
  } }));
506
649
  closePending(h, pending, 1008, "malformed headers");
507
650
  return;
508
651
  }
652
+ if (atSubscriberLimit(h)) {
653
+ sendPending(pending, JSON.stringify({ fin: {
654
+ reason: "reconnect",
655
+ code: "overloaded",
656
+ message: SUBSCRIBER_LIMIT_MESSAGE
657
+ } }));
658
+ closePending(h, pending, 1013, SUBSCRIBER_LIMIT_MESSAGE);
659
+ return;
660
+ }
509
661
  pending.authorizing = true;
510
- Promise.resolve().then(() => authorize({ headers })).then(() => {
662
+ Promise.resolve().then(() => authorize?.({ headers })).then((auth) => {
511
663
  if (!pending.open || h.disposed || h.finished !== null) return;
512
- options.onAuthorized?.();
664
+ options.onAuthorized?.(auth);
513
665
  pending.open = false;
514
666
  h.pendingConns.delete(pending);
515
667
  if (pending.timer !== null) clearTimeout(pending.timer);
516
- const registration = register(h, clientId, socket, true);
668
+ const registration = register(h, clientId, socket, true, auth);
517
669
  if (registration === null) return;
518
670
  pending.connection = registration.connection;
519
- if (frame.ctx.length === 0 && frame.cmd.length === 0) return;
520
- const admission = admitFrame(h, registration.rec, frame);
521
- if (admission.type === "refused") sendTo(h, registration.conn, JSON.stringify(admission.syn));
671
+ if (frame.ctx.length || frame.cmd.length) {
672
+ const admission = admitFrame(h, registration.rec, frame, auth);
673
+ if (admission.type === "refused") sendTo(h, registration.conn, JSON.stringify(admission.syn));
674
+ }
675
+ for (const data of pending.queued.splice(0)) registration.connection.message(data);
522
676
  }).catch((error) => {
523
677
  if (pending.open) rejectPendingAuthorization(h, pending, error);
524
678
  });
@@ -532,16 +686,19 @@ const connectAuthorized = (h, clientId, socket, options) => {
532
686
  const gcClients = (h) => {
533
687
  let excess = h.clients.size - h.clientRecordLimit;
534
688
  if (excess <= 0) return;
689
+ let changed = false;
535
690
  const live = new Set([...h.conns].map((conn) => conn.clientId));
536
691
  const oldest = [...h.clients.values()].sort((a, b) => a.touched - b.touched);
537
692
  for (const rec of oldest) {
538
- if (excess <= 0) return;
693
+ if (excess <= 0) break;
539
694
  if (live.has(rec.id) || rec.inflight.size > 0) continue;
540
695
  h.clients.delete(rec.id);
696
+ changed = true;
541
697
  excess -= 1;
542
698
  }
699
+ if (changed) notifyClientRecords(h);
543
700
  };
544
- const register = (h, clientId, socket, sawFrame = false) => {
701
+ const register = (h, clientId, socket, sawFrame = false, auth = void 0) => {
545
702
  const refuse = (code, reason) => {
546
703
  try {
547
704
  socket.close(code, reason);
@@ -558,7 +715,19 @@ const register = (h, clientId, socket, sawFrame = false) => {
558
715
  refuse(1e3, "finished");
559
716
  return null;
560
717
  }
561
- const rec = h.clients.has(clientId) ? h.clients.get(clientId) : (() => {
718
+ if (atSubscriberLimit(h)) return refuseSubscriberLimit(socket);
719
+ const known = h.clients.get(clientId);
720
+ if (known !== void 0 && !authEquals(known.principal, auth)) {
721
+ try {
722
+ socket.send(JSON.stringify({ fin: {
723
+ reason: "forbidden",
724
+ message: PRINCIPAL_MISMATCH_MESSAGE,
725
+ status: 403
726
+ } }));
727
+ } catch {}
728
+ return refuse(1008, PRINCIPAL_MISMATCH_MESSAGE);
729
+ }
730
+ const rec = known ?? (() => {
562
731
  const created = {
563
732
  id: clientId,
564
733
  lastSeq: 0,
@@ -566,7 +735,8 @@ const register = (h, clientId, socket, sawFrame = false) => {
566
735
  touched: Date.now(),
567
736
  context: null,
568
737
  results: /* @__PURE__ */ new Map(),
569
- inflight: /* @__PURE__ */ new Map()
738
+ inflight: /* @__PURE__ */ new Map(),
739
+ principal: auth
570
740
  };
571
741
  h.clients.set(clientId, created);
572
742
  return created;
@@ -578,10 +748,12 @@ const register = (h, clientId, socket, sawFrame = false) => {
578
748
  open: true,
579
749
  sawFrame,
580
750
  lastSentAt: 0,
581
- lease: null
751
+ lease: null,
752
+ auth
582
753
  };
583
754
  h.conns.add(conn);
584
755
  gcClients(h);
756
+ notifyClientRecords(h);
585
757
  ensureHeartbeat(h);
586
758
  const { value, meta } = encodeValue(h.stateHost.snapshot());
587
759
  const hello = {
@@ -612,7 +784,9 @@ const connect = (h, clientId, socket, options = {}) => {
612
784
  return connectAuthorized(h, clientId, socket, options);
613
785
  };
614
786
  const finish = (h, fin) => {
787
+ if (fin.retry !== void 0 && (!Number.isSafeInteger(fin.retry) || fin.retry < 0)) throw new Error("statewire: retry must be a nonnegative safe integer");
615
788
  if (h.finished !== null || h.disposed) return;
789
+ h.commandsHost?.abort?.();
616
790
  h.finished = fin;
617
791
  for (const pending of [...h.pendingConns]) {
618
792
  sendPending(pending, JSON.stringify({ fin }));
@@ -632,15 +806,27 @@ const suspend = (h) => {
632
806
  }
633
807
  h.stateHost?.setOnOp(null);
634
808
  };
635
- const useSocketHost = (transport, options) => {
809
+ const restoreClientRecords = (records = []) => new Map(records.map((record) => [record.id, {
810
+ ...record,
811
+ results: new Map(record.results.map(({ seq, ts, cmd }) => [seq, {
812
+ ts,
813
+ cmd
814
+ }])),
815
+ inflight: /* @__PURE__ */ new Map()
816
+ }]));
817
+ const useSocketHost = (transport, options, persistence) => {
636
818
  const [h] = useState(() => {
637
819
  const host = {
638
820
  heartbeatMs: 15e3,
639
821
  wsInitTimeoutMs: 5e3,
822
+ maxSubscribers: MAX_SUBSCRIBERS,
823
+ maxInflightCommands: MAX_INFLIGHT_COMMANDS_PER_HOST,
824
+ streamQueueSize: STREAM_QUEUE_SIZE,
640
825
  clientRecordLimit: CLIENT_RECORD_LIMIT,
641
826
  finOnIdle: false,
827
+ pushback: void 0,
642
828
  authorize: void 0,
643
- clients: /* @__PURE__ */ new Map(),
829
+ clients: restoreClientRecords(persistence?.clientRecords),
644
830
  conns: /* @__PURE__ */ new Set(),
645
831
  pendingConns: /* @__PURE__ */ new Set(),
646
832
  pendingOps: [],
@@ -654,6 +840,7 @@ const useSocketHost = (transport, options) => {
654
840
  stateHost: null,
655
841
  commandsHost: null,
656
842
  current: null,
843
+ persistence,
657
844
  post: createMacrotaskPoster(() => {
658
845
  host.flushScheduled = false;
659
846
  flush(host);
@@ -666,7 +853,17 @@ const useSocketHost = (transport, options) => {
666
853
  return host;
667
854
  });
668
855
  setHeartbeatMs(h, options?.heartbeatIntervalMs ?? 15e3);
856
+ h.pushback = options?.pushback;
669
857
  h.wsInitTimeoutMs = options?.wsInitTimeoutMs ?? 5e3;
858
+ const maxSubscribers = options?.maxSubscribers === void 0 ? MAX_SUBSCRIBERS : options.maxSubscribers;
859
+ if (!Number.isInteger(maxSubscribers) || maxSubscribers < 1) throw new TypeError("maxSubscribers must be a positive integer");
860
+ h.maxSubscribers = maxSubscribers;
861
+ const maxInflightCommands = options?.maxInflightCommands === void 0 ? MAX_INFLIGHT_COMMANDS_PER_HOST : options.maxInflightCommands;
862
+ if (!Number.isInteger(maxInflightCommands) || maxInflightCommands < 1) throw new TypeError("maxInflightCommands must be a positive integer");
863
+ h.maxInflightCommands = maxInflightCommands;
864
+ const streamQueueSize = options?.streamQueueSize === void 0 ? STREAM_QUEUE_SIZE : options.streamQueueSize;
865
+ if (!Number.isInteger(streamQueueSize) || streamQueueSize < 1) throw new TypeError("streamQueueSize must be a positive integer");
866
+ h.streamQueueSize = streamQueueSize;
670
867
  h.clientRecordLimit = options?.clientRecordLimit ?? CLIENT_RECORD_LIMIT;
671
868
  h.finOnIdle = options?.finOnIdle ?? false;
672
869
  h.authorize = options?.authorize;
@@ -674,6 +871,7 @@ const useSocketHost = (transport, options) => {
674
871
  attach(h, value);
675
872
  useEffect(() => {
676
873
  attach(h, value);
874
+ scheduleFlush(h);
677
875
  });
678
876
  useEffect(() => {
679
877
  resume(h);
@@ -689,9 +887,9 @@ const useSocketHost = (transport, options) => {
689
887
  * arrays fanned out to every attach of a client id, idle edges, and `fin` on
690
888
  * finish.
691
889
  */
692
- const StatewireSocketHost = (transport, options) => {
890
+ const StatewireSocketHost = (transport, options, persistence) => {
693
891
  const SocketHostRoot = () => {
694
- return useSocketHost(transport, options);
892
+ return useSocketHost(transport, options, persistence);
695
893
  };
696
894
  const root = createTapRoot(SocketHostRoot);
697
895
  let disposed = false;
@@ -703,12 +901,15 @@ const StatewireSocketHost = (transport, options) => {
703
901
  dispose: () => {
704
902
  if (disposed) return;
705
903
  disposed = true;
706
- flushTapSync(() => root.getValue().finish({ reason: "evicted" }));
904
+ flushTapSync(() => root.getValue().finish({
905
+ reason: "reconnect",
906
+ code: "evicted"
907
+ }));
707
908
  root.unmount();
708
909
  }
709
910
  };
710
911
  };
711
912
  //#endregion
712
- export { CLIENT_ID_RE, StatewireSocketHost, admitFrame, closeConn, flush, parseFrame, parseFrameValue, register, sendTo };
913
+ export { CLIENT_ID_RE, PRINCIPAL_MISMATCH_MESSAGE, SUBSCRIBER_LIMIT_MESSAGE, StatewireSocketHost, admitFrame, atSubscriberLimit, authEquals, closeConn, flush, parseFrame, parseFrameValue, register, sendTo };
713
914
 
714
915
  //# sourceMappingURL=socket.js.map