statewire 0.20.0 → 0.22.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.
- package/dist/client/changes.d.ts +3 -6
- package/dist/client/changes.d.ts.map +1 -1
- package/dist/client/changes.js +1 -2
- package/dist/client/connection.d.ts +80 -83
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +20 -7
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts +39 -41
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +2 -3
- package/dist/client/session.d.ts +93 -93
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +47 -23
- package/dist/client/session.js.map +1 -1
- package/dist/client/sessionStore.d.ts +11 -14
- package/dist/client/sessionStore.d.ts.map +1 -1
- package/dist/client/sessionStore.js +1 -2
- package/dist/client/storageError.d.ts +2 -5
- package/dist/client/storageError.d.ts.map +1 -1
- package/dist/client/storageError.js +1 -2
- package/dist/client/timing.d.ts +11 -14
- package/dist/client/timing.d.ts.map +1 -1
- package/dist/client/timing.js +1 -2
- package/dist/document.d.ts +17 -20
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +1 -2
- package/dist/host/adapter.d.ts +4 -7
- package/dist/host/adapter.d.ts.map +1 -1
- package/dist/host/adapter.js +1 -2
- package/dist/host/draft.d.ts +13 -16
- package/dist/host/draft.d.ts.map +1 -1
- package/dist/host/draft.js +1 -2
- package/dist/host/engine.d.ts +7 -10
- package/dist/host/engine.d.ts.map +1 -1
- package/dist/host/engine.js +1 -2
- package/dist/host/host.d.ts +42 -45
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js +15 -13
- package/dist/host/host.js.map +1 -1
- package/dist/host/http.d.ts +4 -7
- package/dist/host/http.d.ts.map +1 -1
- package/dist/host/http.js +15 -11
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +145 -140
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +99 -17
- package/dist/host/socket.js.map +1 -1
- package/dist/host/synced.d.ts +3 -6
- package/dist/host/synced.d.ts.map +1 -1
- package/dist/host/synced.js +1 -2
- package/dist/host/work.d.ts +2 -5
- package/dist/host/work.d.ts.map +1 -1
- package/dist/host/work.js +1 -2
- package/dist/host-internal.d.ts +99 -90
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/host.d.ts +90 -91
- package/dist/host.d.ts.map +1 -1
- package/dist/index.d.ts +11 -8
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -1
- package/dist/statewire.d.ts +265 -268
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +7 -7
- package/dist/statewire.js.map +1 -1
- package/dist/storage.d.ts +23 -26
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +1 -2
- package/dist/transports/fake-sse.d.ts +7 -10
- package/dist/transports/fake-sse.d.ts.map +1 -1
- package/dist/transports/fake-sse.js +1 -2
- package/dist/transports/fixtures.d.ts +19 -25
- package/dist/transports/fixtures.d.ts.map +1 -1
- package/dist/transports/fixtures.js +1 -2
- package/dist/transports/remote.d.ts +8 -11
- package/dist/transports/remote.d.ts.map +1 -1
- package/dist/transports/remote.js +1 -2
- package/dist/transports/sse-parser.d.ts +1 -4
- package/dist/transports/sse-parser.d.ts.map +1 -1
- package/dist/transports/sse-parser.js +1 -2
- package/dist/transports/sse.d.ts +50 -52
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +9 -3
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/testing.d.ts +31 -34
- package/dist/transports/testing.d.ts.map +1 -1
- package/dist/transports/testing.js +1 -2
- package/dist/transports/ws.d.ts +5 -7
- package/dist/transports/ws.d.ts.map +1 -1
- package/dist/transports/ws.js +3 -4
- package/dist/transports/ws.js.map +1 -1
- package/dist/types.d.ts +81 -78
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -2
- package/dist/util/draftTraps.d.ts +2 -4
- package/dist/util/draftTraps.d.ts.map +1 -1
- package/dist/util/draftTraps.js +1 -2
- package/dist/util/macrotask.d.ts +1 -4
- package/dist/util/macrotask.d.ts.map +1 -1
- package/dist/util/macrotask.js +1 -2
- package/dist/util/methodProxy.d.ts +1 -4
- package/dist/util/methodProxy.d.ts.map +1 -1
- package/dist/util/methodProxy.js +1 -2
- package/dist/util/randomId.d.ts +1 -4
- package/dist/util/randomId.d.ts.map +1 -1
- package/dist/util/randomId.js +1 -2
- package/dist/util/recordMutations.d.ts +5 -7
- package/dist/util/recordMutations.d.ts.map +1 -1
- package/dist/util/recordMutations.js +1 -2
- package/dist/util/statePath.d.ts +9 -12
- package/dist/util/statePath.d.ts.map +1 -1
- package/dist/util/statePath.js +1 -2
- package/dist/wire/context.d.ts +7 -12
- package/dist/wire/context.d.ts.map +1 -1
- package/dist/wire/context.js +2 -3
- package/dist/wire/documents.d.ts +36 -39
- package/dist/wire/documents.d.ts.map +1 -1
- package/dist/wire/documents.js +2 -3
- package/dist/wire/documents.js.map +1 -1
- package/dist/wire/limits.d.ts +7 -10
- package/dist/wire/limits.d.ts.map +1 -1
- package/dist/wire/limits.js +1 -2
- package/dist/wire/meta.d.ts +9 -11
- package/dist/wire/meta.d.ts.map +1 -1
- package/dist/wire/meta.js +1 -2
- package/dist/wire/meta.js.map +1 -1
- package/dist/wire/ops.d.ts +9 -12
- package/dist/wire/ops.d.ts.map +1 -1
- package/dist/wire/ops.js +1 -2
- package/dist/wire/protocol.d.ts +53 -56
- package/dist/wire/protocol.d.ts.map +1 -1
- package/dist/wire/protocol.js +1 -2
- package/dist/wire/version.d.ts +1 -4
- package/dist/wire/version.d.ts.map +1 -1
- package/dist/wire/version.js +1 -2
- package/package.json +6 -6
- package/src/client/connection.ts +25 -15
- package/src/client/session.ts +54 -15
- package/src/host/host.ts +9 -3
- package/src/host/http.ts +37 -5
- package/src/host/socket.ts +158 -9
- package/src/host-internal.ts +14 -0
- package/src/statewire.ts +11 -6
- package/src/transports/sse.ts +12 -0
package/src/host/socket.ts
CHANGED
|
@@ -128,10 +128,15 @@ export type Conn = {
|
|
|
128
128
|
lease: string | null;
|
|
129
129
|
/** The awaited `authorize` return captured at this attach's authorization. */
|
|
130
130
|
auth: unknown;
|
|
131
|
+
authorizedAt: number | undefined;
|
|
132
|
+
leaseTimer: ReturnType<typeof setTimeout> | null;
|
|
131
133
|
lanes: Map<string, Lane>;
|
|
132
134
|
contexts: Map<string, Statewire.Context>;
|
|
133
135
|
documents: Map<string, StatewireHost.Document>;
|
|
134
136
|
included: Set<string>;
|
|
137
|
+
onIncludedChange:
|
|
138
|
+
| ((included: readonly StatewireHostInternal.IncludedDocument[]) => void)
|
|
139
|
+
| undefined;
|
|
135
140
|
view: Map<string, { doc: number; value: unknown }>;
|
|
136
141
|
nextDoc: number;
|
|
137
142
|
pendingApplied: Cmd[];
|
|
@@ -316,13 +321,11 @@ const cmdOf = (adm: Admission, outcome: CommandsOutcome): Cmd => {
|
|
|
316
321
|
};
|
|
317
322
|
}
|
|
318
323
|
default:
|
|
324
|
+
console.error("statewire command failed", outcome.error);
|
|
319
325
|
return {
|
|
320
326
|
...at,
|
|
321
327
|
type: "failed",
|
|
322
|
-
message:
|
|
323
|
-
outcome.error instanceof Error
|
|
324
|
-
? outcome.error.message
|
|
325
|
-
: String(outcome.error),
|
|
328
|
+
message: "the command failed",
|
|
326
329
|
};
|
|
327
330
|
}
|
|
328
331
|
};
|
|
@@ -356,6 +359,7 @@ export type SocketHostState = {
|
|
|
356
359
|
key: readonly string[] | undefined;
|
|
357
360
|
wireVersion: StatewireProtocol.Range;
|
|
358
361
|
heartbeatMs: number;
|
|
362
|
+
maxLeaseLifetimeMs: number | null;
|
|
359
363
|
wsInitTimeoutMs: number;
|
|
360
364
|
maxSubscribers: number;
|
|
361
365
|
maxInflightCommands: number;
|
|
@@ -451,6 +455,10 @@ export const closeConn = (
|
|
|
451
455
|
if (!conn.open) return;
|
|
452
456
|
conn.open = false;
|
|
453
457
|
h.conns.delete(conn);
|
|
458
|
+
if (conn.leaseTimer !== null) {
|
|
459
|
+
clearTimeout(conn.leaseTimer);
|
|
460
|
+
conn.leaseTimer = null;
|
|
461
|
+
}
|
|
454
462
|
try {
|
|
455
463
|
conn.socket.close(code, reason);
|
|
456
464
|
} catch {
|
|
@@ -473,6 +481,49 @@ export const sendTo = (h: SocketHostState, conn: Conn, data: string) => {
|
|
|
473
481
|
}
|
|
474
482
|
};
|
|
475
483
|
|
|
484
|
+
const expiredAt = (h: SocketHostState, authorizedAt: number | undefined) => {
|
|
485
|
+
const now = Date.now();
|
|
486
|
+
return (
|
|
487
|
+
authorizedAt === undefined ||
|
|
488
|
+
!Number.isFinite(authorizedAt) ||
|
|
489
|
+
authorizedAt < 0 ||
|
|
490
|
+
authorizedAt > now ||
|
|
491
|
+
(h.maxLeaseLifetimeMs !== null &&
|
|
492
|
+
now - authorizedAt >= h.maxLeaseLifetimeMs)
|
|
493
|
+
);
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const leaseExpiredFin = JSON.stringify({
|
|
497
|
+
fin: { reason: "reconnect", code: "lease-expired" },
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
export const expireLease = (h: SocketHostState, conn: Conn): boolean => {
|
|
501
|
+
if (!conn.open || expiredAt(h, conn.authorizedAt)) {
|
|
502
|
+
if (conn.open) {
|
|
503
|
+
sendTo(h, conn, leaseExpiredFin);
|
|
504
|
+
closeConn(h, conn, 1000, "lease-expired");
|
|
505
|
+
}
|
|
506
|
+
return true;
|
|
507
|
+
}
|
|
508
|
+
return false;
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const armLeaseTimer = (h: SocketHostState, conn: Conn) => {
|
|
512
|
+
if (conn.leaseTimer !== null) clearTimeout(conn.leaseTimer);
|
|
513
|
+
conn.leaseTimer = null;
|
|
514
|
+
if (h.disposed || h.maxLeaseLifetimeMs === null || expireLease(h, conn))
|
|
515
|
+
return;
|
|
516
|
+
const delay = h.maxLeaseLifetimeMs - (Date.now() - conn.authorizedAt!);
|
|
517
|
+
conn.leaseTimer = setTimeout(
|
|
518
|
+
() => {
|
|
519
|
+
conn.leaseTimer = null;
|
|
520
|
+
if (!expireLease(h, conn)) armLeaseTimer(h, conn);
|
|
521
|
+
},
|
|
522
|
+
Math.min(delay, 2_147_483_647),
|
|
523
|
+
);
|
|
524
|
+
(conn.leaseTimer as unknown as { unref?: () => void }).unref?.();
|
|
525
|
+
};
|
|
526
|
+
|
|
476
527
|
const violate = (h: SocketHostState, conn: Conn, reason: string) => {
|
|
477
528
|
sendTo(
|
|
478
529
|
h,
|
|
@@ -593,6 +644,33 @@ const diffView = (h: SocketHostState, conn: Conn): WireOp[] => {
|
|
|
593
644
|
return ops;
|
|
594
645
|
};
|
|
595
646
|
|
|
647
|
+
const notifyIncluded = (conn: Conn) =>
|
|
648
|
+
conn.onIncludedChange?.(
|
|
649
|
+
[...conn.included].map(
|
|
650
|
+
(id) => JSON.parse(id) as StatewireHostInternal.IncludedDocument,
|
|
651
|
+
),
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
const restoredIncluded = (
|
|
655
|
+
included: readonly StatewireHostInternal.IncludedDocument[],
|
|
656
|
+
): Set<string> =>
|
|
657
|
+
new Set(
|
|
658
|
+
included.map((entry) => {
|
|
659
|
+
if (
|
|
660
|
+
!Array.isArray(entry) ||
|
|
661
|
+
entry.length !== 2 ||
|
|
662
|
+
typeof entry[0] !== "string" ||
|
|
663
|
+
!(
|
|
664
|
+
entry[1] === null ||
|
|
665
|
+
(Array.isArray(entry[1]) &&
|
|
666
|
+
entry[1].every((part) => typeof part === "string"))
|
|
667
|
+
)
|
|
668
|
+
)
|
|
669
|
+
throw new TypeError("statewire: invalid restored include");
|
|
670
|
+
return documentIdentity(entry[0], entry[1] ?? undefined);
|
|
671
|
+
}),
|
|
672
|
+
);
|
|
673
|
+
|
|
596
674
|
const attachOf = (h: SocketHostState, conn: Conn): StatewireHost.Attach => {
|
|
597
675
|
const requireSelected = (protocol: string) => {
|
|
598
676
|
const entry = conn.selection.protocols.find((p) => p.name === protocol);
|
|
@@ -652,11 +730,15 @@ const attachOf = (h: SocketHostState, conn: Conn): StatewireHost.Attach => {
|
|
|
652
730
|
include: (protocol, key) => {
|
|
653
731
|
if (requireSelected(protocol).record !== undefined)
|
|
654
732
|
throw new Error(`statewire: protocol ${protocol} is not durable`);
|
|
655
|
-
|
|
733
|
+
const id = documentIdentity(protocol, key);
|
|
734
|
+
if (conn.included.has(id)) return;
|
|
735
|
+
conn.included.add(id);
|
|
736
|
+
notifyIncluded(conn);
|
|
656
737
|
markViews(h);
|
|
657
738
|
},
|
|
658
739
|
exclude: (protocol, key) => {
|
|
659
|
-
conn.included.delete(documentIdentity(protocol, key));
|
|
740
|
+
if (!conn.included.delete(documentIdentity(protocol, key))) return;
|
|
741
|
+
notifyIncluded(conn);
|
|
660
742
|
markViews(h);
|
|
661
743
|
},
|
|
662
744
|
};
|
|
@@ -990,6 +1072,7 @@ const admitCmd = (
|
|
|
990
1072
|
// never a close; an ephemeral gap is a violation (the lane has no replay).
|
|
991
1073
|
export type FrameAdmission =
|
|
992
1074
|
| { type: "ok" }
|
|
1075
|
+
| { type: "expired" }
|
|
993
1076
|
| {
|
|
994
1077
|
type: "refused";
|
|
995
1078
|
syn: {
|
|
@@ -1071,6 +1154,7 @@ export const admitFrame = (
|
|
|
1071
1154
|
conn: Conn,
|
|
1072
1155
|
frame: ParsedFrame,
|
|
1073
1156
|
): FrameAdmission => {
|
|
1157
|
+
if (expireLease(h, conn)) return { type: "expired" };
|
|
1074
1158
|
validateContexts(h, conn, frame);
|
|
1075
1159
|
installContexts(h, conn, frame);
|
|
1076
1160
|
const rec = h.clients.get(conn.clientId)!;
|
|
@@ -1120,6 +1204,7 @@ export const admitFrame = (
|
|
|
1120
1204
|
return refusal(h, conn, retry);
|
|
1121
1205
|
}
|
|
1122
1206
|
}
|
|
1207
|
+
if (expireLease(h, conn)) return { type: "expired" };
|
|
1123
1208
|
for (const s of fresh) admitCmd(h, lanes.get(s.protocol)!, conn, s);
|
|
1124
1209
|
for (const [protocol, lane] of lanes) {
|
|
1125
1210
|
const last = fresh.findLast((s) => s.protocol === protocol);
|
|
@@ -1133,7 +1218,8 @@ export const admitFrame = (
|
|
|
1133
1218
|
};
|
|
1134
1219
|
|
|
1135
1220
|
const message = (h: SocketHostState, conn: Conn, data: string) => {
|
|
1136
|
-
if (!conn.open || h.disposed || h.finished !== null)
|
|
1221
|
+
if (!conn.open || h.disposed || h.finished !== null || expireLease(h, conn))
|
|
1222
|
+
return;
|
|
1137
1223
|
if (frameIsOversized(data)) {
|
|
1138
1224
|
sendTo(
|
|
1139
1225
|
h,
|
|
@@ -1269,6 +1355,19 @@ const connectAuthorized = (
|
|
|
1269
1355
|
): StatewireHostInternal.SocketConnection | null => {
|
|
1270
1356
|
const authorize = h.authorize;
|
|
1271
1357
|
if (options.authorized === true) {
|
|
1358
|
+
if (expiredAt(h, options.authorizedAt)) {
|
|
1359
|
+
try {
|
|
1360
|
+
socket.send(leaseExpiredFin);
|
|
1361
|
+
} catch {
|
|
1362
|
+
/* peer already gone */
|
|
1363
|
+
}
|
|
1364
|
+
try {
|
|
1365
|
+
socket.close(1000, "lease-expired");
|
|
1366
|
+
} catch {
|
|
1367
|
+
/* peer already gone */
|
|
1368
|
+
}
|
|
1369
|
+
return null;
|
|
1370
|
+
}
|
|
1272
1371
|
const registration = register(
|
|
1273
1372
|
h,
|
|
1274
1373
|
clientId,
|
|
@@ -1276,6 +1375,9 @@ const connectAuthorized = (
|
|
|
1276
1375
|
options.authorized === true,
|
|
1277
1376
|
options.auth,
|
|
1278
1377
|
options.selection,
|
|
1378
|
+
undefined,
|
|
1379
|
+
options.authorizedAt,
|
|
1380
|
+
options,
|
|
1279
1381
|
);
|
|
1280
1382
|
return registration?.connection ?? null;
|
|
1281
1383
|
}
|
|
@@ -1403,6 +1505,7 @@ const connectAuthorized = (
|
|
|
1403
1505
|
.then(() => authorize?.({ headers }))
|
|
1404
1506
|
.then((auth) => {
|
|
1405
1507
|
if (!pending.open || h.disposed || h.finished !== null) return;
|
|
1508
|
+
const authorizedAt = Date.now();
|
|
1406
1509
|
const selection = negotiateProtocols(
|
|
1407
1510
|
new Headers(headers),
|
|
1408
1511
|
h.wireVersion,
|
|
@@ -1413,7 +1516,7 @@ const connectAuthorized = (
|
|
|
1413
1516
|
closePending(h, pending, 1008, selection.fin.message);
|
|
1414
1517
|
return;
|
|
1415
1518
|
}
|
|
1416
|
-
options.onAuthorized?.(auth, selection);
|
|
1519
|
+
options.onAuthorized?.(auth, selection, authorizedAt);
|
|
1417
1520
|
pending.open = false;
|
|
1418
1521
|
h.pendingConns.delete(pending);
|
|
1419
1522
|
if (pending.timer !== null) clearTimeout(pending.timer);
|
|
@@ -1427,6 +1530,10 @@ const connectAuthorized = (
|
|
|
1427
1530
|
auth,
|
|
1428
1531
|
selection,
|
|
1429
1532
|
frame,
|
|
1533
|
+
authorizedAt,
|
|
1534
|
+
options.onIncludedChange === undefined
|
|
1535
|
+
? {}
|
|
1536
|
+
: { onIncludedChange: options.onIncludedChange },
|
|
1430
1537
|
);
|
|
1431
1538
|
} catch (error) {
|
|
1432
1539
|
if (violationReason(error) === null) throw error;
|
|
@@ -1489,7 +1596,13 @@ export const register = (
|
|
|
1489
1596
|
protocols: [{ name: h.protocol.name, version: h.protocol.version }],
|
|
1490
1597
|
},
|
|
1491
1598
|
frame: ParsedFrame = { cmd: [] },
|
|
1599
|
+
authorizedAt?: number,
|
|
1600
|
+
included: Pick<
|
|
1601
|
+
StatewireHostInternal.SocketConnectOptions,
|
|
1602
|
+
"included" | "onIncludedChange"
|
|
1603
|
+
> = {},
|
|
1492
1604
|
): Registration | null => {
|
|
1605
|
+
const restored = restoredIncluded(included.included ?? []);
|
|
1493
1606
|
const refuse = (code: number, reason: string) => {
|
|
1494
1607
|
try {
|
|
1495
1608
|
socket.close(code, reason);
|
|
@@ -1513,6 +1626,14 @@ export const register = (
|
|
|
1513
1626
|
if (atSubscriberLimit(h)) {
|
|
1514
1627
|
return refuseSubscriberLimit(socket);
|
|
1515
1628
|
}
|
|
1629
|
+
if (expiredAt(h, authorizedAt)) {
|
|
1630
|
+
try {
|
|
1631
|
+
socket.send(leaseExpiredFin);
|
|
1632
|
+
} catch {
|
|
1633
|
+
/* peer already gone */
|
|
1634
|
+
}
|
|
1635
|
+
return refuse(1000, "lease-expired");
|
|
1636
|
+
}
|
|
1516
1637
|
const known = h.clients.get(clientId);
|
|
1517
1638
|
if (known !== undefined && !authEquals(known.principal, auth)) {
|
|
1518
1639
|
try {
|
|
@@ -1553,10 +1674,13 @@ export const register = (
|
|
|
1553
1674
|
lastSentAt: 0,
|
|
1554
1675
|
lease: null,
|
|
1555
1676
|
auth,
|
|
1677
|
+
authorizedAt,
|
|
1678
|
+
leaseTimer: null,
|
|
1556
1679
|
lanes: new Map(),
|
|
1557
1680
|
contexts: new Map(),
|
|
1558
1681
|
documents: new Map(),
|
|
1559
|
-
included:
|
|
1682
|
+
included: restored,
|
|
1683
|
+
onIncludedChange: included.onIncludedChange,
|
|
1560
1684
|
view: new Map(),
|
|
1561
1685
|
nextDoc: 1,
|
|
1562
1686
|
pendingApplied: [],
|
|
@@ -1577,6 +1701,8 @@ export const register = (
|
|
|
1577
1701
|
}
|
|
1578
1702
|
gcClients(h);
|
|
1579
1703
|
ensureHeartbeat(h);
|
|
1704
|
+
armLeaseTimer(h, conn);
|
|
1705
|
+
if (!conn.open) return null;
|
|
1580
1706
|
const isEphemeral = (s: CmdStatement) =>
|
|
1581
1707
|
selection.protocols.find((p) => p.name === s.protocol)?.record ===
|
|
1582
1708
|
"ephemeral";
|
|
@@ -1596,6 +1722,7 @@ export const register = (
|
|
|
1596
1722
|
const earlyAdmission = admit(early);
|
|
1597
1723
|
if (early.length > 0) flush(h);
|
|
1598
1724
|
notifyClientRecords(h);
|
|
1725
|
+
if (expireLease(h, conn)) return null;
|
|
1599
1726
|
const ops: WireOp[] = [];
|
|
1600
1727
|
if (selects(conn, h.protocol.name)) {
|
|
1601
1728
|
const { value, meta } = encodeDocument(h.stateHost!.snapshot());
|
|
@@ -1670,10 +1797,15 @@ const resume = (h: SocketHostState) => {
|
|
|
1670
1797
|
h.disposed = false;
|
|
1671
1798
|
h.stateHost?.setOnOp((op) => recordOp(h, op));
|
|
1672
1799
|
h.stateHost?.setOnDocuments(() => markViews(h));
|
|
1800
|
+
for (const conn of h.conns) armLeaseTimer(h, conn);
|
|
1673
1801
|
};
|
|
1674
1802
|
|
|
1675
1803
|
const suspend = (h: SocketHostState) => {
|
|
1676
1804
|
h.disposed = true;
|
|
1805
|
+
for (const conn of h.conns) {
|
|
1806
|
+
if (conn.leaseTimer !== null) clearTimeout(conn.leaseTimer);
|
|
1807
|
+
conn.leaseTimer = null;
|
|
1808
|
+
}
|
|
1677
1809
|
if (h.heartbeatTimer !== null) {
|
|
1678
1810
|
clearInterval(h.heartbeatTimer);
|
|
1679
1811
|
h.heartbeatTimer = null;
|
|
@@ -1725,6 +1857,7 @@ const useSocketHost = (
|
|
|
1725
1857
|
key: undefined,
|
|
1726
1858
|
wireVersion: { version: WIRE_VERSION },
|
|
1727
1859
|
heartbeatMs: 15_000,
|
|
1860
|
+
maxLeaseLifetimeMs: 86_400_000,
|
|
1728
1861
|
wsInitTimeoutMs: 5_000,
|
|
1729
1862
|
maxSubscribers: MAX_SUBSCRIBERS,
|
|
1730
1863
|
maxInflightCommands: MAX_INFLIGHT_COMMANDS_PER_HOST,
|
|
@@ -1768,6 +1901,22 @@ const useSocketHost = (
|
|
|
1768
1901
|
h.key = options?.key;
|
|
1769
1902
|
h.wireVersion = options?.wireVersion ?? { version: WIRE_VERSION };
|
|
1770
1903
|
setHeartbeatMs(h, options?.heartbeatIntervalMs ?? 15_000);
|
|
1904
|
+
const maxLeaseLifetimeMs =
|
|
1905
|
+
options?.maxLeaseLifetimeMs === undefined
|
|
1906
|
+
? 86_400_000
|
|
1907
|
+
: options.maxLeaseLifetimeMs;
|
|
1908
|
+
if (
|
|
1909
|
+
maxLeaseLifetimeMs !== null &&
|
|
1910
|
+
(!Number.isFinite(maxLeaseLifetimeMs) || maxLeaseLifetimeMs <= 0)
|
|
1911
|
+
) {
|
|
1912
|
+
throw new TypeError(
|
|
1913
|
+
"maxLeaseLifetimeMs must be finite and positive or null",
|
|
1914
|
+
);
|
|
1915
|
+
}
|
|
1916
|
+
if (h.maxLeaseLifetimeMs !== maxLeaseLifetimeMs) {
|
|
1917
|
+
h.maxLeaseLifetimeMs = maxLeaseLifetimeMs;
|
|
1918
|
+
for (const conn of [...h.conns]) armLeaseTimer(h, conn);
|
|
1919
|
+
}
|
|
1771
1920
|
h.pushback = options?.pushback;
|
|
1772
1921
|
h.wsInitTimeoutMs = options?.wsInitTimeoutMs ?? 5_000;
|
|
1773
1922
|
const maxSubscribers =
|
package/src/host-internal.ts
CHANGED
|
@@ -21,6 +21,7 @@ export namespace StatewireHostInternal {
|
|
|
21
21
|
headers?: Record<string, string>;
|
|
22
22
|
/** The adapter restored this socket from an already-authorized session. */
|
|
23
23
|
authorized?: boolean;
|
|
24
|
+
authorizedAt?: number;
|
|
24
25
|
selection?: StatewireProtocol.Selection;
|
|
25
26
|
/** The restored session's captured auth value; it must satisfy the client record's principal binding. */
|
|
26
27
|
auth?: unknown;
|
|
@@ -28,9 +29,20 @@ export namespace StatewireHostInternal {
|
|
|
28
29
|
onAuthorized?: (
|
|
29
30
|
auth: unknown,
|
|
30
31
|
selection: StatewireProtocol.Selection,
|
|
32
|
+
authorizedAt: number,
|
|
31
33
|
) => void;
|
|
34
|
+
/** Documents the attach had included before the adapter retained its socket; they ride the hello. */
|
|
35
|
+
included?: readonly IncludedDocument[];
|
|
36
|
+
/** Called with the attach's whole include set each time `include` or `exclude` changes it. */
|
|
37
|
+
onIncludedChange?: (included: readonly IncludedDocument[]) => void;
|
|
32
38
|
};
|
|
33
39
|
|
|
40
|
+
/** A durable document an attach included: `[protocol, key]`. */
|
|
41
|
+
export type IncludedDocument = readonly [
|
|
42
|
+
protocol: string,
|
|
43
|
+
key: readonly string[] | null,
|
|
44
|
+
];
|
|
45
|
+
|
|
34
46
|
export type SocketHost = {
|
|
35
47
|
/** Null means refused: the socket was told (fin and/or close) and is dead. */
|
|
36
48
|
connect: (
|
|
@@ -88,6 +100,8 @@ export namespace StatewireHostInternal {
|
|
|
88
100
|
/** Time allowed to complete WebSocket initialization and authorization. */
|
|
89
101
|
wsInitTimeoutMs?: number;
|
|
90
102
|
heartbeatIntervalMs?: number;
|
|
103
|
+
/** Maximum attach lifetime in milliseconds; defaults to 24 hours, null disables expiry. */
|
|
104
|
+
maxLeaseLifetimeMs?: number | null;
|
|
91
105
|
/** Maximum number of concurrently attached WebSocket and SSE streams. */
|
|
92
106
|
maxSubscribers?: number;
|
|
93
107
|
/** Maximum number of admitted commands still running across all clients. */
|
package/src/statewire.ts
CHANGED
|
@@ -156,6 +156,15 @@ export const useStatewire = <
|
|
|
156
156
|
[transportInvoke],
|
|
157
157
|
);
|
|
158
158
|
|
|
159
|
+
const setContext = useCallback(
|
|
160
|
+
(protocol: string, value: Statewire.Context) => {
|
|
161
|
+
if (!transportSetContext)
|
|
162
|
+
throw new Error("statewire: transport does not support context");
|
|
163
|
+
transportSetContext(protocol, value);
|
|
164
|
+
},
|
|
165
|
+
[transportSetContext],
|
|
166
|
+
);
|
|
167
|
+
|
|
159
168
|
return useMemo(() => {
|
|
160
169
|
const failures = (): NonNullable<typeof transportFailures> => {
|
|
161
170
|
if (!transportFailures) {
|
|
@@ -168,11 +177,7 @@ export const useStatewire = <
|
|
|
168
177
|
documents: source,
|
|
169
178
|
selection,
|
|
170
179
|
invoke,
|
|
171
|
-
setContext
|
|
172
|
-
if (!transportSetContext)
|
|
173
|
-
throw new Error("statewire: transport does not support context");
|
|
174
|
-
transportSetContext(protocol, value);
|
|
175
|
-
},
|
|
180
|
+
setContext,
|
|
176
181
|
getState: core.getState as () => TState,
|
|
177
182
|
connection,
|
|
178
183
|
commands: core.commands as Statewire.CommandsProxy<TCommands>,
|
|
@@ -193,7 +198,7 @@ export const useStatewire = <
|
|
|
193
198
|
selection,
|
|
194
199
|
invoke,
|
|
195
200
|
transportFailures,
|
|
196
|
-
|
|
201
|
+
setContext,
|
|
197
202
|
]);
|
|
198
203
|
};
|
|
199
204
|
|
package/src/transports/sse.ts
CHANGED
|
@@ -61,6 +61,7 @@ export type ConnectionState = StatewireClient.ConnectionState;
|
|
|
61
61
|
/** The live wire a remote session drives: connection status plus frame submission. */
|
|
62
62
|
export type RemoteLink = {
|
|
63
63
|
storageFailed: (error: Error) => void;
|
|
64
|
+
protocolFailed: (error: Error) => void;
|
|
64
65
|
connection: StatewireClient.Connection;
|
|
65
66
|
/** A deferred first attach still waiting; `reconnect()` releases it. */
|
|
66
67
|
parked: boolean;
|
|
@@ -69,6 +70,16 @@ export type RemoteLink = {
|
|
|
69
70
|
|
|
70
71
|
const LEASE_HEADER = "Statewire-Lease";
|
|
71
72
|
|
|
73
|
+
let warnedNoLease = false;
|
|
74
|
+
|
|
75
|
+
const warnNoLease = () => {
|
|
76
|
+
if (warnedNoLease) return;
|
|
77
|
+
warnedNoLease = true;
|
|
78
|
+
console.warn(
|
|
79
|
+
"statewire: SSE attach returned no readable Statewire-Lease header; commands will re-attach the stream. Cross-origin hosts must expose it (Access-Control-Expose-Headers: Statewire-Lease).",
|
|
80
|
+
);
|
|
81
|
+
};
|
|
82
|
+
|
|
72
83
|
const baseUrl = (options: StatewireClient.HttpOptions) =>
|
|
73
84
|
options.url.replace(/\/$/, "");
|
|
74
85
|
|
|
@@ -181,6 +192,7 @@ const attemptStream = async (
|
|
|
181
192
|
if (!res.body)
|
|
182
193
|
throw new Error("statewire: protocol error: stream response has no body");
|
|
183
194
|
c.lease = res.headers.get(LEASE_HEADER);
|
|
195
|
+
if (c.lease === null) warnNoLease();
|
|
184
196
|
const reader = res.body.getReader();
|
|
185
197
|
const cancel = () => {
|
|
186
198
|
void reader.cancel().catch(() => {});
|