statewire 0.13.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +29 -78
  2. package/dist/client/connection.d.ts +20 -6
  3. package/dist/client/connection.d.ts.map +1 -1
  4. package/dist/client/connection.js +120 -52
  5. package/dist/client/connection.js.map +1 -1
  6. package/dist/client/core.d.ts +3 -2
  7. package/dist/client/core.d.ts.map +1 -1
  8. package/dist/client/core.js +10 -4
  9. package/dist/client/core.js.map +1 -1
  10. package/dist/client/session.d.ts +2 -0
  11. package/dist/client/session.d.ts.map +1 -1
  12. package/dist/client/session.js +97 -17
  13. package/dist/client/session.js.map +1 -1
  14. package/dist/host/adapter.d.ts +1 -1
  15. package/dist/host/adapter.d.ts.map +1 -1
  16. package/dist/host/adapter.js +7 -1
  17. package/dist/host/adapter.js.map +1 -1
  18. package/dist/host/host.d.ts +1 -0
  19. package/dist/host/host.d.ts.map +1 -1
  20. package/dist/host/host.js +45 -5
  21. package/dist/host/host.js.map +1 -1
  22. package/dist/host/http.d.ts +2 -1
  23. package/dist/host/http.d.ts.map +1 -1
  24. package/dist/host/http.js +108 -47
  25. package/dist/host/http.js.map +1 -1
  26. package/dist/host/socket.d.ts +33 -8
  27. package/dist/host/socket.d.ts.map +1 -1
  28. package/dist/host/socket.js +388 -58
  29. package/dist/host/socket.js.map +1 -1
  30. package/dist/host/work.d.ts +7 -0
  31. package/dist/host/work.d.ts.map +1 -0
  32. package/dist/host/work.js +53 -0
  33. package/dist/host/work.js.map +1 -0
  34. package/dist/host-internal.d.ts +42 -4
  35. package/dist/host-internal.d.ts.map +1 -1
  36. package/dist/host-internal.js +2 -1
  37. package/dist/host.d.ts +16 -3
  38. package/dist/host.d.ts.map +1 -1
  39. package/dist/host.js +2 -1
  40. package/dist/statewire.d.ts +21 -11
  41. package/dist/statewire.d.ts.map +1 -1
  42. package/dist/statewire.js +2 -2
  43. package/dist/statewire.js.map +1 -1
  44. package/dist/transports/sse.d.ts +1 -4
  45. package/dist/transports/sse.d.ts.map +1 -1
  46. package/dist/transports/sse.js +47 -56
  47. package/dist/transports/sse.js.map +1 -1
  48. package/dist/transports/testing.js +2 -2
  49. package/dist/transports/testing.js.map +1 -1
  50. package/dist/transports/ws.d.ts +1 -1
  51. package/dist/transports/ws.d.ts.map +1 -1
  52. package/dist/transports/ws.js +54 -41
  53. package/dist/transports/ws.js.map +1 -1
  54. package/dist/types.d.ts +3 -0
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/types.js.map +1 -1
  57. package/dist/wire/documents.d.ts +20 -0
  58. package/dist/wire/documents.d.ts.map +1 -0
  59. package/dist/wire/documents.js +74 -0
  60. package/dist/wire/documents.js.map +1 -0
  61. package/dist/wire/limits.d.ts +10 -0
  62. package/dist/wire/limits.d.ts.map +1 -0
  63. package/dist/wire/limits.js +11 -0
  64. package/dist/wire/limits.js.map +1 -0
  65. package/dist/wire/meta.d.ts.map +1 -1
  66. package/dist/wire/meta.js +1 -1
  67. package/dist/wire/meta.js.map +1 -1
  68. package/dist/wire/ops.d.ts.map +1 -1
  69. package/dist/wire/ops.js +1 -2
  70. package/dist/wire/ops.js.map +1 -1
  71. package/dist/wire/version.d.ts +7 -0
  72. package/dist/wire/version.d.ts.map +1 -0
  73. package/dist/wire/version.js +8 -0
  74. package/dist/wire/version.js.map +1 -0
  75. package/package.json +1 -1
  76. package/src/client/connection.ts +213 -67
  77. package/src/client/core.ts +10 -3
  78. package/src/client/session.ts +106 -30
  79. package/src/host/adapter.ts +10 -2
  80. package/src/host/host.ts +54 -4
  81. package/src/host/http.ts +168 -64
  82. package/src/host/socket.ts +603 -60
  83. package/src/host/work.ts +50 -0
  84. package/src/host-internal.ts +44 -3
  85. package/src/host.ts +18 -2
  86. package/src/statewire.ts +33 -10
  87. package/src/transports/sse.ts +60 -68
  88. package/src/transports/testing.ts +2 -2
  89. package/src/transports/ws.ts +29 -48
  90. package/src/types.ts +1 -0
  91. package/src/wire/documents.ts +109 -0
  92. package/src/wire/limits.ts +6 -0
  93. package/src/wire/meta.ts +6 -1
  94. package/src/wire/ops.ts +1 -2
  95. package/src/wire/version.ts +3 -0
