statewire 0.9.2 → 0.11.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 +1 -0
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +8 -1
- package/dist/client/connection.js.map +1 -1
- package/dist/client/session.d.ts +13 -4
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +263 -66
- package/dist/client/session.js.map +1 -1
- package/dist/client/sessionStore.d.ts +5 -14
- package/dist/client/sessionStore.d.ts.map +1 -1
- package/dist/client/sessionStore.js +4 -1
- package/dist/client/sessionStore.js.map +1 -1
- package/dist/host/http.d.ts +7 -0
- package/dist/host/http.d.ts.map +1 -0
- package/dist/host/http.js +175 -0
- package/dist/host/http.js.map +1 -0
- package/dist/host/socket.d.ts +102 -1
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +48 -27
- package/dist/host/socket.js.map +1 -1
- package/dist/host-internal.d.ts +5 -0
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/statewire.d.ts +48 -7
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +9 -2
- package/dist/statewire.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +13 -1
- package/src/client/session.ts +352 -70
- package/src/client/sessionStore.ts +8 -15
- package/src/host/http.ts +280 -0
- package/src/host/socket.ts +120 -57
- package/src/host-internal.ts +5 -0
- package/src/statewire.ts +80 -8
- package/src/types.ts +1 -1
package/src/host/socket.ts
CHANGED
|
@@ -12,8 +12,9 @@ import { createMacrotaskPoster } from "../util/macrotask";
|
|
|
12
12
|
import type { StatewireMetaEntry } from "../wire/meta";
|
|
13
13
|
import type { StatewirePath } from "../wire/ops";
|
|
14
14
|
import type { StatewireCmd, StatewireEnvelope } from "../client/connection";
|
|
15
|
+
import { frames as serveFrames, stream as serveStream } from "./http";
|
|
15
16
|
|
|
16
|
-
const CLIENT_ID_RE = /^[A-Za-z0-9._~-]{1,256}$/;
|
|
17
|
+
export const CLIENT_ID_RE = /^[A-Za-z0-9._~-]{1,256}$/;
|
|
17
18
|
const RESULT_TTL_MS = 30_000;
|
|
18
19
|
const RESULT_LIMIT = 256;
|
|
19
20
|
const CLIENT_RECORD_LIMIT = 1024;
|
|
@@ -33,17 +34,18 @@ type Admission = {
|
|
|
33
34
|
settled: boolean;
|
|
34
35
|
};
|
|
35
36
|
|
|
36
|
-
type ClientRec = {
|
|
37
|
+
export type ClientRec = {
|
|
37
38
|
id: string;
|
|
38
39
|
lastSeq: number;
|
|
39
40
|
ctxSeq: number;
|
|
41
|
+
lease: string | null;
|
|
40
42
|
touched: number;
|
|
41
43
|
context: Record<string, unknown> | null;
|
|
42
44
|
results: Map<number, { ts: number; cmd: StatewireCmd }>;
|
|
43
45
|
inflight: Map<number, Admission>;
|
|
44
46
|
};
|
|
45
47
|
|
|
46
|
-
type Conn = {
|
|
48
|
+
export type Conn = {
|
|
47
49
|
clientId: string;
|
|
48
50
|
socket: StatewireHostInternal.Socket;
|
|
49
51
|
open: boolean;
|
|
@@ -70,7 +72,7 @@ type CmdStatement = {
|
|
|
70
72
|
meta?: unknown;
|
|
71
73
|
};
|
|
72
74
|
|
|
73
|
-
type ParsedFrame = {
|
|
75
|
+
export type ParsedFrame = {
|
|
74
76
|
headers: Record<string, string> | null;
|
|
75
77
|
ctx: CtxStatement[];
|
|
76
78
|
cmd: CmdStatement[];
|
|
@@ -121,13 +123,10 @@ const contextValue = (ops: unknown): Record<string, unknown> => {
|
|
|
121
123
|
return value;
|
|
122
124
|
};
|
|
123
125
|
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
} catch {
|
|
129
|
-
return violation("malformed frame");
|
|
130
|
-
}
|
|
126
|
+
export const parseFrameValue = (
|
|
127
|
+
frame: unknown,
|
|
128
|
+
first: boolean,
|
|
129
|
+
): ParsedFrame => {
|
|
131
130
|
if (!isPlainObject(frame)) return violation("malformed frame");
|
|
132
131
|
if (
|
|
133
132
|
frame["headers"] === undefined &&
|
|
@@ -179,6 +178,16 @@ const parseFrame = (raw: string, first: boolean): ParsedFrame => {
|
|
|
179
178
|
return { headers, ctx, cmd };
|
|
180
179
|
};
|
|
181
180
|
|
|
181
|
+
export const parseFrame = (raw: string, first: boolean): ParsedFrame => {
|
|
182
|
+
let frame: unknown;
|
|
183
|
+
try {
|
|
184
|
+
frame = JSON.parse(raw);
|
|
185
|
+
} catch {
|
|
186
|
+
return violation("malformed frame");
|
|
187
|
+
}
|
|
188
|
+
return parseFrameValue(frame, first);
|
|
189
|
+
};
|
|
190
|
+
|
|
182
191
|
// ─── verdict encoding ───────────────────────────────────
|
|
183
192
|
|
|
184
193
|
const encodePayload = (
|
|
@@ -236,10 +245,11 @@ const cmdOf = (adm: Admission, outcome: CommandsOutcome): StatewireCmd => {
|
|
|
236
245
|
|
|
237
246
|
// ─── host ───────────────────────────────────────────────
|
|
238
247
|
|
|
239
|
-
type
|
|
248
|
+
export type SocketHostState = {
|
|
240
249
|
heartbeatMs: number;
|
|
241
250
|
clientRecordLimit: number;
|
|
242
251
|
finOnIdle: boolean;
|
|
252
|
+
authorize: StatewireHostInternal.SocketHostOptions["authorize"];
|
|
243
253
|
clients: Map<string, ClientRec>;
|
|
244
254
|
conns: Set<Conn>;
|
|
245
255
|
pendingOps: WireOp[];
|
|
@@ -259,16 +269,18 @@ type Host = {
|
|
|
259
269
|
socket: StatewireHostInternal.Socket,
|
|
260
270
|
) => StatewireHostInternal.SocketConnection;
|
|
261
271
|
finish: (fin: StatewireHost.Fin) => void;
|
|
272
|
+
stream: (request: Request) => Promise<Response>;
|
|
273
|
+
frames: (request: Request) => Promise<Response>;
|
|
262
274
|
};
|
|
263
275
|
|
|
264
|
-
const scheduleFlush = (h:
|
|
276
|
+
const scheduleFlush = (h: SocketHostState) => {
|
|
265
277
|
if (h.flushScheduled || h.disposed) return;
|
|
266
278
|
h.flushScheduled = true;
|
|
267
279
|
h.post();
|
|
268
280
|
};
|
|
269
281
|
|
|
270
282
|
const recordOp = (
|
|
271
|
-
h:
|
|
283
|
+
h: SocketHostState,
|
|
272
284
|
op: {
|
|
273
285
|
op: string;
|
|
274
286
|
path?: StatewirePath;
|
|
@@ -284,7 +296,7 @@ const recordOp = (
|
|
|
284
296
|
scheduleFlush(h);
|
|
285
297
|
};
|
|
286
298
|
|
|
287
|
-
const attach = (h:
|
|
299
|
+
const attach = (h: SocketHostState, next: Statewire<unknown>) => {
|
|
288
300
|
if (next === h.current) return;
|
|
289
301
|
h.current = next;
|
|
290
302
|
const nextState =
|
|
@@ -305,7 +317,12 @@ const attach = (h: Host, next: Statewire<unknown>) => {
|
|
|
305
317
|
h.commandsHost = nextCommands;
|
|
306
318
|
};
|
|
307
319
|
|
|
308
|
-
const closeConn = (
|
|
320
|
+
export const closeConn = (
|
|
321
|
+
h: SocketHostState,
|
|
322
|
+
conn: Conn,
|
|
323
|
+
code?: number,
|
|
324
|
+
reason?: string,
|
|
325
|
+
) => {
|
|
309
326
|
if (!conn.open) return;
|
|
310
327
|
conn.open = false;
|
|
311
328
|
h.conns.delete(conn);
|
|
@@ -320,7 +337,7 @@ const closeConn = (h: Host, conn: Conn, code?: number, reason?: string) => {
|
|
|
320
337
|
}
|
|
321
338
|
};
|
|
322
339
|
|
|
323
|
-
const sendTo = (h:
|
|
340
|
+
export const sendTo = (h: SocketHostState, conn: Conn, data: string) => {
|
|
324
341
|
if (!conn.open) return;
|
|
325
342
|
conn.lastSentAt = Date.now();
|
|
326
343
|
try {
|
|
@@ -330,7 +347,7 @@ const sendTo = (h: Host, conn: Conn, data: string) => {
|
|
|
330
347
|
}
|
|
331
348
|
};
|
|
332
349
|
|
|
333
|
-
const ensureHeartbeat = (h:
|
|
350
|
+
const ensureHeartbeat = (h: SocketHostState) => {
|
|
334
351
|
if (h.heartbeatTimer !== null || h.heartbeatMs <= 0) return;
|
|
335
352
|
h.heartbeatTimer = setInterval(() => {
|
|
336
353
|
const now = Date.now();
|
|
@@ -341,7 +358,7 @@ const ensureHeartbeat = (h: Host) => {
|
|
|
341
358
|
(h.heartbeatTimer as unknown as { unref?: () => void }).unref?.();
|
|
342
359
|
};
|
|
343
360
|
|
|
344
|
-
const setHeartbeatMs = (h:
|
|
361
|
+
const setHeartbeatMs = (h: SocketHostState, ms: number) => {
|
|
345
362
|
if (ms === h.heartbeatMs) return;
|
|
346
363
|
h.heartbeatMs = ms;
|
|
347
364
|
if (h.heartbeatTimer !== null) {
|
|
@@ -351,14 +368,14 @@ const setHeartbeatMs = (h: Host, ms: number) => {
|
|
|
351
368
|
}
|
|
352
369
|
};
|
|
353
370
|
|
|
354
|
-
const idleLevel = (h:
|
|
371
|
+
const idleLevel = (h: SocketHostState): boolean => {
|
|
355
372
|
for (const rec of h.clients.values()) {
|
|
356
373
|
if (rec.inflight.size > 0) return false;
|
|
357
374
|
}
|
|
358
375
|
return true;
|
|
359
376
|
};
|
|
360
377
|
|
|
361
|
-
const flush = (h:
|
|
378
|
+
export const flush = (h: SocketHostState, fin?: StatewireHost.Fin) => {
|
|
362
379
|
flushTapSync(() => {});
|
|
363
380
|
h.stateHost?.takeChanged();
|
|
364
381
|
for (const rec of h.clients.values()) {
|
|
@@ -421,14 +438,14 @@ const flush = (h: Host, fin?: StatewireHost.Fin) => {
|
|
|
421
438
|
}
|
|
422
439
|
};
|
|
423
440
|
|
|
424
|
-
const queueApplied = (h:
|
|
441
|
+
const queueApplied = (h: SocketHostState, clientId: string, seq: number) => {
|
|
425
442
|
const list = h.pendingApplied.get(clientId);
|
|
426
443
|
if (list) list.push(seq);
|
|
427
444
|
else h.pendingApplied.set(clientId, [seq]);
|
|
428
445
|
scheduleFlush(h);
|
|
429
446
|
};
|
|
430
447
|
|
|
431
|
-
const queueCmd = (h:
|
|
448
|
+
const queueCmd = (h: SocketHostState, clientId: string, cmd: StatewireCmd) => {
|
|
432
449
|
const list = h.pendingCmds.get(clientId);
|
|
433
450
|
if (list) list.push(cmd);
|
|
434
451
|
else h.pendingCmds.set(clientId, [cmd]);
|
|
@@ -456,7 +473,7 @@ const admittedThrough = (rec: ClientRec): number =>
|
|
|
456
473
|
// A re-asked seq never re-runs: the stored verdict re-emits, an applied
|
|
457
474
|
// inflight run re-answers applied; one not yet applied answers nothing (its
|
|
458
475
|
// answers ride the stream when ready), an evicted verdict answers expired.
|
|
459
|
-
const reemit = (h:
|
|
476
|
+
const reemit = (h: SocketHostState, rec: ClientRec, seq: number) => {
|
|
460
477
|
const cached = rec.results.get(seq);
|
|
461
478
|
if (cached) {
|
|
462
479
|
const newest = Math.max(...rec.results.keys());
|
|
@@ -475,7 +492,7 @@ const reemit = (h: Host, rec: ClientRec, seq: number) => {
|
|
|
475
492
|
};
|
|
476
493
|
|
|
477
494
|
const settleCmd = (
|
|
478
|
-
h:
|
|
495
|
+
h: SocketHostState,
|
|
479
496
|
rec: ClientRec,
|
|
480
497
|
adm: Admission,
|
|
481
498
|
cmd: StatewireCmd,
|
|
@@ -490,13 +507,16 @@ const settleCmd = (
|
|
|
490
507
|
};
|
|
491
508
|
|
|
492
509
|
const settleWire = (
|
|
493
|
-
h:
|
|
510
|
+
h: SocketHostState,
|
|
494
511
|
rec: ClientRec,
|
|
495
512
|
adm: Admission,
|
|
496
513
|
outcome: CommandsOutcome,
|
|
497
514
|
) => settleCmd(h, rec, adm, cmdOf(adm, outcome));
|
|
498
515
|
|
|
499
|
-
const callerOf = (
|
|
516
|
+
const callerOf = (
|
|
517
|
+
h: SocketHostState,
|
|
518
|
+
rec: ClientRec,
|
|
519
|
+
): StatewireHost.Caller => ({
|
|
500
520
|
clientId: rec.id,
|
|
501
521
|
get context() {
|
|
502
522
|
return rec.context;
|
|
@@ -511,7 +531,7 @@ const callerOf = (h: Host, rec: ClientRec): StatewireHost.Caller => ({
|
|
|
511
531
|
});
|
|
512
532
|
|
|
513
533
|
const rejectStatement = (
|
|
514
|
-
h:
|
|
534
|
+
h: SocketHostState,
|
|
515
535
|
rec: ClientRec,
|
|
516
536
|
adm: Admission,
|
|
517
537
|
message: string,
|
|
@@ -523,7 +543,11 @@ const rejectStatement = (
|
|
|
523
543
|
message,
|
|
524
544
|
});
|
|
525
545
|
|
|
526
|
-
const admitCmd = (
|
|
546
|
+
const admitCmd = (
|
|
547
|
+
h: SocketHostState,
|
|
548
|
+
rec: ClientRec,
|
|
549
|
+
statement: CmdStatement,
|
|
550
|
+
) => {
|
|
527
551
|
const adm: Admission = {
|
|
528
552
|
seq: statement.seq,
|
|
529
553
|
method: statement.method,
|
|
@@ -582,8 +606,15 @@ const admitCmd = (h: Host, rec: ClientRec, statement: CmdStatement) => {
|
|
|
582
606
|
// One admission law, both lanes: absorbed statements answer from the record,
|
|
583
607
|
// then the new suffix is admitted whole or the frame is refused whole via a
|
|
584
608
|
// {syn} stating both watermarks, nothing consumed. A gap is never a close.
|
|
585
|
-
|
|
586
|
-
|
|
609
|
+
export type FrameAdmission =
|
|
610
|
+
| { type: "ok" }
|
|
611
|
+
| { type: "refused"; syn: { syn: { seq: number; ctxSeq: number } } };
|
|
612
|
+
|
|
613
|
+
export const admitFrame = (
|
|
614
|
+
h: SocketHostState,
|
|
615
|
+
rec: ClientRec,
|
|
616
|
+
frame: ParsedFrame,
|
|
617
|
+
): FrameAdmission => {
|
|
587
618
|
rec.touched = Date.now();
|
|
588
619
|
const through = admittedThrough(rec);
|
|
589
620
|
for (const statement of frame.cmd) {
|
|
@@ -595,12 +626,10 @@ const admitFrame = (h: Host, conn: Conn, frame: ParsedFrame) => {
|
|
|
595
626
|
(ctxNew.length > 0 && ctxNew[0]!.seq !== rec.ctxSeq + 1) ||
|
|
596
627
|
(cmdNew.length > 0 && cmdNew[0]!.seq !== through + 1)
|
|
597
628
|
) {
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
);
|
|
603
|
-
return;
|
|
629
|
+
return {
|
|
630
|
+
type: "refused",
|
|
631
|
+
syn: { syn: { seq: through, ctxSeq: rec.ctxSeq } },
|
|
632
|
+
};
|
|
604
633
|
}
|
|
605
634
|
for (const statement of ctxNew) {
|
|
606
635
|
rec.context = statement.value;
|
|
@@ -612,9 +641,10 @@ const admitFrame = (h: Host, conn: Conn, frame: ParsedFrame) => {
|
|
|
612
641
|
queueCmd(h, rec.id, { seq: cmdNew.at(-1)!.seq, dur: true });
|
|
613
642
|
}
|
|
614
643
|
scheduleFlush(h);
|
|
644
|
+
return { type: "ok" };
|
|
615
645
|
};
|
|
616
646
|
|
|
617
|
-
const message = (h:
|
|
647
|
+
const message = (h: SocketHostState, conn: Conn, data: string) => {
|
|
618
648
|
if (!conn.open || h.disposed || h.finished !== null) return;
|
|
619
649
|
const first = !conn.sawFrame;
|
|
620
650
|
conn.sawFrame = true;
|
|
@@ -628,10 +658,14 @@ const message = (h: Host, conn: Conn, data: string) => {
|
|
|
628
658
|
return;
|
|
629
659
|
}
|
|
630
660
|
if (frame.ctx.length === 0 && frame.cmd.length === 0) return;
|
|
631
|
-
|
|
661
|
+
const rec = h.clients.get(conn.clientId)!;
|
|
662
|
+
const admission = admitFrame(h, rec, frame);
|
|
663
|
+
if (admission.type === "refused") {
|
|
664
|
+
sendTo(h, conn, JSON.stringify(admission.syn));
|
|
665
|
+
}
|
|
632
666
|
};
|
|
633
667
|
|
|
634
|
-
const gcClients = (h:
|
|
668
|
+
const gcClients = (h: SocketHostState) => {
|
|
635
669
|
let excess = h.clients.size - h.clientRecordLimit;
|
|
636
670
|
if (excess <= 0) return;
|
|
637
671
|
const live = new Set([...h.conns].map((conn) => conn.clientId));
|
|
@@ -644,23 +678,24 @@ const gcClients = (h: Host) => {
|
|
|
644
678
|
}
|
|
645
679
|
};
|
|
646
680
|
|
|
647
|
-
|
|
648
|
-
|
|
681
|
+
export type Registration = {
|
|
682
|
+
rec: ClientRec;
|
|
683
|
+
conn: Conn;
|
|
684
|
+
connection: StatewireHostInternal.SocketConnection;
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
export const register = (
|
|
688
|
+
h: SocketHostState,
|
|
649
689
|
clientId: string,
|
|
650
690
|
socket: StatewireHostInternal.Socket,
|
|
651
|
-
):
|
|
652
|
-
const dead: StatewireHostInternal.SocketConnection = {
|
|
653
|
-
clientId,
|
|
654
|
-
message: () => {},
|
|
655
|
-
close: () => {},
|
|
656
|
-
};
|
|
691
|
+
): Registration | null => {
|
|
657
692
|
const refuse = (code: number, reason: string) => {
|
|
658
693
|
try {
|
|
659
694
|
socket.close(code, reason);
|
|
660
695
|
} catch {
|
|
661
696
|
/* peer already gone */
|
|
662
697
|
}
|
|
663
|
-
return
|
|
698
|
+
return null;
|
|
664
699
|
};
|
|
665
700
|
if (h.disposed) return refuse(1001, "disposed");
|
|
666
701
|
if (!CLIENT_ID_RE.test(clientId)) return refuse(1008, "invalid client id");
|
|
@@ -671,7 +706,8 @@ const connect = (
|
|
|
671
706
|
} catch {
|
|
672
707
|
/* peer already gone */
|
|
673
708
|
}
|
|
674
|
-
|
|
709
|
+
refuse(1000, "finished");
|
|
710
|
+
return null;
|
|
675
711
|
}
|
|
676
712
|
const known = h.clients.has(clientId);
|
|
677
713
|
const rec = known
|
|
@@ -681,6 +717,7 @@ const connect = (
|
|
|
681
717
|
id: clientId,
|
|
682
718
|
lastSeq: 0,
|
|
683
719
|
ctxSeq: 0,
|
|
720
|
+
lease: null,
|
|
684
721
|
touched: Date.now(),
|
|
685
722
|
context: null,
|
|
686
723
|
results: new Map(),
|
|
@@ -690,6 +727,7 @@ const connect = (
|
|
|
690
727
|
return created;
|
|
691
728
|
})();
|
|
692
729
|
rec.touched = Date.now();
|
|
730
|
+
rec.lease = null;
|
|
693
731
|
for (const other of [...h.conns]) {
|
|
694
732
|
if (other.clientId !== clientId) continue;
|
|
695
733
|
sendTo(h, other, JSON.stringify({ fin: { reason: "superseded" } }));
|
|
@@ -723,25 +761,41 @@ const connect = (
|
|
|
723
761
|
},
|
|
724
762
|
};
|
|
725
763
|
sendTo(h, conn, JSON.stringify(hello));
|
|
726
|
-
|
|
764
|
+
const connection: StatewireHostInternal.SocketConnection = {
|
|
727
765
|
clientId,
|
|
728
766
|
message: (data) => message(h, conn, data),
|
|
729
767
|
close: () => closeConn(h, conn),
|
|
730
768
|
};
|
|
769
|
+
return { rec, conn, connection };
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
const connect = (
|
|
773
|
+
h: SocketHostState,
|
|
774
|
+
clientId: string,
|
|
775
|
+
socket: StatewireHostInternal.Socket,
|
|
776
|
+
): StatewireHostInternal.SocketConnection => {
|
|
777
|
+
const registration = register(h, clientId, socket);
|
|
778
|
+
return (
|
|
779
|
+
registration?.connection ?? {
|
|
780
|
+
clientId,
|
|
781
|
+
message: () => {},
|
|
782
|
+
close: () => {},
|
|
783
|
+
}
|
|
784
|
+
);
|
|
731
785
|
};
|
|
732
786
|
|
|
733
|
-
const finish = (h:
|
|
787
|
+
const finish = (h: SocketHostState, fin: StatewireHost.Fin) => {
|
|
734
788
|
if (h.finished !== null || h.disposed) return;
|
|
735
789
|
h.finished = fin;
|
|
736
790
|
flush(h, fin);
|
|
737
791
|
};
|
|
738
792
|
|
|
739
|
-
const resume = (h:
|
|
793
|
+
const resume = (h: SocketHostState) => {
|
|
740
794
|
h.disposed = false;
|
|
741
795
|
h.stateHost?.setOnOp((op) => recordOp(h, op));
|
|
742
796
|
};
|
|
743
797
|
|
|
744
|
-
const suspend = (h:
|
|
798
|
+
const suspend = (h: SocketHostState) => {
|
|
745
799
|
h.disposed = true;
|
|
746
800
|
if (h.heartbeatTimer !== null) {
|
|
747
801
|
clearInterval(h.heartbeatTimer);
|
|
@@ -753,12 +807,16 @@ const suspend = (h: Host) => {
|
|
|
753
807
|
const useSocketHost = (
|
|
754
808
|
transport: ResourceElement<Statewire>,
|
|
755
809
|
options?: StatewireHostInternal.SocketHostOptions,
|
|
756
|
-
): Pick<
|
|
757
|
-
|
|
758
|
-
|
|
810
|
+
): Pick<
|
|
811
|
+
StatewireHostInternal.SocketHost,
|
|
812
|
+
"connect" | "finish" | "stream" | "frames"
|
|
813
|
+
> => {
|
|
814
|
+
const [h] = useState((): SocketHostState => {
|
|
815
|
+
const host: SocketHostState = {
|
|
759
816
|
heartbeatMs: 15_000,
|
|
760
817
|
clientRecordLimit: CLIENT_RECORD_LIMIT,
|
|
761
818
|
finOnIdle: false,
|
|
819
|
+
authorize: undefined,
|
|
762
820
|
clients: new Map(),
|
|
763
821
|
conns: new Set(),
|
|
764
822
|
pendingOps: [],
|
|
@@ -778,12 +836,15 @@ const useSocketHost = (
|
|
|
778
836
|
}),
|
|
779
837
|
connect: (clientId, socket) => connect(host, clientId, socket),
|
|
780
838
|
finish: (fin) => finish(host, fin),
|
|
839
|
+
stream: (request) => serveStream(host, request),
|
|
840
|
+
frames: (request) => serveFrames(host, request),
|
|
781
841
|
};
|
|
782
842
|
return host;
|
|
783
843
|
});
|
|
784
844
|
setHeartbeatMs(h, options?.heartbeatIntervalMs ?? 15_000);
|
|
785
845
|
h.clientRecordLimit = options?.clientRecordLimit ?? CLIENT_RECORD_LIMIT;
|
|
786
846
|
h.finOnIdle = options?.finOnIdle ?? false;
|
|
847
|
+
h.authorize = options?.authorize;
|
|
787
848
|
const value = useResource(transport) as Statewire<unknown>;
|
|
788
849
|
attach(h, value);
|
|
789
850
|
useEffect(() => {
|
|
@@ -813,6 +874,8 @@ export const StatewireSocketHost = (
|
|
|
813
874
|
return {
|
|
814
875
|
connect: (clientId, socket) =>
|
|
815
876
|
flushTapSync(() => root.getValue().connect(clientId, socket)),
|
|
877
|
+
stream: (request) => flushTapSync(() => root.getValue().stream(request)),
|
|
878
|
+
frames: (request) => flushTapSync(() => root.getValue().frames(request)),
|
|
816
879
|
finish: (fin) => flushTapSync(() => root.getValue().finish(fin)),
|
|
817
880
|
dispose: () => {
|
|
818
881
|
if (disposed) return;
|
package/src/host-internal.ts
CHANGED
|
@@ -16,11 +16,16 @@ export namespace StatewireHostInternal {
|
|
|
16
16
|
|
|
17
17
|
export type SocketHost = {
|
|
18
18
|
connect: (clientId: string, socket: Socket) => SocketConnection;
|
|
19
|
+
stream: (request: Request) => Promise<Response>;
|
|
20
|
+
frames: (request: Request) => Promise<Response>;
|
|
19
21
|
finish: (fin: StatewireHost.Fin) => void;
|
|
20
22
|
dispose: () => void;
|
|
21
23
|
};
|
|
22
24
|
|
|
23
25
|
export type SocketHostOptions = {
|
|
26
|
+
authorize?: (ctx: {
|
|
27
|
+
headers: Record<string, string>;
|
|
28
|
+
}) => void | Promise<void>;
|
|
24
29
|
heartbeatIntervalMs?: number;
|
|
25
30
|
/** LRU cap on stored client records; live or in-flight records are exempt. */
|
|
26
31
|
clientRecordLimit?: number;
|
package/src/statewire.ts
CHANGED
|
@@ -3,12 +3,31 @@ import {
|
|
|
3
3
|
createTapRoot,
|
|
4
4
|
flushTapSync,
|
|
5
5
|
resource,
|
|
6
|
+
useContextProvider,
|
|
6
7
|
useResource,
|
|
8
|
+
useResources,
|
|
9
|
+
withKey,
|
|
7
10
|
} from "@assistant-ui/tap";
|
|
8
11
|
import type { ResourceElement } from "@assistant-ui/tap";
|
|
9
12
|
import { LIVE_CONNECTION, useStatewireCore } from "./client/core";
|
|
10
13
|
import type { Statewire } from "./types";
|
|
11
|
-
import
|
|
14
|
+
import { SessionStorageContext } from "./client/sessionStore";
|
|
15
|
+
|
|
16
|
+
const useSessionTransport = ({
|
|
17
|
+
transport,
|
|
18
|
+
session,
|
|
19
|
+
}: {
|
|
20
|
+
transport: ResourceElement<Statewire<any, any>>;
|
|
21
|
+
session: StatewireClient.SessionStorage | undefined;
|
|
22
|
+
}): Statewire<unknown> =>
|
|
23
|
+
useContextProvider(SessionStorageContext, session, () =>
|
|
24
|
+
// oxlint-disable-next-line react-hooks/rules-of-hooks
|
|
25
|
+
useResource(transport),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// The transport owns the session; it reads the storage off the context this
|
|
29
|
+
// wrapper's own fiber provides, so plain-React callers get a provider too.
|
|
30
|
+
const SessionTransport = resource(useSessionTransport);
|
|
12
31
|
|
|
13
32
|
export const useStatewire = <
|
|
14
33
|
TState,
|
|
@@ -19,7 +38,14 @@ export const useStatewire = <
|
|
|
19
38
|
const latest = useRef(options);
|
|
20
39
|
latest.current = options;
|
|
21
40
|
|
|
22
|
-
const
|
|
41
|
+
const session = useResources(
|
|
42
|
+
options.storage?.session !== undefined
|
|
43
|
+
? [withKey("session", options.storage.session)]
|
|
44
|
+
: [],
|
|
45
|
+
)[0];
|
|
46
|
+
const value = useResource(
|
|
47
|
+
SessionTransport({ transport: options.transport, session }),
|
|
48
|
+
);
|
|
23
49
|
const core = useStatewireCore(() => latest.current, value);
|
|
24
50
|
const [subscribe] = useState(
|
|
25
51
|
() => (listener: () => void) =>
|
|
@@ -111,6 +137,10 @@ export namespace StatewireClient {
|
|
|
111
137
|
TCommands extends Statewire.Commands = Statewire.Commands,
|
|
112
138
|
> = {
|
|
113
139
|
transport: ResourceElement<Statewire<TState, TCommands>>;
|
|
140
|
+
/** Durable session: the mounted storage holds this client's lane identity and its un-durable commands, which resend on the next attach. */
|
|
141
|
+
storage?:
|
|
142
|
+
| { session?: ResourceElement<SessionStorage> | undefined }
|
|
143
|
+
| undefined;
|
|
114
144
|
/** Served as the canonical state until the attach snapshot; own commands fold over it. */
|
|
115
145
|
initialState?: TState;
|
|
116
146
|
optimistic?: Optimistic<NonNullable<TState>, TCommands>;
|
|
@@ -187,10 +217,12 @@ export namespace StatewireClient {
|
|
|
187
217
|
/**
|
|
188
218
|
* fate: "aborted" = never sent, "rejected" = the server refused it
|
|
189
219
|
* (`code` "unknown-command" | "invalid-params" for the static rejects,
|
|
190
|
-
* code-less for handler rejects), "lost" = sent but
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
220
|
+
* code-less for handler rejects), "lost" = sent but no verdict will ever
|
|
221
|
+
* arrive — the record is gone (`record: "fresh"`) or the channel died: it
|
|
222
|
+
* may or may not have run, "failed" = the handler ran and threw,
|
|
223
|
+
* "crashed" = the handler died involuntarily, "expired" = settled —
|
|
224
|
+
* result, rejected, failed, or crashed — but the verdict outlived the
|
|
225
|
+
* server's retention.
|
|
194
226
|
* cause is which channel settled it: "server" = the server's answer;
|
|
195
227
|
* the rest are channel deaths the client settled locally.
|
|
196
228
|
*/
|
|
@@ -215,12 +247,51 @@ export namespace StatewireClient {
|
|
|
215
247
|
cause: "unmount" | "stream-gone" | "session-lost" | "superseded";
|
|
216
248
|
};
|
|
217
249
|
|
|
250
|
+
/** A command as persisted while un-durable: its lane seq plus the wire statement. */
|
|
251
|
+
export type SessionCommand = {
|
|
252
|
+
seq: number;
|
|
253
|
+
method: string;
|
|
254
|
+
params: unknown[];
|
|
255
|
+
meta?: unknown;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/** The persisted session: the lane identity, the lane clocks, and the commands the server has not marked durable. */
|
|
259
|
+
export type SessionRecord = {
|
|
260
|
+
clientId: string;
|
|
261
|
+
nextSeq: number;
|
|
262
|
+
ctxNextSeq: number;
|
|
263
|
+
commands: readonly SessionCommand[];
|
|
264
|
+
/** Terminal verdicts parked for the sharer that minted the seq (shared storage only). */
|
|
265
|
+
verdicts?: readonly SessionVerdict[];
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/** A terminal verdict one sharer's connection carried, parked for the sharer awaiting the seq. */
|
|
269
|
+
export type SessionVerdict = {
|
|
270
|
+
seq: number;
|
|
271
|
+
type: "result" | "rejected" | "failed" | "crashed" | "expired";
|
|
272
|
+
code?: string;
|
|
273
|
+
message?: string;
|
|
274
|
+
payload?: unknown;
|
|
275
|
+
meta?: unknown;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/** Session storage seam; `save` must persist synchronously in the caller's flow. */
|
|
279
|
+
export type SessionStorage = {
|
|
280
|
+
load: () => SessionRecord | null;
|
|
281
|
+
save: (record: SessionRecord) => void;
|
|
282
|
+
/** Serializes record mutations across sharers; presence marks the storage shared. */
|
|
283
|
+
lock?: <T>(fn: () => T | Promise<T>) => Promise<T>;
|
|
284
|
+
/** Notifies of record saves by other sharers; returns unsubscribe. Required alongside `lock`. */
|
|
285
|
+
subscribe?: (listener: () => void) => () => void;
|
|
286
|
+
};
|
|
287
|
+
|
|
218
288
|
/** Static headers, or a resolver invoked per request/attach; `staleAuth` is true right after an auth rejection. */
|
|
219
289
|
export type HeadersOption =
|
|
220
290
|
| HeadersInit
|
|
221
291
|
| ((ctx: { staleAuth: boolean }) => HeadersInit | Promise<HeadersInit>);
|
|
222
292
|
|
|
223
|
-
export type SSEOptions =
|
|
293
|
+
export type SSEOptions = {
|
|
294
|
+
sessionStore?: SessionStorage | undefined;
|
|
224
295
|
url: string;
|
|
225
296
|
headers?: HeadersOption;
|
|
226
297
|
fetch?: typeof globalThis.fetch;
|
|
@@ -229,7 +300,8 @@ export namespace StatewireClient {
|
|
|
229
300
|
onDone?: () => void;
|
|
230
301
|
};
|
|
231
302
|
|
|
232
|
-
export type WSOptions =
|
|
303
|
+
export type WSOptions = {
|
|
304
|
+
sessionStore?: SessionStorage | undefined;
|
|
233
305
|
url: string;
|
|
234
306
|
headers?: HeadersOption;
|
|
235
307
|
protocols?: string | string[];
|
package/src/types.ts
CHANGED
|
@@ -53,7 +53,7 @@ export interface Statewire<
|
|
|
53
53
|
commands: Statewire.CommandsProxy<TCommands>;
|
|
54
54
|
connection?: StatewireClient.Connection;
|
|
55
55
|
source?: Statewire.StateSource;
|
|
56
|
-
/** Highest command seq released from the resend queue
|
|
56
|
+
/** Highest command seq the server's dur flag states durably admitted — never lost, never run again — and released from the resend queue. */
|
|
57
57
|
durableSeq?: number;
|
|
58
58
|
/** Negation of the server's broadcast idle level; absent means always busy. */
|
|
59
59
|
isBusy?: boolean;
|