statewire 0.11.0 → 0.12.2
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 +2 -2
- package/dist/client/connection.d.ts +6 -4
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +10 -2
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts +1 -1
- package/dist/client/session.d.ts +4 -2
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +102 -102
- package/dist/client/session.js.map +1 -1
- package/dist/host/draft.d.ts.map +1 -1
- package/dist/host/draft.js +9 -1
- package/dist/host/draft.js.map +1 -1
- package/dist/host/host.d.ts +1 -1
- package/dist/host/http.js +4 -3
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +6 -5
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +12 -20
- package/dist/host/socket.js.map +1 -1
- package/dist/host-internal.d.ts +2 -1
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/host.d.ts +2 -2
- package/dist/host.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/statewire.d.ts +15 -4
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js.map +1 -1
- package/dist/transports/fake-sse.js +4 -4
- package/dist/transports/fake-sse.js.map +1 -1
- package/dist/transports/fixtures.d.ts +1 -1
- package/dist/transports/remote.d.ts +1 -1
- package/dist/transports/remote.js +1 -0
- package/dist/transports/remote.js.map +1 -1
- package/dist/transports/sse.d.ts +2 -2
- package/dist/transports/testing.d.ts +1 -1
- package/dist/transports/testing.js +2 -4
- package/dist/transports/testing.js.map +1 -1
- package/dist/wire/meta.d.ts +5 -3
- package/dist/wire/meta.d.ts.map +1 -1
- package/dist/wire/meta.js +7 -4
- package/dist/wire/meta.js.map +1 -1
- package/dist/wire/ops.d.ts.map +1 -1
- package/dist/wire/ops.js +26 -2
- package/dist/wire/ops.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +12 -6
- package/src/client/session.ts +145 -139
- package/src/host/draft.ts +15 -1
- package/src/host/http.ts +10 -9
- package/src/host/socket.ts +18 -28
- package/src/host-internal.ts +2 -1
- package/src/host.ts +0 -1
- package/src/statewire.ts +18 -4
- package/src/transports/fake-sse.ts +6 -6
- package/src/transports/remote.ts +1 -0
- package/src/transports/testing.ts +2 -7
- package/src/wire/meta.ts +12 -4
- package/src/wire/ops.ts +30 -3
package/src/client/session.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useContext, useEffect, useState } from "react";
|
|
2
2
|
import { applyOps } from "../wire/ops";
|
|
3
3
|
import type { StatewirePath } from "../wire/ops";
|
|
4
|
+
import { TERMINAL } from "./connection";
|
|
4
5
|
import type { StatewireCmd, StatewireSyn } from "./connection";
|
|
5
6
|
import {
|
|
6
7
|
encodeParams,
|
|
@@ -70,6 +71,7 @@ type AttachState = "detached" | "attached" | "finished";
|
|
|
70
71
|
|
|
71
72
|
export type StatewireSessionOptions = {
|
|
72
73
|
sessionStore?: StatewireClient.SessionStorage | undefined;
|
|
74
|
+
onSessionLost?: StatewireClient.OnSessionLost | undefined;
|
|
73
75
|
onDone?: (() => void) | undefined;
|
|
74
76
|
};
|
|
75
77
|
|
|
@@ -92,6 +94,7 @@ type Session = {
|
|
|
92
94
|
durableSeq: number;
|
|
93
95
|
resumeAt: number;
|
|
94
96
|
idle: boolean;
|
|
97
|
+
onSessionLost: StatewireClient.OnSessionLost | undefined;
|
|
95
98
|
onDone: (() => void) | undefined;
|
|
96
99
|
ctxOps: unknown[] | undefined;
|
|
97
100
|
ctxPending: CtxPending[];
|
|
@@ -252,14 +255,9 @@ const settleEntry = (
|
|
|
252
255
|
}
|
|
253
256
|
};
|
|
254
257
|
|
|
255
|
-
const settleAll = (s: Session, detach: "unmount" | "gone"
|
|
256
|
-
const cause =
|
|
257
|
-
|
|
258
|
-
unmount: "unmount",
|
|
259
|
-
gone: "stream-gone",
|
|
260
|
-
superseded: "superseded",
|
|
261
|
-
} as const
|
|
262
|
-
)[detach];
|
|
258
|
+
const settleAll = (s: Session, detach: "unmount" | "gone") => {
|
|
259
|
+
const cause =
|
|
260
|
+
detach === "unmount" ? ("unmount" as const) : ("stream-gone" as const);
|
|
263
261
|
const batch = s.pending;
|
|
264
262
|
const detachFailure = (entry: Pending): StatewireClient.SendFailure => {
|
|
265
263
|
if (!entry.attempted) {
|
|
@@ -286,26 +284,30 @@ const restateContext = (s: Session) => {
|
|
|
286
284
|
s.ctxPending.push({ seq, ops: s.ctxOps, posted: false });
|
|
287
285
|
};
|
|
288
286
|
|
|
287
|
+
const settleLost = (s: Session, entries: Pending[]) => {
|
|
288
|
+
for (const entry of entries) {
|
|
289
|
+
s.lifecycle?.onCovered?.(entry.inv);
|
|
290
|
+
settleEntry(s, entry, {
|
|
291
|
+
ok: false,
|
|
292
|
+
failure: { fate: "lost", cause: "session-lost" },
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
289
297
|
// The server-side record's dedup memory is gone: history that may have
|
|
290
298
|
// reached a server settles lost — the new hello's snapshot is the only
|
|
291
299
|
// truth about what survived — and never-attempted statements re-stamp
|
|
292
|
-
//
|
|
293
|
-
const
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
failure: { fate: "lost", cause: "session-lost" },
|
|
301
|
-
});
|
|
302
|
-
} else {
|
|
303
|
-
survivors.push(entry);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
300
|
+
// on the adopted lane clocks.
|
|
301
|
+
const rebaseRecord = (s: Session, adopt: () => void) => {
|
|
302
|
+
const held = [...s.pending];
|
|
303
|
+
const survivors = held.filter((p) => !p.attempted && !p.applied);
|
|
304
|
+
settleLost(
|
|
305
|
+
s,
|
|
306
|
+
held.filter((p) => p.attempted || p.applied),
|
|
307
|
+
);
|
|
306
308
|
s.pending = [];
|
|
307
309
|
s.durableSeq = 0;
|
|
308
|
-
|
|
310
|
+
adopt();
|
|
309
311
|
for (const old of survivors) {
|
|
310
312
|
s.lifecycle?.onCovered?.(old.inv);
|
|
311
313
|
enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
|
|
@@ -316,33 +318,17 @@ const resetRecord = (s: Session, clientId: string) => {
|
|
|
316
318
|
void pump(s);
|
|
317
319
|
};
|
|
318
320
|
|
|
321
|
+
const resetRecord = (s: Session, clientId: string) =>
|
|
322
|
+
rebaseRecord(s, () =>
|
|
323
|
+
setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1, commands: [] }),
|
|
324
|
+
);
|
|
325
|
+
|
|
319
326
|
// Another sharer replaced the record's identity: their reset settles our
|
|
320
327
|
// attempted history and our survivors re-stamp on the stored clocks.
|
|
321
|
-
const adoptReset = (s: Session, stored: StatewireClient.SessionRecord) =>
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
s.lifecycle?.onCovered?.(entry.inv);
|
|
326
|
-
settleEntry(s, entry, {
|
|
327
|
-
ok: false,
|
|
328
|
-
failure: { fate: "lost", cause: "session-lost" },
|
|
329
|
-
});
|
|
330
|
-
} else {
|
|
331
|
-
survivors.push(entry);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
s.pending = [];
|
|
335
|
-
s.durableSeq = 0;
|
|
336
|
-
s.record = stored;
|
|
337
|
-
for (const old of survivors) {
|
|
338
|
-
s.lifecycle?.onCovered?.(old.inv);
|
|
339
|
-
enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
|
|
340
|
-
}
|
|
341
|
-
restateContext(s);
|
|
342
|
-
publish(s);
|
|
343
|
-
wake(s);
|
|
344
|
-
void pump(s);
|
|
345
|
-
};
|
|
328
|
+
const adoptReset = (s: Session, stored: StatewireClient.SessionRecord) =>
|
|
329
|
+
rebaseRecord(s, () => {
|
|
330
|
+
s.record = stored;
|
|
331
|
+
});
|
|
346
332
|
|
|
347
333
|
// The record died with the attach: mint a fresh id for the next one.
|
|
348
334
|
const ephemeralDetach = (s: Session) => {
|
|
@@ -365,42 +351,31 @@ const outcomeOf = (
|
|
|
365
351
|
cmd: StatewireCmd,
|
|
366
352
|
):
|
|
367
353
|
| { ok: true; payload: unknown }
|
|
368
|
-
| { ok: false; failure: StatewireClient.SendFailure } =>
|
|
369
|
-
cmd.type
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
: cmd
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
},
|
|
394
|
-
}
|
|
395
|
-
: protocolError(`unknown cmd type: ${String(cmd.type)}`);
|
|
396
|
-
|
|
397
|
-
const TERMINAL_CMD_TYPES = new Set([
|
|
398
|
-
"result",
|
|
399
|
-
"rejected",
|
|
400
|
-
"failed",
|
|
401
|
-
"crashed",
|
|
402
|
-
"expired",
|
|
403
|
-
]);
|
|
354
|
+
| { ok: false; failure: StatewireClient.SendFailure } => {
|
|
355
|
+
switch (cmd.type) {
|
|
356
|
+
case "result":
|
|
357
|
+
return { ok: true, payload: reviveResponse(cmd).payload };
|
|
358
|
+
case "rejected":
|
|
359
|
+
return { ok: false, failure: rejectedFailure(reviveResponse(cmd)) };
|
|
360
|
+
case "failed":
|
|
361
|
+
return {
|
|
362
|
+
ok: false,
|
|
363
|
+
failure: {
|
|
364
|
+
fate: "failed",
|
|
365
|
+
cause: "server",
|
|
366
|
+
...(cmd.message !== undefined && { message: cmd.message }),
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
case "crashed":
|
|
370
|
+
return { ok: false, failure: { fate: "crashed", cause: "server" } };
|
|
371
|
+
case "expired":
|
|
372
|
+
return { ok: false, failure: { fate: "expired", cause: "server" } };
|
|
373
|
+
default:
|
|
374
|
+
return protocolError(`unknown cmd type: ${String(cmd.type)}`);
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const TERMINAL_CMD_TYPES: ReadonlySet<string> = new Set(TERMINAL);
|
|
404
379
|
|
|
405
380
|
const parkVerdict = (s: Session, cmd: StatewireCmd) => {
|
|
406
381
|
if (!s.shared || !TERMINAL_CMD_TYPES.has(cmd.type!)) return;
|
|
@@ -473,24 +448,10 @@ const publishIngested = (s: Session) => {
|
|
|
473
448
|
wake(s);
|
|
474
449
|
};
|
|
475
450
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
//
|
|
479
|
-
|
|
480
|
-
// never-admitted statements consecutively after the watermark.
|
|
481
|
-
const snapClocks = (s: Session, seqWm: number, ctxWm: number) => {
|
|
482
|
-
const tail = s.pending.filter((p) => p.seq > seqWm);
|
|
483
|
-
const cmdAligned =
|
|
484
|
-
tail.every((p, i) => p.seq === seqWm + 1 + i) &&
|
|
485
|
-
s.record.nextSeq === seqWm + tail.length + 1;
|
|
486
|
-
if (!cmdAligned) {
|
|
487
|
-
s.pending = s.pending.filter((p) => p.seq <= seqWm);
|
|
488
|
-
setRecord(s, { ...s.record, nextSeq: seqWm + 1 });
|
|
489
|
-
for (const old of tail) {
|
|
490
|
-
s.lifecycle?.onCovered?.(old.inv);
|
|
491
|
-
enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
|
|
492
|
-
}
|
|
493
|
-
}
|
|
451
|
+
// A hello's cmd watermark may understate a durable record (cold host, tray
|
|
452
|
+
// unloaded): held seqs resend as-is and the frame's answer arbitrates —
|
|
453
|
+
// absorption where the record survived, a gap refusal where it did not.
|
|
454
|
+
const snapCtxClock = (s: Session, ctxWm: number) => {
|
|
494
455
|
const ctxAligned =
|
|
495
456
|
s.ctxPending.every((c, i) => c.seq === ctxWm + 1 + i) &&
|
|
496
457
|
s.record.ctxNextSeq === ctxWm + s.ctxPending.length + 1;
|
|
@@ -500,39 +461,69 @@ const snapClocks = (s: Session, seqWm: number, ctxWm: number) => {
|
|
|
500
461
|
}
|
|
501
462
|
};
|
|
502
463
|
|
|
464
|
+
// The refusal's watermarks are exact: every held statement above them was
|
|
465
|
+
// never admitted by the live record, and the first unposted seq being beyond
|
|
466
|
+
// watermark+1 proves the record that admitted the missing prefix is gone —
|
|
467
|
+
// its history settles lost and the lane clocks restart at the watermarks.
|
|
468
|
+
const sessionLostInner = (s: Session, seqWm: number, ctxWm: number) => {
|
|
469
|
+
const held = [...s.pending];
|
|
470
|
+
settleLost(s, held);
|
|
471
|
+
s.pending = [];
|
|
472
|
+
s.durableSeq = seqWm;
|
|
473
|
+
setRecord(s, {
|
|
474
|
+
clientId: s.record.clientId,
|
|
475
|
+
nextSeq: seqWm + 1,
|
|
476
|
+
ctxNextSeq: ctxWm + 1,
|
|
477
|
+
commands: [],
|
|
478
|
+
});
|
|
479
|
+
restateContext(s);
|
|
480
|
+
if (
|
|
481
|
+
held.length > 0 &&
|
|
482
|
+
s.onSessionLost?.(held.map((p) => p.inv)) === "requeue"
|
|
483
|
+
) {
|
|
484
|
+
for (const old of held) {
|
|
485
|
+
enqueueInner(
|
|
486
|
+
s,
|
|
487
|
+
old.command,
|
|
488
|
+
old.inv.params,
|
|
489
|
+
() => {},
|
|
490
|
+
() => {},
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
publish(s);
|
|
495
|
+
wake(s);
|
|
496
|
+
};
|
|
497
|
+
|
|
503
498
|
const handleHello = (
|
|
504
499
|
s: Session,
|
|
505
500
|
{ ops, syn }: { ops: unknown[]; syn: StatewireSyn },
|
|
506
501
|
) => {
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
protocolError(`unknown record: ${String(record)}`);
|
|
502
|
+
if (syn.record !== undefined && syn.record !== "ephemeral") {
|
|
503
|
+
protocolError(`unknown record: ${String(syn.record)}`);
|
|
510
504
|
}
|
|
511
505
|
ingestOps(s, ops);
|
|
512
506
|
s.attach = "attached";
|
|
513
507
|
s.attachEpoch += 1;
|
|
514
508
|
s.frameFailures = 0;
|
|
515
509
|
s.resumeAt = 0;
|
|
516
|
-
s.ephemeral = record === "ephemeral";
|
|
510
|
+
s.ephemeral = syn.record === "ephemeral";
|
|
517
511
|
if (s.ephemeral) s.deactivated = true;
|
|
518
512
|
const [attachCmd, attachCtx] = s.attachLaneSeqs;
|
|
519
513
|
s.attachLaneSeqs = [new Set(), new Set()];
|
|
520
514
|
const reconcile = () => {
|
|
521
|
-
if (
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
}
|
|
528
|
-
const ctxWatermark = syn.ctxSeq ?? 0;
|
|
529
|
-
s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
|
|
530
|
-
for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
|
|
531
|
-
snapClocks(s, syn.seq ?? 0, ctxWatermark);
|
|
532
|
-
syncBacklogInner(s);
|
|
533
|
-
} else {
|
|
534
|
-
resetRecord(s, s.record.clientId);
|
|
515
|
+
if (s.shared) adoptStored(s);
|
|
516
|
+
// Resending unsettled statements is exactly-once-safe: absorbed replays
|
|
517
|
+
// re-emit their stored verdicts (applied while still in flight).
|
|
518
|
+
s.durableSeq = syn.seq ?? 0;
|
|
519
|
+
for (const entry of s.pending) {
|
|
520
|
+
entry.posted = attachCmd.has(entry.seq);
|
|
535
521
|
}
|
|
522
|
+
const ctxWatermark = syn.ctxSeq ?? 0;
|
|
523
|
+
s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
|
|
524
|
+
for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
|
|
525
|
+
snapCtxClock(s, ctxWatermark);
|
|
526
|
+
syncBacklogInner(s);
|
|
536
527
|
s.sessionKnown = true;
|
|
537
528
|
};
|
|
538
529
|
if (!s.shared || s.deactivated) {
|
|
@@ -557,8 +548,9 @@ const handleOps = (
|
|
|
557
548
|
publishIngested(s);
|
|
558
549
|
};
|
|
559
550
|
|
|
560
|
-
// A {syn} refusal
|
|
561
|
-
//
|
|
551
|
+
// A retry-less {syn} refusal is the one session-loss verdict: a cmd gap
|
|
552
|
+
// against its exact watermark settles the held history lost; a pushback
|
|
553
|
+
// (retry present) or a gapless refusal only reconciles the resend queues.
|
|
562
554
|
const handleRefusal = (s: Session, syn: StatewireSyn) => {
|
|
563
555
|
const { seq, ctxSeq } = syn;
|
|
564
556
|
if (typeof seq === "number") {
|
|
@@ -571,19 +563,30 @@ const handleRefusal = (s: Session, syn: StatewireSyn) => {
|
|
|
571
563
|
}
|
|
572
564
|
if (typeof syn.retry === "number") {
|
|
573
565
|
s.resumeAt = Date.now() + syn.retry;
|
|
566
|
+
wake(s);
|
|
567
|
+
void pump(s);
|
|
568
|
+
return;
|
|
574
569
|
}
|
|
575
570
|
const firstCmd = s.pending.find((p) => !p.posted);
|
|
576
571
|
const firstCtx = s.ctxPending.find((c) => !c.posted);
|
|
577
|
-
const
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
572
|
+
const cmdGap =
|
|
573
|
+
typeof seq === "number" &&
|
|
574
|
+
firstCmd !== undefined &&
|
|
575
|
+
firstCmd.seq > Math.max(seq, s.durableSeq) + 1;
|
|
576
|
+
const ctxGap =
|
|
577
|
+
typeof ctxSeq === "number" &&
|
|
578
|
+
firstCtx !== undefined &&
|
|
579
|
+
firstCtx.seq > ctxSeq + 1;
|
|
580
|
+
if (cmdGap || ctxGap) {
|
|
581
|
+
void transact(s, () => {
|
|
582
|
+
if (cmdGap) sessionLostInner(s, seq!, ctxSeq ?? 0);
|
|
583
|
+
else snapCtxClock(s, ctxSeq!);
|
|
584
|
+
}).then(() => {
|
|
585
|
+
wake(s);
|
|
586
|
+
void pump(s);
|
|
587
|
+
});
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
587
590
|
wake(s);
|
|
588
591
|
void pump(s);
|
|
589
592
|
};
|
|
@@ -597,11 +600,7 @@ const handleDetach = (s: Session) => {
|
|
|
597
600
|
const handleFinish = (s: Session, { reason }: { reason: string }) => {
|
|
598
601
|
if (reason === "evicted" || reason === "error") return;
|
|
599
602
|
s.attach = "finished";
|
|
600
|
-
|
|
601
|
-
// parks the verdicts; our promises settle from the observed record.
|
|
602
|
-
if (!(s.shared && reason === "superseded")) {
|
|
603
|
-
settleAll(s, reason === "superseded" ? "superseded" : "gone");
|
|
604
|
-
}
|
|
603
|
+
settleAll(s, "gone");
|
|
605
604
|
publish(s);
|
|
606
605
|
wake(s);
|
|
607
606
|
};
|
|
@@ -819,8 +818,8 @@ const enqueue = (
|
|
|
819
818
|
).then(() => void pump(s));
|
|
820
819
|
};
|
|
821
820
|
|
|
822
|
-
// A stored command
|
|
823
|
-
//
|
|
821
|
+
// A stored command may have reached a server: attempted settles it lost,
|
|
822
|
+
// never aborted, when a channel death ends the session.
|
|
824
823
|
const adoptStored = (s: Session) => {
|
|
825
824
|
const known = new Set(s.pending.map((p) => p.seq));
|
|
826
825
|
const stored = s.record.commands.filter((c) => !known.has(c.seq));
|
|
@@ -965,6 +964,7 @@ export const useStatewireSession = (
|
|
|
965
964
|
durableSeq: 0,
|
|
966
965
|
resumeAt: 0,
|
|
967
966
|
idle: false,
|
|
967
|
+
onSessionLost: undefined,
|
|
968
968
|
onDone: undefined,
|
|
969
969
|
ctxOps: undefined,
|
|
970
970
|
ctxPending: [],
|
|
@@ -1005,7 +1005,13 @@ export const useStatewireSession = (
|
|
|
1005
1005
|
});
|
|
1006
1006
|
return session;
|
|
1007
1007
|
});
|
|
1008
|
+
if ((store.lock !== undefined) !== (s.shared !== false)) {
|
|
1009
|
+
throw new Error(
|
|
1010
|
+
"statewire: session storage changed sharedness across renders — lock/subscribe must be present from the first render or never",
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1008
1013
|
s.store = store;
|
|
1014
|
+
s.onSessionLost = options.onSessionLost;
|
|
1009
1015
|
s.onDone = options.onDone;
|
|
1010
1016
|
useEffect(() => {
|
|
1011
1017
|
start(s);
|
package/src/host/draft.ts
CHANGED
|
@@ -30,6 +30,16 @@ const invalid = (path: StatewirePath, message: string) =>
|
|
|
30
30
|
|
|
31
31
|
type Container = Record<string | number, unknown>;
|
|
32
32
|
|
|
33
|
+
// Text-insert offsets are Unicode code points on the wire, not UTF-16 units.
|
|
34
|
+
const codePointLength = (text: string): number => {
|
|
35
|
+
let count = 0;
|
|
36
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
37
|
+
if (text.codePointAt(index)! > 0xffff) index += 1;
|
|
38
|
+
count += 1;
|
|
39
|
+
}
|
|
40
|
+
return count;
|
|
41
|
+
};
|
|
42
|
+
|
|
33
43
|
type Node = {
|
|
34
44
|
parent: Node | null;
|
|
35
45
|
key: string | number;
|
|
@@ -96,7 +106,11 @@ export const createStateDraft = <T>(host: DraftHost): StateDraft<T> => {
|
|
|
96
106
|
if (suffix === "") return;
|
|
97
107
|
prepareForWrite(node)[key] = existing + suffix;
|
|
98
108
|
host.record(
|
|
99
|
-
{
|
|
109
|
+
{
|
|
110
|
+
op: "add",
|
|
111
|
+
path: [...node.path, key, codePointLength(existing)],
|
|
112
|
+
value: suffix,
|
|
113
|
+
},
|
|
100
114
|
changedPathFor(node, key),
|
|
101
115
|
);
|
|
102
116
|
};
|
package/src/host/http.ts
CHANGED
|
@@ -166,7 +166,7 @@ const getStream = async (
|
|
|
166
166
|
const attachment = attachSse(h, client.clientId);
|
|
167
167
|
const lease = attachment.registration === null ? null : mintLease();
|
|
168
168
|
if (attachment.registration !== null)
|
|
169
|
-
attachment.registration.
|
|
169
|
+
attachment.registration.conn.lease = lease;
|
|
170
170
|
return streamResponse(attachment, lease);
|
|
171
171
|
};
|
|
172
172
|
|
|
@@ -202,7 +202,7 @@ const postStream = async (
|
|
|
202
202
|
const attachment = attachSse(h, client.clientId);
|
|
203
203
|
if (attachment.registration === null) return streamResponse(attachment, null);
|
|
204
204
|
const lease = mintLease();
|
|
205
|
-
attachment.registration.
|
|
205
|
+
attachment.registration.conn.lease = lease;
|
|
206
206
|
admitAttachFrame(h, attachment.registration, frame);
|
|
207
207
|
return streamResponse(attachment, lease);
|
|
208
208
|
};
|
|
@@ -254,13 +254,14 @@ export const frames = async (
|
|
|
254
254
|
if ("response" in client) return client.response;
|
|
255
255
|
const rec = h.clients.get(client.clientId);
|
|
256
256
|
const lease = request.headers.get(LEASE_HEADER);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
257
|
+
// A lease lives and dies with its attach: any live attach of the id accepts.
|
|
258
|
+
const live =
|
|
259
|
+
lease !== null &&
|
|
260
|
+
[...h.conns].some(
|
|
261
|
+
(conn) =>
|
|
262
|
+
conn.open && conn.clientId === client.clientId && conn.lease === lease,
|
|
263
|
+
);
|
|
264
|
+
if (h.disposed || h.finished !== null || rec === undefined || !live) {
|
|
264
265
|
return Response.json({ error: "stale-lease" }, { status: 423 });
|
|
265
266
|
}
|
|
266
267
|
let frame: ParsedFrame;
|
package/src/host/socket.ts
CHANGED
|
@@ -38,7 +38,6 @@ export type ClientRec = {
|
|
|
38
38
|
id: string;
|
|
39
39
|
lastSeq: number;
|
|
40
40
|
ctxSeq: number;
|
|
41
|
-
lease: string | null;
|
|
42
41
|
touched: number;
|
|
43
42
|
context: Record<string, unknown> | null;
|
|
44
43
|
results: Map<number, { ts: number; cmd: StatewireCmd }>;
|
|
@@ -51,6 +50,7 @@ export type Conn = {
|
|
|
51
50
|
open: boolean;
|
|
52
51
|
sawFrame: boolean;
|
|
53
52
|
lastSentAt: number;
|
|
53
|
+
lease: string | null;
|
|
54
54
|
};
|
|
55
55
|
|
|
56
56
|
// ─── frame parsing ──────────────────────────────────────
|
|
@@ -115,7 +115,7 @@ const contextValue = (ops: unknown): Record<string, unknown> => {
|
|
|
115
115
|
}
|
|
116
116
|
const value =
|
|
117
117
|
op["meta"] !== undefined
|
|
118
|
-
? reviveValue(op["value"], op["meta"])
|
|
118
|
+
? reviveValue(op["value"], op["meta"], { rejectUnknown: true })
|
|
119
119
|
: op["value"];
|
|
120
120
|
if (!isPlainObject(value)) {
|
|
121
121
|
throw new Error("context value must be an object");
|
|
@@ -128,13 +128,13 @@ export const parseFrameValue = (
|
|
|
128
128
|
first: boolean,
|
|
129
129
|
): ParsedFrame => {
|
|
130
130
|
if (!isPlainObject(frame)) return violation("malformed frame");
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
131
|
+
const keys = Object.keys(frame);
|
|
132
|
+
if (keys.length === 0) return violation("malformed frame");
|
|
133
|
+
const unknown = keys
|
|
134
|
+
.filter((key) => key !== "headers" && key !== "ctx" && key !== "cmd")
|
|
135
|
+
.sort();
|
|
136
|
+
if (unknown.length > 0)
|
|
137
|
+
return violation(`unknown frame key: '${unknown[0]}'`);
|
|
138
138
|
let headers: Record<string, string> | null = null;
|
|
139
139
|
if (frame["headers"] !== undefined) {
|
|
140
140
|
if (!first) return violation("mid-session headers");
|
|
@@ -267,7 +267,7 @@ export type SocketHostState = {
|
|
|
267
267
|
connect: (
|
|
268
268
|
clientId: string,
|
|
269
269
|
socket: StatewireHostInternal.Socket,
|
|
270
|
-
) => StatewireHostInternal.SocketConnection;
|
|
270
|
+
) => StatewireHostInternal.SocketConnection | null;
|
|
271
271
|
finish: (fin: StatewireHost.Fin) => void;
|
|
272
272
|
stream: (request: Request) => Promise<Response>;
|
|
273
273
|
frames: (request: Request) => Promise<Response>;
|
|
@@ -563,7 +563,9 @@ const admitCmd = (
|
|
|
563
563
|
let params = statement.params;
|
|
564
564
|
if (statement.meta !== undefined) {
|
|
565
565
|
try {
|
|
566
|
-
params = reviveValue(params, statement.meta
|
|
566
|
+
params = reviveValue(params, statement.meta, {
|
|
567
|
+
rejectUnknown: true,
|
|
568
|
+
}) as unknown[];
|
|
567
569
|
} catch (error) {
|
|
568
570
|
rejectStatement(
|
|
569
571
|
h,
|
|
@@ -717,7 +719,6 @@ export const register = (
|
|
|
717
719
|
id: clientId,
|
|
718
720
|
lastSeq: 0,
|
|
719
721
|
ctxSeq: 0,
|
|
720
|
-
lease: null,
|
|
721
722
|
touched: Date.now(),
|
|
722
723
|
context: null,
|
|
723
724
|
results: new Map(),
|
|
@@ -727,18 +728,13 @@ export const register = (
|
|
|
727
728
|
return created;
|
|
728
729
|
})();
|
|
729
730
|
rec.touched = Date.now();
|
|
730
|
-
rec.lease = null;
|
|
731
|
-
for (const other of [...h.conns]) {
|
|
732
|
-
if (other.clientId !== clientId) continue;
|
|
733
|
-
sendTo(h, other, JSON.stringify({ fin: { reason: "superseded" } }));
|
|
734
|
-
closeConn(h, other);
|
|
735
|
-
}
|
|
736
731
|
const conn: Conn = {
|
|
737
732
|
clientId,
|
|
738
733
|
socket,
|
|
739
734
|
open: true,
|
|
740
735
|
sawFrame: false,
|
|
741
736
|
lastSentAt: 0,
|
|
737
|
+
lease: null,
|
|
742
738
|
};
|
|
743
739
|
h.conns.add(conn);
|
|
744
740
|
gcClients(h);
|
|
@@ -756,7 +752,6 @@ export const register = (
|
|
|
756
752
|
syn: {
|
|
757
753
|
seq: admittedThrough(rec),
|
|
758
754
|
ctxSeq: rec.ctxSeq,
|
|
759
|
-
record: known ? "resumed" : "fresh",
|
|
760
755
|
...(h.idleWire && { idle: true }),
|
|
761
756
|
},
|
|
762
757
|
};
|
|
@@ -773,15 +768,9 @@ const connect = (
|
|
|
773
768
|
h: SocketHostState,
|
|
774
769
|
clientId: string,
|
|
775
770
|
socket: StatewireHostInternal.Socket,
|
|
776
|
-
): StatewireHostInternal.SocketConnection => {
|
|
771
|
+
): StatewireHostInternal.SocketConnection | null => {
|
|
777
772
|
const registration = register(h, clientId, socket);
|
|
778
|
-
return
|
|
779
|
-
registration?.connection ?? {
|
|
780
|
-
clientId,
|
|
781
|
-
message: () => {},
|
|
782
|
-
close: () => {},
|
|
783
|
-
}
|
|
784
|
-
);
|
|
773
|
+
return registration?.connection ?? null;
|
|
785
774
|
};
|
|
786
775
|
|
|
787
776
|
const finish = (h: SocketHostState, fin: StatewireHost.Fin) => {
|
|
@@ -862,7 +851,8 @@ const useSocketHost = (
|
|
|
862
851
|
* feed it sockets (anything with `send`/`close`) and raw text frames, and it
|
|
863
852
|
* speaks the Statewire packet protocol — snapshot-first hello with `syn`,
|
|
864
853
|
* statement-lane client frames under one admission law, `{cmd}` answer
|
|
865
|
-
* arrays
|
|
854
|
+
* arrays fanned out to every attach of a client id, idle edges, and `fin` on
|
|
855
|
+
* finish.
|
|
866
856
|
*/
|
|
867
857
|
export const StatewireSocketHost = (
|
|
868
858
|
transport: ResourceElement<Statewire>,
|
package/src/host-internal.ts
CHANGED
|
@@ -15,7 +15,8 @@ export namespace StatewireHostInternal {
|
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
export type SocketHost = {
|
|
18
|
-
|
|
18
|
+
/** Null means refused: the socket was told (fin and/or close) and is dead. */
|
|
19
|
+
connect: (clientId: string, socket: Socket) => SocketConnection | null;
|
|
19
20
|
stream: (request: Request) => Promise<Response>;
|
|
20
21
|
frames: (request: Request) => Promise<Response>;
|
|
21
22
|
finish: (fin: StatewireHost.Fin) => void;
|