@@ -1,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,62 +434,271 @@ 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
  };
522
+ const closePending = (h, pending, code, reason) => {
523
+ if (!pending.open) return;
524
+ pending.open = false;
525
+ h.pendingConns.delete(pending);
526
+ if (pending.timer !== null) clearTimeout(pending.timer);
527
+ try {
528
+ pending.socket.close(code, reason);
529
+ } catch {}
530
+ };
531
+ const sendPending = (pending, data) => {
532
+ if (!pending.open) return false;
533
+ try {
534
+ pending.socket.send(data);
535
+ return true;
536
+ } catch {
537
+ return false;
538
+ }
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
+ };
553
+ const rejectPendingAuthorization = (h, pending, error) => {
554
+ const response = error instanceof Response ? error : null;
555
+ const unauthorized = response?.status === 401 || response?.status === 403;
556
+ const status = response?.status;
557
+ const message = unauthorized ? "unauthorized" : response?.statusText || (status === void 0 ? "authorization failed" : `HTTP ${status}`);
558
+ sendPending(pending, JSON.stringify({ fin: {
559
+ reason: status === 403 ? "forbidden" : unauthorized ? "unauthorized" : "error",
560
+ message,
561
+ ...status !== void 0 && { status }
562
+ } }));
563
+ closePending(h, pending, unauthorized ? 1008 : 1011, message);
564
+ };
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
+ };
574
+ const connectAuthorized = (h, clientId, socket, options) => {
575
+ const authorize = h.authorize;
576
+ if (options.authorized === true) return register(h, clientId, socket, options.authorized === true, options.auth)?.connection ?? null;
577
+ const refuse = (code, reason) => {
578
+ try {
579
+ socket.close(code, reason);
580
+ } catch {}
581
+ return null;
582
+ };
583
+ if (h.disposed) return refuse(1001, "disposed");
584
+ if (!CLIENT_ID_RE.test(clientId)) return refuse(1008, "invalid client id");
585
+ flush(h);
586
+ if (h.finished !== null) {
587
+ try {
588
+ socket.send(JSON.stringify({ fin: h.finished }));
589
+ } catch {}
590
+ return refuse(1e3, "finished");
591
+ }
592
+ const pending = {
593
+ socket,
594
+ open: true,
595
+ authorizing: false,
596
+ queued: [],
597
+ timer: null,
598
+ connection: null
599
+ };
600
+ h.pendingConns.add(pending);
601
+ pending.timer = setTimeout(() => rejectPendingAuthorization(h, pending, new Response(null, { status: 401 })), h.wsInitTimeoutMs);
602
+ pending.timer.unref?.();
603
+ return {
604
+ clientId,
605
+ message: (data) => {
606
+ if (pending.connection !== null) {
607
+ pending.connection.message(data);
608
+ return;
609
+ }
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
+ }
619
+ if (pending.authorizing) {
620
+ pending.queued.push(data);
621
+ return;
622
+ }
623
+ let initial;
624
+ try {
625
+ initial = parseInitialFrame(data);
626
+ } catch (error) {
627
+ if (error instanceof Response) {
628
+ rejectPendingAuthorization(h, pending, error);
629
+ return;
630
+ }
631
+ const reason = violationReason(error);
632
+ if (reason === null) throw error;
633
+ sendPending(pending, JSON.stringify({ fin: {
634
+ reason: "protocol-error",
635
+ message: reason
636
+ } }));
637
+ closePending(h, pending, 1008, reason);
638
+ return;
639
+ }
640
+ const frame = initial.frame;
641
+ let headers;
642
+ try {
643
+ headers = normalizedHeaders(initial.headers);
644
+ } catch {
645
+ sendPending(pending, JSON.stringify({ fin: {
646
+ reason: "protocol-error",
647
+ message: "malformed headers"
648
+ } }));
649
+ closePending(h, pending, 1008, "malformed headers");
650
+ return;
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
+ }
661
+ pending.authorizing = true;
662
+ Promise.resolve().then(() => authorize?.({ headers })).then((auth) => {
663
+ if (!pending.open || h.disposed || h.finished !== null) return;
664
+ options.onAuthorized?.(auth);
665
+ pending.open = false;
666
+ h.pendingConns.delete(pending);
667
+ if (pending.timer !== null) clearTimeout(pending.timer);
668
+ const registration = register(h, clientId, socket, true, auth);
669
+ if (registration === null) return;
670
+ pending.connection = registration.connection;
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);
676
+ }).catch((error) => {
677
+ if (pending.open) rejectPendingAuthorization(h, pending, error);
678
+ });
679
+ },
680
+ close: () => {
681
+ if (pending.connection !== null) pending.connection.close();
682
+ else closePending(h, pending);
683
+ }
684
+ };
685
+ };
410
686
  const gcClients = (h) => {
411
687
  let excess = h.clients.size - h.clientRecordLimit;
412
688
  if (excess <= 0) return;
689
+ let changed = false;
413
690
  const live = new Set([...h.conns].map((conn) => conn.clientId));
414
691
  const oldest = [...h.clients.values()].sort((a, b) => a.touched - b.touched);
415
692
  for (const rec of oldest) {
416
- if (excess <= 0) return;
693
+ if (excess <= 0) break;
417
694
  if (live.has(rec.id) || rec.inflight.size > 0) continue;
418
695
  h.clients.delete(rec.id);
696
+ changed = true;
419
697
  excess -= 1;
420
698
  }
699
+ if (changed) notifyClientRecords(h);
421
700
  };
