statewire 0.19.4 → 0.21.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 (73) hide show
  1. package/README.md +15 -1
  2. package/dist/client/connection.d.ts.map +1 -1
  3. package/dist/client/connection.js +18 -4
  4. package/dist/client/connection.js.map +1 -1
  5. package/dist/client/session.d.ts +10 -1
  6. package/dist/client/session.d.ts.map +1 -1
  7. package/dist/client/session.js +92 -27
  8. package/dist/client/session.js.map +1 -1
  9. package/dist/client/sessionStore.d.ts +2 -0
  10. package/dist/client/sessionStore.d.ts.map +1 -1
  11. package/dist/client/sessionStore.js.map +1 -1
  12. package/dist/host/host.d.ts +2 -1
  13. package/dist/host/host.d.ts.map +1 -1
  14. package/dist/host/host.js +20 -12
  15. package/dist/host/host.js.map +1 -1
  16. package/dist/host/http.d.ts.map +1 -1
  17. package/dist/host/http.js +14 -9
  18. package/dist/host/http.js.map +1 -1
  19. package/dist/host/socket.d.ts +14 -3
  20. package/dist/host/socket.d.ts.map +1 -1
  21. package/dist/host/socket.js +162 -20
  22. package/dist/host/socket.js.map +1 -1
  23. package/dist/host-internal.d.ts +5 -2
  24. package/dist/host-internal.d.ts.map +1 -1
  25. package/dist/host.d.ts +6 -0
  26. package/dist/host.d.ts.map +1 -1
  27. package/dist/index.d.ts +1 -1
  28. package/dist/statewire.d.ts +3 -0
  29. package/dist/statewire.d.ts.map +1 -1
  30. package/dist/statewire.js +23 -5
  31. package/dist/statewire.js.map +1 -1
  32. package/dist/transports/remote.d.ts.map +1 -1
  33. package/dist/transports/remote.js +3 -2
  34. package/dist/transports/remote.js.map +1 -1
  35. package/dist/transports/sse.d.ts +5 -0
  36. package/dist/transports/sse.d.ts.map +1 -1
  37. package/dist/transports/sse.js +12 -0
  38. package/dist/transports/sse.js.map +1 -1
  39. package/dist/transports/testing.d.ts +2 -0
  40. package/dist/transports/testing.d.ts.map +1 -1
  41. package/dist/transports/testing.js +11 -4
  42. package/dist/transports/testing.js.map +1 -1
  43. package/dist/types.d.ts +4 -0
  44. package/dist/types.d.ts.map +1 -1
  45. package/dist/types.js.map +1 -1
  46. package/dist/wire/context.d.ts +14 -0
  47. package/dist/wire/context.d.ts.map +1 -0
  48. package/dist/wire/context.js +31 -0
  49. package/dist/wire/context.js.map +1 -0
  50. package/dist/wire/limits.d.ts +2 -1
  51. package/dist/wire/limits.d.ts.map +1 -1
  52. package/dist/wire/limits.js +2 -1
  53. package/dist/wire/limits.js.map +1 -1
  54. package/dist/wire/protocol.d.ts +1 -1
  55. package/dist/wire/protocol.js +4 -4
  56. package/dist/wire/protocol.js.map +1 -1
  57. package/package.json +1 -1
  58. package/src/client/connection.ts +16 -4
  59. package/src/client/session.ts +128 -19
  60. package/src/client/sessionStore.ts +5 -1
  61. package/src/host/host.ts +18 -3
  62. package/src/host/http.ts +37 -5
  63. package/src/host/socket.ts +232 -13
  64. package/src/host-internal.ts +4 -0
  65. package/src/host.ts +5 -0
  66. package/src/statewire.ts +34 -3
  67. package/src/transports/remote.ts +3 -0
  68. package/src/transports/sse.ts +19 -0
  69. package/src/transports/testing.ts +14 -3
  70. package/src/types.ts +12 -0
  71. package/src/wire/context.ts +40 -0
  72. package/src/wire/limits.ts +2 -0
  73. package/src/wire/protocol.ts +1 -1
@@ -1,3 +1,4 @@
1
+ import { encodeContext } from "../wire/context";
1
2
  import { wrapStorageError } from "./storageError";
