statewire 0.20.0 → 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.
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +18 -4
- package/dist/client/connection.js.map +1 -1
- package/dist/client/session.d.ts +3 -1
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +41 -16
- package/dist/client/session.js.map +1 -1
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js +14 -11
- package/dist/host/host.js.map +1 -1
- package/dist/host/http.d.ts.map +1 -1
- package/dist/host/http.js +13 -8
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +8 -2
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +83 -11
- package/dist/host/socket.js.map +1 -1
- package/dist/host-internal.d.ts +4 -1
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +6 -5
- package/dist/statewire.js.map +1 -1
- package/dist/transports/sse.d.ts +1 -0
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +7 -0
- package/dist/transports/sse.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +16 -4
- 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 +111 -6
- package/src/host-internal.ts +4 -0
- package/src/statewire.ts +11 -6
- package/src/transports/sse.ts +12 -0
package/src/host/socket.ts
CHANGED
|
@@ -128,6 +128,8 @@ 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>;
|
|
@@ -316,13 +318,11 @@ const cmdOf = (adm: Admission, outcome: CommandsOutcome): Cmd => {
|
|
|
316
318
|
};
|
|
317
319
|
}
|
|
318
320
|
default:
|
|
321
|
+
console.error("statewire command failed", outcome.error);
|
|
319
322
|
return {
|
|
320
323
|
...at,
|
|
321
324
|
type: "failed",
|
|
322
|
-
message:
|
|
323
|
-
outcome.error instanceof Error
|
|
324
|
-
? outcome.error.message
|
|
325
|
-
: String(outcome.error),
|
|
325
|
+
message: "the command failed",
|
|
326
326
|
};
|
|
327
327
|
}
|
|
328
328
|
};
|
|
@@ -356,6 +356,7 @@ export type SocketHostState = {
|
|
|
356
356
|
key: readonly string[] | undefined;
|
|
357
357
|
wireVersion: StatewireProtocol.Range;
|
|
358
358
|
heartbeatMs: number;
|
|
359
|
+
maxLeaseLifetimeMs: number | null;
|
|
359
360
|
wsInitTimeoutMs: number;
|
|
360
361
|
maxSubscribers: number;
|
|
361
362
|
maxInflightCommands: number;
|
|
@@ -451,6 +452,10 @@ export const closeConn = (
|
|
|
451
452
|
if (!conn.open) return;
|
|
452
453
|
conn.open = false;
|
|
453
454
|
h.conns.delete(conn);
|
|
455
|
+
if (conn.leaseTimer !== null) {
|
|
456
|
+
clearTimeout(conn.leaseTimer);
|
|
457
|
+
conn.leaseTimer = null;
|
|
458
|
+
}
|
|
454
459
|
try {
|
|
455
460
|
conn.socket.close(code, reason);
|
|
456
461
|
} catch {
|
|
@@ -473,6 +478,49 @@ export const sendTo = (h: SocketHostState, conn: Conn, data: string) => {
|
|
|
473
478
|
}
|
|
474
479
|
};
|
|
475
480
|
|
|
481
|
+
const expiredAt = (h: SocketHostState, authorizedAt: number | undefined) => {
|
|
482
|
+
const now = Date.now();
|
|
483
|
+
return (
|
|
484
|
+
authorizedAt === undefined ||
|
|
485
|
+
!Number.isFinite(authorizedAt) ||
|
|
486
|
+
authorizedAt < 0 ||
|
|
487
|
+
authorizedAt > now ||
|
|
488
|
+
(h.maxLeaseLifetimeMs !== null &&
|
|
489
|
+
now - authorizedAt >= h.maxLeaseLifetimeMs)
|
|
490
|
+
);
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const leaseExpiredFin = JSON.stringify({
|
|
494
|
+
fin: { reason: "reconnect", code: "lease-expired" },
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
export const expireLease = (h: SocketHostState, conn: Conn): boolean => {
|
|
498
|
+
if (!conn.open || expiredAt(h, conn.authorizedAt)) {
|
|
499
|
+
if (conn.open) {
|
|
500
|
+
sendTo(h, conn, leaseExpiredFin);
|
|
501
|
+
closeConn(h, conn, 1000, "lease-expired");
|
|
502
|
+
}
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
return false;
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const armLeaseTimer = (h: SocketHostState, conn: Conn) => {
|
|
509
|
+
if (conn.leaseTimer !== null) clearTimeout(conn.leaseTimer);
|
|
510
|
+
conn.leaseTimer = null;
|
|
511
|
+
if (h.disposed || h.maxLeaseLifetimeMs === null || expireLease(h, conn))
|
|
512
|
+
return;
|
|
513
|
+
const delay = h.maxLeaseLifetimeMs - (Date.now() - conn.authorizedAt!);
|
|
514
|
+
conn.leaseTimer = setTimeout(
|
|
515
|
+
() => {
|
|
516
|
+
conn.leaseTimer = null;
|
|
517
|
+
if (!expireLease(h, conn)) armLeaseTimer(h, conn);
|
|
518
|
+
},
|
|
519
|
+
Math.min(delay, 2_147_483_647),
|
|
520
|
+
);
|
|
521
|
+
(conn.leaseTimer as unknown as { unref?: () => void }).unref?.();
|
|
522
|
+
};
|
|
523
|
+
|
|
476
524
|
const violate = (h: SocketHostState, conn: Conn, reason: string) => {
|
|
477
525
|
sendTo(
|
|
478
526
|
h,
|
|
@@ -990,6 +1038,7 @@ const admitCmd = (
|
|
|
990
1038
|
// never a close; an ephemeral gap is a violation (the lane has no replay).
|
|
991
1039
|
export type FrameAdmission =
|
|
992
1040
|
| { type: "ok" }
|
|
1041
|
+
| { type: "expired" }
|
|
993
1042
|
| {
|
|
994
1043
|
type: "refused";
|
|
995
1044
|
syn: {
|
|
@@ -1071,6 +1120,7 @@ export const admitFrame = (
|
|
|
1071
1120
|
conn: Conn,
|
|
1072
1121
|
frame: ParsedFrame,
|
|
1073
1122
|
): FrameAdmission => {
|
|
1123
|
+
if (expireLease(h, conn)) return { type: "expired" };
|
|
1074
1124
|
validateContexts(h, conn, frame);
|
|
1075
1125
|
installContexts(h, conn, frame);
|
|
1076
1126
|
const rec = h.clients.get(conn.clientId)!;
|
|
@@ -1120,6 +1170,7 @@ export const admitFrame = (
|
|
|
1120
1170
|
return refusal(h, conn, retry);
|
|
1121
1171
|
}
|
|
1122
1172
|
}
|
|
1173
|
+
if (expireLease(h, conn)) return { type: "expired" };
|
|
1123
1174
|
for (const s of fresh) admitCmd(h, lanes.get(s.protocol)!, conn, s);
|
|
1124
1175
|
for (const [protocol, lane] of lanes) {
|
|
1125
1176
|
const last = fresh.findLast((s) => s.protocol === protocol);
|
|
@@ -1133,7 +1184,8 @@ export const admitFrame = (
|
|
|
1133
1184
|
};
|
|
1134
1185
|
|
|
1135
1186
|
const message = (h: SocketHostState, conn: Conn, data: string) => {
|
|
1136
|
-
if (!conn.open || h.disposed || h.finished !== null)
|
|
1187
|
+
if (!conn.open || h.disposed || h.finished !== null || expireLease(h, conn))
|
|
1188
|
+
return;
|
|
1137
1189
|
if (frameIsOversized(data)) {
|
|
1138
1190
|
sendTo(
|
|
1139
1191
|
h,
|
|
@@ -1269,6 +1321,19 @@ const connectAuthorized = (
|
|
|
1269
1321
|
): StatewireHostInternal.SocketConnection | null => {
|
|
1270
1322
|
const authorize = h.authorize;
|
|
1271
1323
|
if (options.authorized === true) {
|
|
1324
|
+
if (expiredAt(h, options.authorizedAt)) {
|
|
1325
|
+
try {
|
|
1326
|
+
socket.send(leaseExpiredFin);
|
|
1327
|
+
} catch {
|
|
1328
|
+
/* peer already gone */
|
|
1329
|
+
}
|
|
1330
|
+
try {
|
|
1331
|
+
socket.close(1000, "lease-expired");
|
|
1332
|
+
} catch {
|
|
1333
|
+
/* peer already gone */
|
|
1334
|
+
}
|
|
1335
|
+
return null;
|
|
1336
|
+
}
|
|
1272
1337
|
const registration = register(
|
|
1273
1338
|
h,
|
|
1274
1339
|
clientId,
|
|
@@ -1276,6 +1341,8 @@ const connectAuthorized = (
|
|
|
1276
1341
|
options.authorized === true,
|
|
1277
1342
|
options.auth,
|
|
1278
1343
|
options.selection,
|
|
1344
|
+
undefined,
|
|
1345
|
+
options.authorizedAt,
|
|
1279
1346
|
);
|
|
1280
1347
|
return registration?.connection ?? null;
|
|
1281
1348
|
}
|
|
@@ -1403,6 +1470,7 @@ const connectAuthorized = (
|
|
|
1403
1470
|
.then(() => authorize?.({ headers }))
|
|
1404
1471
|
.then((auth) => {
|
|
1405
1472
|
if (!pending.open || h.disposed || h.finished !== null) return;
|
|
1473
|
+
const authorizedAt = Date.now();
|
|
1406
1474
|
const selection = negotiateProtocols(
|
|
1407
1475
|
new Headers(headers),
|
|
1408
1476
|
h.wireVersion,
|
|
@@ -1413,7 +1481,7 @@ const connectAuthorized = (
|
|
|
1413
1481
|
closePending(h, pending, 1008, selection.fin.message);
|
|
1414
1482
|
return;
|
|
1415
1483
|
}
|
|
1416
|
-
options.onAuthorized?.(auth, selection);
|
|
1484
|
+
options.onAuthorized?.(auth, selection, authorizedAt);
|
|
1417
1485
|
pending.open = false;
|
|
1418
1486
|
h.pendingConns.delete(pending);
|
|
1419
1487
|
if (pending.timer !== null) clearTimeout(pending.timer);
|
|
@@ -1427,6 +1495,7 @@ const connectAuthorized = (
|
|
|
1427
1495
|
auth,
|
|
1428
1496
|
selection,
|
|
1429
1497
|
frame,
|
|
1498
|
+
authorizedAt,
|
|
1430
1499
|
);
|
|
1431
1500
|
} catch (error) {
|
|
1432
1501
|
if (violationReason(error) === null) throw error;
|
|
@@ -1489,6 +1558,7 @@ export const register = (
|
|
|
1489
1558
|
protocols: [{ name: h.protocol.name, version: h.protocol.version }],
|
|
1490
1559
|
},
|
|
1491
1560
|
frame: ParsedFrame = { cmd: [] },
|
|
1561
|
+
authorizedAt?: number,
|
|
1492
1562
|
): Registration | null => {
|
|
1493
1563
|
const refuse = (code: number, reason: string) => {
|
|
1494
1564
|
try {
|
|
@@ -1513,6 +1583,14 @@ export const register = (
|
|
|
1513
1583
|
if (atSubscriberLimit(h)) {
|
|
1514
1584
|
return refuseSubscriberLimit(socket);
|
|
1515
1585
|
}
|
|
1586
|
+
if (expiredAt(h, authorizedAt)) {
|
|
1587
|
+
try {
|
|
1588
|
+
socket.send(leaseExpiredFin);
|
|
1589
|
+
} catch {
|
|
1590
|
+
/* peer already gone */
|
|
1591
|
+
}
|
|
1592
|
+
return refuse(1000, "lease-expired");
|
|
1593
|
+
}
|
|
1516
1594
|
const known = h.clients.get(clientId);
|
|
1517
1595
|
if (known !== undefined && !authEquals(known.principal, auth)) {
|
|
1518
1596
|
try {
|
|
@@ -1553,6 +1631,8 @@ export const register = (
|
|
|
1553
1631
|
lastSentAt: 0,
|
|
1554
1632
|
lease: null,
|
|
1555
1633
|
auth,
|
|
1634
|
+
authorizedAt,
|
|
1635
|
+
leaseTimer: null,
|
|
1556
1636
|
lanes: new Map(),
|
|
1557
1637
|
contexts: new Map(),
|
|
1558
1638
|
documents: new Map(),
|
|
@@ -1577,6 +1657,8 @@ export const register = (
|
|
|
1577
1657
|
}
|
|
1578
1658
|
gcClients(h);
|
|
1579
1659
|
ensureHeartbeat(h);
|
|
1660
|
+
armLeaseTimer(h, conn);
|
|
1661
|
+
if (!conn.open) return null;
|
|
1580
1662
|
const isEphemeral = (s: CmdStatement) =>
|
|
1581
1663
|
selection.protocols.find((p) => p.name === s.protocol)?.record ===
|
|
1582
1664
|
"ephemeral";
|
|
@@ -1596,6 +1678,7 @@ export const register = (
|
|
|
1596
1678
|
const earlyAdmission = admit(early);
|
|
1597
1679
|
if (early.length > 0) flush(h);
|
|
1598
1680
|
notifyClientRecords(h);
|
|
1681
|
+
if (expireLease(h, conn)) return null;
|
|
1599
1682
|
const ops: WireOp[] = [];
|
|
1600
1683
|
if (selects(conn, h.protocol.name)) {
|
|
1601
1684
|
const { value, meta } = encodeDocument(h.stateHost!.snapshot());
|
|
@@ -1670,10 +1753,15 @@ const resume = (h: SocketHostState) => {
|
|
|
1670
1753
|
h.disposed = false;
|
|
1671
1754
|
h.stateHost?.setOnOp((op) => recordOp(h, op));
|
|
1672
1755
|
h.stateHost?.setOnDocuments(() => markViews(h));
|
|
1756
|
+
for (const conn of h.conns) armLeaseTimer(h, conn);
|
|
1673
1757
|
};
|
|
1674
1758
|
|
|
1675
1759
|
const suspend = (h: SocketHostState) => {
|
|
1676
1760
|
h.disposed = true;
|
|
1761
|
+
for (const conn of h.conns) {
|
|
1762
|
+
if (conn.leaseTimer !== null) clearTimeout(conn.leaseTimer);
|
|
1763
|
+
conn.leaseTimer = null;
|
|
1764
|
+
}
|
|
1677
1765
|
if (h.heartbeatTimer !== null) {
|
|
1678
1766
|
clearInterval(h.heartbeatTimer);
|
|
1679
1767
|
h.heartbeatTimer = null;
|
|
@@ -1725,6 +1813,7 @@ const useSocketHost = (
|
|
|
1725
1813
|
key: undefined,
|
|
1726
1814
|
wireVersion: { version: WIRE_VERSION },
|
|
1727
1815
|
heartbeatMs: 15_000,
|
|
1816
|
+
maxLeaseLifetimeMs: 86_400_000,
|
|
1728
1817
|
wsInitTimeoutMs: 5_000,
|
|
1729
1818
|
maxSubscribers: MAX_SUBSCRIBERS,
|
|
1730
1819
|
maxInflightCommands: MAX_INFLIGHT_COMMANDS_PER_HOST,
|
|
@@ -1768,6 +1857,22 @@ const useSocketHost = (
|
|
|
1768
1857
|
h.key = options?.key;
|
|
1769
1858
|
h.wireVersion = options?.wireVersion ?? { version: WIRE_VERSION };
|
|
1770
1859
|
setHeartbeatMs(h, options?.heartbeatIntervalMs ?? 15_000);
|
|
1860
|
+
const maxLeaseLifetimeMs =
|
|
1861
|
+
options?.maxLeaseLifetimeMs === undefined
|
|
1862
|
+
? 86_400_000
|
|
1863
|
+
: options.maxLeaseLifetimeMs;
|
|
1864
|
+
if (
|
|
1865
|
+
maxLeaseLifetimeMs !== null &&
|
|
1866
|
+
(!Number.isFinite(maxLeaseLifetimeMs) || maxLeaseLifetimeMs <= 0)
|
|
1867
|
+
) {
|
|
1868
|
+
throw new TypeError(
|
|
1869
|
+
"maxLeaseLifetimeMs must be finite and positive or null",
|
|
1870
|
+
);
|
|
1871
|
+
}
|
|
1872
|
+
if (h.maxLeaseLifetimeMs !== maxLeaseLifetimeMs) {
|
|
1873
|
+
h.maxLeaseLifetimeMs = maxLeaseLifetimeMs;
|
|
1874
|
+
for (const conn of [...h.conns]) armLeaseTimer(h, conn);
|
|
1875
|
+
}
|
|
1771
1876
|
h.pushback = options?.pushback;
|
|
1772
1877
|
h.wsInitTimeoutMs = options?.wsInitTimeoutMs ?? 5_000;
|
|
1773
1878
|
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,6 +29,7 @@ export namespace StatewireHostInternal {
|
|
|
28
29
|
onAuthorized?: (
|
|
29
30
|
auth: unknown,
|
|
30
31
|
selection: StatewireProtocol.Selection,
|
|
32
|
+
authorizedAt: number,
|
|
31
33
|
) => void;
|
|
32
34
|
};
|
|
33
35
|
|
|
@@ -88,6 +90,8 @@ export namespace StatewireHostInternal {
|
|
|
88
90
|
/** Time allowed to complete WebSocket initialization and authorization. */
|
|
89
91
|
wsInitTimeoutMs?: number;
|
|
90
92
|
heartbeatIntervalMs?: number;
|
|
93
|
+
/** Maximum attach lifetime in milliseconds; defaults to 24 hours, null disables expiry. */
|
|
94
|
+
maxLeaseLifetimeMs?: number | null;
|
|
91
95
|
/** Maximum number of concurrently attached WebSocket and SSE streams. */
|
|
92
96
|
maxSubscribers?: number;
|
|
93
97
|
/** 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(() => {});
|