422
- const register = (h, clientId, socket) => {
701
+ const register = (h, clientId, socket, sawFrame = false, auth = void 0) => {
423
702
  const refuse = (code, reason) => {
424
703
  try {
425
704
  socket.close(code, reason);
@@ -436,7 +715,19 @@ const register = (h, clientId, socket) => {
436
715
  refuse(1e3, "finished");
437
716
  return null;
438
717
  }
439
- 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 ?? (() => {
440
731
  const created = {
441
732
  id: clientId,
442
733
  lastSeq: 0,
@@ -444,7 +735,8 @@ const register = (h, clientId, socket) => {
444
735
  touched: Date.now(),
445
736
  context: null,
446
737
  results: /* @__PURE__ */ new Map(),
447
- inflight: /* @__PURE__ */ new Map()
738
+ inflight: /* @__PURE__ */ new Map(),
739
+ principal: auth
448
740
  };
449
741
  h.clients.set(clientId, created);
450
742
  return created;
@@ -454,12 +746,14 @@ const register = (h, clientId, socket) => {
454
746
  clientId,
455
747
  socket,
456
748
  open: true,
457
- sawFrame: false,
749
+ sawFrame,
458
750
  lastSentAt: 0,
459
- lease: null
751
+ lease: null,
752
+ auth
460
753
  };
461
754
  h.conns.add(conn);
462
755
  gcClients(h);
756
+ notifyClientRecords(h);
463
757
  ensureHeartbeat(h);
464
758
  const { value, meta } = encodeValue(h.stateHost.snapshot());
465
759
  const hello = {
@@ -486,12 +780,18 @@ const register = (h, clientId, socket) => {
486
780
  }
487
781
  };
488
782
  };
489
- const connect = (h, clientId, socket) => {
490
- return register(h, clientId, socket)?.connection ?? null;
783
+ const connect = (h, clientId, socket, options = {}) => {
784
+ return connectAuthorized(h, clientId, socket, options);
491
785
  };
492
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");
493
788
  if (h.finished !== null || h.disposed) return;
789
+ h.commandsHost?.abort?.();
494
790
  h.finished = fin;
791
+ for (const pending of [...h.pendingConns]) {
792
+ sendPending(pending, JSON.stringify({ fin }));
793
+ closePending(h, pending, 1e3, fin.reason);
794
+ }
495
795
  flush(h, fin);
496
796
  };
497
797
  const resume = (h) => {
@@ -506,15 +806,29 @@ const suspend = (h) => {
506
806
  }
507
807
  h.stateHost?.setOnOp(null);
508
808
  };
509
- 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) => {
510
818
  const [h] = useState(() => {
511
819
  const host = {
512
820
  heartbeatMs: 15e3,
821
+ wsInitTimeoutMs: 5e3,
822
+ maxSubscribers: MAX_SUBSCRIBERS,
823
+ maxInflightCommands: MAX_INFLIGHT_COMMANDS_PER_HOST,
824
+ streamQueueSize: STREAM_QUEUE_SIZE,
513
825
  clientRecordLimit: CLIENT_RECORD_LIMIT,
514
826
  finOnIdle: false,
827
+ pushback: void 0,
515
828
  authorize: void 0,
516
- clients: /* @__PURE__ */ new Map(),
829
+ clients: restoreClientRecords(persistence?.clientRecords),
517
830
  conns: /* @__PURE__ */ new Set(),
831
+ pendingConns: /* @__PURE__ */ new Set(),
518
832
  pendingOps: [],
519
833
  pendingApplied: /* @__PURE__ */ new Map(),
520
834
  pendingCmds: /* @__PURE__ */ new Map(),
@@ -526,11 +840,12 @@ const useSocketHost = (transport, options) => {
526
840
  stateHost: null,
527
841
  commandsHost: null,
528
842
  current: null,
843
+ persistence,
529
844
  post: createMacrotaskPoster(() => {
530
845
  host.flushScheduled = false;
531
846
  flush(host);
532
847
  }),
533
- connect: (clientId, socket) => connect(host, clientId, socket),
848
+ connect: (clientId, socket, connectOptions) => connect(host, clientId, socket, connectOptions),
534
849
  finish: (fin) => finish(host, fin),
535
850
  stream: (request) => stream(host, request),
536
851
  frames: (request_0) => frames(host, request_0)
@@ -538,6 +853,17 @@ const useSocketHost = (transport, options) => {
538
853
  return host;
539
854
  });
540
855
  setHeartbeatMs(h, options?.heartbeatIntervalMs ?? 15e3);
856
+ h.pushback = options?.pushback;
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;
541
867
  h.clientRecordLimit = options?.clientRecordLimit ?? CLIENT_RECORD_LIMIT;
542
868
  h.finOnIdle = options?.finOnIdle ?? false;
543
869
  h.authorize = options?.authorize;
@@ -545,6 +871,7 @@ const useSocketHost = (transport, options) => {
545
871
  attach(h, value);
546
872
  useEffect(() => {
547
873
  attach(h, value);
874
+ scheduleFlush(h);
548
875
  });
549
876
  useEffect(() => {
550
877
  resume(h);
@@ -560,26 +887,29 @@ const useSocketHost = (transport, options) => {
560
887
  * arrays fanned out to every attach of a client id, idle edges, and `fin` on
561
888
  * finish.
562
889
  */
563
- const StatewireSocketHost = (transport, options) => {
890
+ const StatewireSocketHost = (transport, options, persistence) => {
564
891
  const SocketHostRoot = () => {
565
- return useSocketHost(transport, options);
892
+ return useSocketHost(transport, options, persistence);
566
893
  };
567
894
  const root = createTapRoot(SocketHostRoot);
568
895
  let disposed = false;
569
896
  return {
570
- connect: (clientId, socket) => flushTapSync(() => root.getValue().connect(clientId, socket)),
897
+ connect: (clientId, socket, connectOptions) => flushTapSync(() => root.getValue().connect(clientId, socket, connectOptions)),
571
898
  stream: (request) => flushTapSync(() => root.getValue().stream(request)),
572
899
  frames: (request) => flushTapSync(() => root.getValue().frames(request)),
573
900
  finish: (fin) => flushTapSync(() => root.getValue().finish(fin)),
574
901
  dispose: () => {
575
902
  if (disposed) return;
576
903
  disposed = true;
577
- flushTapSync(() => root.getValue().finish({ reason: "evicted" }));
904
+ flushTapSync(() => root.getValue().finish({
905
+ reason: "reconnect",
906
+ code: "evicted"
907
+ }));
578
908
  root.unmount();
579
909
  }
580
910
  };
581
911
  };
582
912
  //#endregion
583
- 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 };
584
914
 
585
915
  //# sourceMappingURL=socket.js.map