2
3
  import {
3
4
  DEFAULT_PROTOCOL,
@@ -76,6 +77,7 @@ const rejectedSend = (
76
77
  type AttachState = "detached" | "attached" | "finished";
77
78
 
78
79
  export type StatewireSessionOptions = StatewireProtocol.Options & {
80
+ context?: Readonly<Record<string, Statewire.Context>>;
79
81
  sessionStore?: StatewireClient.SessionStorage | undefined;
80
82
  onSessionLost?: StatewireClient.OnSessionLost | undefined;
81
83
  onDone?: (() => void) | undefined;
@@ -83,12 +85,17 @@ export type StatewireSessionOptions = StatewireProtocol.Options & {
83
85
 
84
86
  type Shared = {
85
87
  txq: Promise<void>;
88
+ syncQueued: boolean;
86
89
  parked: { protocol: string; verdict: StatewireClient.SessionVerdict }[];
87
90
  unsubscribe: (() => void) | null;
88
91
  };
89
92
 
90
93
  type Session = {
91
94
  options: StatewireSessionOptions;
95
+ configuredContext: StatewireSessionOptions["context"];
96
+ contexts: Map<string, ReturnType<typeof encodeContext>>;
97
+ dirtyContexts: Set<string>;
98
+ attachContexts: Map<string, ReturnType<typeof encodeContext>>;
92
99
  selection: StatewireProtocol.Selection | undefined;
93
100
  record: StatewireClient.SessionRecord;
94
101
  ctx: StatewireRemoteProps;
@@ -198,7 +205,6 @@ const storageFailed = (s: Session, cause: unknown) => {
198
205
  };
199
206
 
200
207
  const setRecord = (s: Session, record: StatewireClient.SessionRecord) => {
201
- s.record = record;
202
208
  if (!s.deactivated) {
203
209
  try {
204
210
  s.store.save(record);
@@ -207,6 +213,7 @@ const setRecord = (s: Session, record: StatewireClient.SessionRecord) => {
207
213
  throw s.storageError;
208
214
  }
209
215
  }
216
+ s.record = record;
210
217
  };
211
218
 
212
219
  const toStored = (p: Pending): StatewireClient.SessionCommand => {
@@ -282,8 +289,16 @@ const syncBacklogInner = (s: Session) => {
282
289
  };
283
290
 
284
291
  const syncBacklog = (s: Session) => {
285
- if (!s.shared) return syncBacklogInner(s);
286
- transactBackground(s, () => syncBacklogInner(s));
292
+ const shared = s.shared;
293
+ if (!shared) return syncBacklogInner(s);
294
+ if (shared.syncQueued) return;
295
+ shared.syncQueued = true;
296
+ void transact(s, () => {
297
+ shared.syncQueued = false;
298
+ syncBacklogInner(s);
299
+ }).catch(() => {
300
+ shared.syncQueued = false;
301
+ });
287
302
  };
288
303
 
289
304
  // Every record mutation over shared storage is a locked read-modify-write.
@@ -299,7 +314,7 @@ const transact = (s: Session, fn: () => void): Promise<void> => {
299
314
  if (stored !== null && stored.clientId !== s.record.clientId) {
300
315
  adoptReset(s, stored);
301
316
  } else if (stored !== null) {
302
- s.record = stored;
317
+ s.record = boundRecord(stored);
303
318
  }
304
319
  fn();
305
320
  const persistence = s.store.flush?.();
@@ -351,6 +366,25 @@ export const failedCommands = (
351
366
  return s.failedList;
352
367
  };
353
368
 
369
+ export const MAX_FAILED_RETAINED = 256;
370
+
371
+ const boundFailed = (failed: readonly StatewireClient.FailedCommand[]) =>
372
+ failed.length > MAX_FAILED_RETAINED
373
+ ? [...failed].sort((a, b) => a.ts - b.ts).slice(-MAX_FAILED_RETAINED)
374
+ : failed;
375
+
376
+ const boundRecord = (record: StatewireClient.SessionRecord) => {
377
+ let lanes = record.lanes;
378
+ for (const [protocol, lane] of Object.entries(lanes)) {
379
+ if (lane.failed !== undefined && lane.failed.length > MAX_FAILED_RETAINED)
380
+ lanes = {
381
+ ...lanes,
382
+ [protocol]: { ...lane, failed: boundFailed(lane.failed) },
383
+ };
384
+ }
385
+ return lanes === record.lanes ? record : { ...record, lanes };
386
+ };
387
+
354
388
  const retainFailedInner = (
355
389
  s: Session,
356
390
  entry: Pending,
@@ -361,7 +395,7 @@ const retainFailedInner = (
361
395
  s,
362
396
  withLane(s.record, entry.command.protocol, {
363
397
  ...lane,
364
- failed: [
398
+ failed: boundFailed([
365
399
  ...(lane.failed ?? []),
366
400
  {
367
401
  protocol: entry.command.protocol,
@@ -375,7 +409,7 @@ const retainFailedInner = (
375
409
  ts: entry.inv.ts,
376
410
  seq: entry.seq,
377
411
  },
378
- ],
412
+ ]),
379
413
  }),
380
414
  );
381
415
  s.notify();
@@ -491,7 +525,7 @@ const rebaseRecord = (s: Session, adopt: () => void) => {
491
525
  ...lanes,
492
526
  [protocol]: {
493
527
  ...current,
494
- failed: [...(current.failed ?? []), ...failed],
528
+ failed: boundFailed([...(current.failed ?? []), ...failed]),
495
529
  },
496
530
  };
497
531
  }
@@ -509,7 +543,7 @@ const resetRecord = (s: Session, clientId: string) =>
509
543
  // attempted history and our survivors re-stamp on the stored clocks.
510
544
  const adoptReset = (s: Session, stored: StatewireClient.SessionRecord) => {
511
545
  rebaseRecord(s, () => {
512
- s.record = stored;
546
+ s.record = boundRecord(stored);
513
547
  });
514
548
  // The host attributes frames by lease, and the lease is bound to the id
515
549
  // this attach was opened with.
@@ -760,6 +794,16 @@ const handleHello = (
760
794
  s: Session,
761
795
  { ops, syn }: { ops: unknown[]; syn: StatewireSyn },
762
796
  ) => {
797
+ if (s.attach === "attached") {
798
+ s.ephemeralSeqs = {};
799
+ detachEphemeralLanes(s);
800
+ }
801
+ for (const [protocol, value] of s.contexts) {
802
+ if (s.attachContexts.get(protocol) === value)
803
+ s.dirtyContexts.delete(protocol);
804
+ else s.dirtyContexts.add(protocol);
805
+ }
806
+ s.attachContexts.clear();
763
807
  s.selection = selectionOf(syn);
764
808
  s.documents = { mode: null, documents: new Map() };
765
809
  ingestOps(s, ops);
@@ -809,7 +853,7 @@ const handleOps = (
809
853
  ) => {
810
854
  ingestOps(s, ops);
811
855
  for (const cmd of cmds) handleCmd(s, cmd);
812
- syncBacklog(s);
856
+ if (cmds.length > 0) syncBacklog(s);
813
857
  publishIngested(s);
814
858
  };
815
859
 
@@ -914,12 +958,16 @@ const buildLanes = (
914
958
  last[protocol] = entry.seq;
915
959
  entries.push(entry);
916
960
  }
917
- if (entries.length === 0) return null;
961
+ const ctx = [...s.dirtyContexts].map((protocol) => s.contexts.get(protocol)!);
962
+ if (entries.length === 0 && ctx.length === 0) return null;
918
963
  return {
919
964
  frame: {
920
- cmd: entries.map((p) => {
921
- const { produced: _produced, ...command } = p.command;
922
- return { ...command, seq: p.seq };
965
+ ...(ctx.length > 0 && { ctx }),
966
+ ...(entries.length > 0 && {
967
+ cmd: entries.map((p) => {
968
+ const { produced: _produced, ...command } = p.command;
969
+ return { ...command, seq: p.seq };
970
+ }),
923
971
  }),
924
972
  },
925
973
  entries,
@@ -930,6 +978,8 @@ const takeAttachLanes = (s: Session): StatewireFrame | null => {
930
978
  s.attachEntries = new Set();
931
979
  const wanted = s.lanesOnAttach;
932
980
  s.lanesOnAttach = false;
981
+ for (const protocol of s.contexts.keys()) s.dirtyContexts.add(protocol);
982
+ s.attachContexts = new Map(s.contexts);
933
983
  const lanes = buildLanes(s, wanted && s.sessionKnown ? "persisted" : "none");
934
984
  if (!lanes) return null;
935
985
  s.attachEntries = new Set(lanes.entries);
@@ -966,6 +1016,7 @@ const pump = async (s: Session) => {
966
1016
  if (!s.hydrated) await s.ready;
967
1017
  const transport = s.transport;
968
1018
  if (buildLanes(s) === null) return;
1019
+ if (transport?.parked && s.pending.length === 0) return;
969
1020
  if (s.attach !== "attached" || !transport) {
970
1021
  if (
971
1022
  (transport?.connection.status === "standby" &&
@@ -995,17 +1046,32 @@ const pump = async (s: Session) => {
995
1046
  // Shared storage: the lock across the send keeps per-lane arrival
996
1047
  // ordered across sharers during attach handover.
997
1048
  const send = () => transport.send(lanes.frame);
998
- result = await (s.shared && !s.deactivated
1049
+ result = await (s.shared && !s.deactivated && lanes.entries.length > 0
999
1050
  ? s.store.lock!(send)
1000
1051
  : send());
1001
1052
  } catch {
1002
1053
  if (!s.running || isFinished(s)) return;
1054
+ if (lanes.frame.ctx?.length) {
1055
+ s.lanesOnAttach = true;
1056
+ transport.connection.reconnect();
1057
+ await waitForWake(s);
1058
+ continue;
1059
+ }
1003
1060
  s.frameFailures += 1;
1004
1061
  await sleepUntilWake(s, backoff(s.frameFailures));
1005
1062
  continue;
1006
1063
  }
1007
1064
  if (!s.running) return;
1008
1065
  s.frameFailures = 0;
1066
+ if (
1067
+ epoch === s.attachEpoch &&
1068
+ (result.type === "ok" || result.type === "refusal")
1069
+ ) {
1070
+ for (const entry of lanes.frame.ctx ?? []) {
1071
+ if (s.contexts.get(entry.protocol) === entry)
1072
+ s.dirtyContexts.delete(entry.protocol);
1073
+ }
1074
+ }
1009
1075
  if (result.type === "ok") {
1010
1076
  if (epoch === s.attachEpoch) {
1011
1077
  for (const entry of lanes.entries) {
@@ -1186,7 +1252,10 @@ const adoptStored = (s: Session) => {
1186
1252
  const params =
1187
1253
  command.meta === undefined
1188
1254
  ? command.params
1189
- : (reviveValue([...command.params], command.meta) as unknown[]);
1255
+ : (reviveValue(
1256
+ structuredClone(command.params),
1257
+ command.meta,
1258
+ ) as unknown[]);
1190
1259
  const entry: Pending = {
1191
1260
  seq,
1192
1261
  command: { protocol, ...command },
@@ -1326,7 +1395,7 @@ export const retryFailed = (
1326
1395
  const params =
1327
1396
  found.meta === undefined
1328
1397
  ? found.params
1329
- : (reviveValue([...found.params], found.meta) as unknown[]);
1398
+ : (reviveValue(structuredClone(found.params), found.meta) as unknown[]);
1330
1399
  const result = new Promise<unknown>((resolve, reject) => {
1331
1400
  const retry = () => {
1332
1401
  if (!s.running) {
@@ -1472,11 +1541,31 @@ const loadSessionRecord = (
1472
1541
  store.save(stored);
1473
1542
  }
1474
1543
  }
1475
- const record = stored ?? { clientId: newClientId(), lanes: {} };
1476
- if (stored === null) store.save(record);
1544
+ const record =
1545
+ stored === null
1546
+ ? { clientId: newClientId(), lanes: {} }
1547
+ : boundRecord(stored);
1548
+ if (record !== stored) store.save(record);
1477
1549
  return record;
1478
1550
  };
1479
1551
 
1552
+ export const setContext = (
1553
+ s: Session,
1554
+ protocol: string,
1555
+ value: Statewire.Context,
1556
+ ): void => {
1557
+ if (
1558
+ s.selection
1559
+ ? !s.selection.protocols.some((p) => p.name === protocol)
1560
+ : !offered(s, protocol)
1561
+ )
1562
+ throw new Error(`statewire: protocol "${protocol}" is not negotiated`);
1563
+ s.contexts.set(protocol, encodeContext(protocol, value));
1564
+ s.dirtyContexts.add(protocol);
1565
+ wake(s);
1566
+ void pump(s);
1567
+ };
1568
+
1480
1569
  export const useStatewireSession = (
1481
1570
  options: StatewireSessionOptions,
1482
1571
  ): Session => {
@@ -1501,6 +1590,15 @@ export const useStatewireSession = (
1501
1590
  : { clientId: newClientId(), lanes: {} };
1502
1591
  const session: Session = {
1503
1592
  options,
1593
+ configuredContext: options.context,
1594
+ contexts: new Map(
1595
+ Object.entries(options.context ?? {}).map(([protocol, value]) => [
1596
+ protocol,
1597
+ encodeContext(protocol, value),
1598
+ ]),
1599
+ ),
1600
+ dirtyContexts: new Set(Object.keys(options.context ?? {})),
1601
+ attachContexts: new Map(),
1504
1602
  selection: undefined,
1505
1603
  record,
1506
1604
  ctx: {
@@ -1526,7 +1624,12 @@ export const useStatewireSession = (
1526
1624
  documentStore,
1527
1625
  shared:
1528
1626
  store.lock !== undefined
1529
- ? { txq: Promise.resolve(), parked: [], unsubscribe: null }
1627
+ ? {
1628
+ txq: Promise.resolve(),
1629
+ syncQueued: false,
1630
+ parked: [],
1631
+ unsubscribe: null,
1632
+ }
1530
1633
  : false,
1531
1634
  ephemeral: false,
1532
1635
  deactivated: false,
@@ -1598,6 +1701,12 @@ export const useStatewireSession = (
1598
1701
  "statewire: session storage changed sharedness across renders — lock/subscribe must be present from the first render or never",
1599
1702
  );
1600
1703
  }
1704
+ useEffect(() => {
1705
+ if (s.configuredContext === options.context) return;
1706
+ s.configuredContext = options.context;
1707
+ for (const [protocol, value] of Object.entries(options.context ?? {}))
1708
+ setContext(s, protocol, value);
1709
+ }, [s, options.context]);
1601
1710
  s.options = options;
1602
1711
  s.store = store;
1603
1712
  s.documentStore = documentStore;
@@ -1,6 +1,7 @@
1
1
  import type { StatewireDocuments } from "../wire/documents";
2
2
  import { createContext } from "react";
3
3
  import type { StatewireProtocol } from "../wire/protocol";
4
+ import type { Statewire } from "../types";
4
5
  import type { StatewireClient } from "../statewire";
5
6
 
6
7
  /** The session storage `useStatewire` mounts, read by the transport's session. */
@@ -21,7 +22,10 @@ export const createMemorySessionStore = (): StatewireClient.SessionStorage => {
21
22
  export const ProtocolContext = createContext<StatewireProtocol.Options>({});
22
23
 
23
24
  /** Link options `useStatewire` hands down to whichever transport it mounts. */
24
- export const LinkContext = createContext<{ isNew?: boolean | undefined }>({});
25
+ export const LinkContext = createContext<{
26
+ isNew?: boolean | undefined;
27
+ context?: Readonly<Record<string, Statewire.Context>> | undefined;
28
+ }>({});
25
29
 
26
30
  export const DocumentStorageContext = createContext<
27
31
  StatewireDocuments.Storage | undefined
package/src/host/host.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { captureContext } from "../wire/context";
1
2
  import { useEffect, useState } from "react";
2
3
  import { applyOps, sanitizeValue } from "../wire/ops";
3
4
  import type { StatewirePath } from "../wire/ops";
@@ -167,6 +168,7 @@ export type CommandsSubmission = {
167
168
  params: readonly unknown[];
168
169
  clientId?: string;
169
170
  caller?: StatewireHost.Caller;
171
+ context?: Statewire.Context;
170
172
  attach?: StatewireHost.Attach;
171
173
  onApplied?: () => void;
172
174
  onSettle?: (outcome: CommandsOutcome) => void;
@@ -190,6 +192,7 @@ export const bindCommandsHost = (commands: object, host: CommandsHost) => {
190
192
  };
191
193
 
192
194
  type QueuedCall = {
195
+ context?: Statewire.Context;
193
196
  method: string;
194
197
  params: readonly unknown[];
195
198
  ts: number;
@@ -206,6 +209,7 @@ type ActiveInvocation = CommandsInvocation & {
206
209
  };
207
210
 
208
211
  type CommandsCell = {
212
+ context: (() => Statewire.Context) | undefined;
209
213
  handlers: Record<string, (...args: never[]) => unknown> | null;
210
214
  queue: QueuedCall[];
211
215
  active: Map<ActiveInvocation, QueuedCall>;
@@ -227,8 +231,13 @@ const LOCAL_CALLER: StatewireHost.Caller = Object.freeze({
227
231
  isConnected: true,
228
232
  });
229
233
 
230
- const toSendError = (error: unknown): StatewireSendError =>
231
- error instanceof StatewireSendError
234
+ const toSendError = (error: unknown): StatewireSendError => {
235
+ if (
236
+ !(error instanceof StatewireSendError) &&
237
+ !(error instanceof StatewireReject)
238
+ )
239
+ console.error("statewire command failed", error);
240
+ return error instanceof StatewireSendError
232
241
  ? error
233
242
  : error instanceof StatewireReject
234
243
  ? new StatewireSendError({
@@ -241,8 +250,9 @@ const toSendError = (error: unknown): StatewireSendError =>
241
250
  : new StatewireSendError({
242
251
  fate: "failed",
243
252
  cause: "server",
244
- message: error instanceof Error ? error.message : String(error),
253
+ message: "the command failed",
245
254
  });
255
+ };
246
256
 
247
257
  const applyInvocation = (cell: CommandsCell, inv: ActiveInvocation) => {
248
258
  if (inv.applied) return;
@@ -343,6 +353,7 @@ const dispatch = (cell: CommandsCell, call: QueuedCall) => {
343
353
  const attach = call.origin?.attach;
344
354
  const ctx: StatewireHost.Ctx = {
345
355
  caller: call.origin?.caller ?? LOCAL_CALLER,
356
+ context: call.context ?? call.origin?.context ?? Object.freeze({}),
346
357
  get attach() {
347
358
  if (attach === undefined)
348
359
  throw new Error("statewire: in-process dispatch has no attach");
@@ -416,9 +427,11 @@ export const useStatewireCommands = <THandlers extends Statewire.Commands>(
416
427
  | undefined
417
428
  | null
418
429
  | false,
430
+ options?: StatewireHost.CommandsOptions,
419
431
  ): Statewire.CommandsProxy<StatewireHost.HandledCommands<THandlers>> => {
420
432
  const [built] = useState(() => {
421
433
  const cell: CommandsCell = {
434
+ context: undefined,
422
435
  handlers: null,
423
436
  queue: [],
424
437
  active: new Map(),
@@ -438,6 +451,7 @@ export const useStatewireCommands = <THandlers extends Statewire.Commands>(
438
451
  return;
439
452
  }
440
453
  cell.queue.push({
454
+ context: captureContext(cell.context?.() ?? {}),
441
455
  method,
442
456
  params,
443
457
  ts: Date.now(),
@@ -479,6 +493,7 @@ export const useStatewireCommands = <THandlers extends Statewire.Commands>(
479
493
  return { cell, proxy };
480
494
  });
481
495
 
496
+ built.cell.context = options?.context;
482
497
  useEffect(() => {
483
498
  const { cell } = built;
484
499
  cell.unmounted = false;
package/src/host/http.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  authEquals,
9
9
  closeConn,
10
10
  flush,
11
+ expireLease,
11
12
  parseFrame,
12
13
  parseFrameValue,
13
14
  register,
@@ -94,9 +95,10 @@ const headersOf = (request: Request): Record<string, string> =>
94
95
  const authorize = async (
95
96
  h: SocketHostState,
96
97
  headers: Record<string, string>,
97
- ): Promise<{ auth: unknown } | { refused: Response }> => {
98
+ ): Promise<{ auth: unknown; authorizedAt: number } | { refused: Response }> => {
98
99
  try {
99
- return { auth: await h.authorize?.({ headers }) };
100
+ const auth = await h.authorize?.({ headers });
101
+ return { auth, authorizedAt: Date.now() };
100
102
  } catch (error) {
101
103
  if (error instanceof Response) return { refused: error };
102
104
  throw error;
@@ -140,6 +142,7 @@ const attachSse = (
140
142
  h: SocketHostState,
141
143
  clientId: string,
142
144
  auth: unknown,
145
+ authorizedAt: number,
143
146
  selection: StatewireProtocol.Selection,
144
147
  frame?: ParsedFrame,
145
148
  ): SseAttachment => {
@@ -203,6 +206,7 @@ const attachSse = (
203
206
  auth,
204
207
  selection,
205
208
  frame,
209
+ authorizedAt,
206
210
  );
207
211
  connection = registration?.connection ?? null;
208
212
  if (!open) connection?.close();
@@ -249,7 +253,13 @@ const getStream = async (
249
253
  ]);
250
254
  if ("fin" in selection)
251
255
  return Response.json(selection, { status: selection.fin.status });
252
- const attachment = attachSse(h, client.clientId, authorized.auth, selection);
256
+ const attachment = attachSse(
257
+ h,
258
+ client.clientId,
259
+ authorized.auth,
260
+ authorized.authorizedAt,
261
+ selection,
262
+ );
253
263
  const lease = attachment.registration === null ? null : mintLease();
254
264
  if (attachment.registration !== null)
255
265
  attachment.registration.conn.lease = lease;
@@ -301,6 +311,7 @@ const postStream = async (
301
311
  h,
302
312
  client.clientId,
303
313
  authorized.auth,
314
+ authorized.authorizedAt,
304
315
  selection,
305
316
  frame,
306
317
  );
@@ -365,11 +376,24 @@ export const frames = async (
365
376
  lease === null
366
377
  ? undefined
367
378
  : [...h.conns].find((conn) => conn.open && conn.lease === lease);
368
- if (h.disposed || h.finished !== null || leased === undefined) {
379
+ if (
380
+ h.disposed ||
381
+ h.finished !== null ||
382
+ leased === undefined ||
383
+ expireLease(h, leased)
384
+ ) {
369
385
  return Response.json({ error: "stale-lease" }, { status: 423 });
370
386
  }
371
387
  const clientId = leased.clientId;
372
388
  const raw = await readFrameBody(request);
389
+ if (
390
+ !leased.open ||
391
+ h.disposed ||
392
+ h.finished !== null ||
393
+ expireLease(h, leased)
394
+ ) {
395
+ return Response.json({ error: "stale-lease" }, { status: 423 });
396
+ }
373
397
  if (raw === null) {
374
398
  return Response.json({ error: "frame body too large" }, { status: 413 });
375
399
  }
@@ -382,7 +406,12 @@ export const frames = async (
382
406
  }
383
407
  return violationResponse(h, clientId, error.message);
384
408
  }
385
- if (!leased.open || h.disposed || h.finished !== null) {
409
+ if (
410
+ !leased.open ||
411
+ h.disposed ||
412
+ h.finished !== null ||
413
+ expireLease(h, leased)
414
+ ) {
386
415
  return Response.json({ error: "stale-lease" }, { status: 423 });
387
416
  }
388
417
  let admission: ReturnType<typeof admitFrame>;
@@ -393,6 +422,9 @@ export const frames = async (
393
422
  throw error;
394
423
  return violationResponse(h, clientId, error.message);
395
424
  }
425
+ if (admission.type === "expired") {
426
+ return Response.json({ error: "stale-lease" }, { status: 423 });
427
+ }
396
428
  if (admission.type === "refused") {
397
429
  const retry = admission.syn.syn.retry;
398
430
  return Response.json(admission.syn, {