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.
- package/README.md +15 -1
- 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 +10 -1
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +92 -27
- package/dist/client/session.js.map +1 -1
- package/dist/client/sessionStore.d.ts +2 -0
- package/dist/client/sessionStore.d.ts.map +1 -1
- package/dist/client/sessionStore.js.map +1 -1
- package/dist/host/host.d.ts +2 -1
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js +20 -12
- package/dist/host/host.js.map +1 -1
- package/dist/host/http.d.ts.map +1 -1
- package/dist/host/http.js +14 -9
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +14 -3
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +162 -20
- package/dist/host/socket.js.map +1 -1
- package/dist/host-internal.d.ts +5 -2
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/host.d.ts +6 -0
- package/dist/host.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/statewire.d.ts +3 -0
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +23 -5
- package/dist/statewire.js.map +1 -1
- package/dist/transports/remote.d.ts.map +1 -1
- package/dist/transports/remote.js +3 -2
- package/dist/transports/remote.js.map +1 -1
- package/dist/transports/sse.d.ts +5 -0
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +12 -0
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/testing.d.ts +2 -0
- package/dist/transports/testing.d.ts.map +1 -1
- package/dist/transports/testing.js +11 -4
- package/dist/transports/testing.js.map +1 -1
- package/dist/types.d.ts +4 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/wire/context.d.ts +14 -0
- package/dist/wire/context.d.ts.map +1 -0
- package/dist/wire/context.js +31 -0
- package/dist/wire/context.js.map +1 -0
- package/dist/wire/limits.d.ts +2 -1
- package/dist/wire/limits.d.ts.map +1 -1
- package/dist/wire/limits.js +2 -1
- package/dist/wire/limits.js.map +1 -1
- package/dist/wire/protocol.d.ts +1 -1
- package/dist/wire/protocol.js +4 -4
- package/dist/wire/protocol.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +16 -4
- package/src/client/session.ts +128 -19
- package/src/client/sessionStore.ts +5 -1
- package/src/host/host.ts +18 -3
- package/src/host/http.ts +37 -5
- package/src/host/socket.ts +232 -13
- package/src/host-internal.ts +4 -0
- package/src/host.ts +5 -0
- package/src/statewire.ts +34 -3
- package/src/transports/remote.ts +3 -0
- package/src/transports/sse.ts +19 -0
- package/src/transports/testing.ts +14 -3
- package/src/types.ts +12 -0
- package/src/wire/context.ts +40 -0
- package/src/wire/limits.ts +2 -0
- package/src/wire/protocol.ts +1 -1
package/src/client/session.ts
CHANGED
|
@@ -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
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
1476
|
-
|
|
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
|
-
? {
|
|
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<{
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
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 (
|
|
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, {
|