statewire 0.24.0 → 0.24.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/dist/client/connection.d.ts +4 -0
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +9 -2
- package/dist/client/connection.js.map +1 -1
- package/dist/client/session.d.ts +9 -0
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +100 -17
- package/dist/client/session.js.map +1 -1
- package/dist/host/host.d.ts +1 -0
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js +8 -0
- package/dist/host/host.js.map +1 -1
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +3 -2
- package/dist/host/socket.js.map +1 -1
- package/dist/host.d.ts +13 -7
- package/dist/host.d.ts.map +1 -1
- package/dist/storage.js +1 -1
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +2 -1
- package/dist/transports/sse.js.map +1 -1
- package/dist/wire/protocol.d.ts +6 -1
- package/dist/wire/protocol.d.ts.map +1 -1
- package/dist/wire/protocol.js +12 -4
- package/dist/wire/protocol.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +11 -0
- package/src/client/session.ts +166 -18
- package/src/host/host.ts +13 -0
- package/src/host/socket.ts +5 -2
- package/src/host.ts +14 -6
- package/src/transports/sse.ts +2 -0
- package/src/wire/protocol.ts +11 -3
package/src/client/session.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { encodeContext } from "../wire/context";
|
|
|
2
2
|
import { wrapStorageError } from "./storageError";
|
|
3
3
|
import {
|
|
4
4
|
DEFAULT_PROTOCOL,
|
|
5
|
-
|
|
5
|
+
CLIENT_WIRE,
|
|
6
|
+
GAP_FILL_VERSION,
|
|
6
7
|
protocolOffers,
|
|
7
8
|
inRange,
|
|
8
9
|
} from "../wire/protocol";
|
|
@@ -162,6 +163,8 @@ type Session = {
|
|
|
162
163
|
/** Ephemeral lane clocks: restart at 1 on every attach. */
|
|
163
164
|
ephemeralSeqs: Record<string, number>;
|
|
164
165
|
resumeAt: number;
|
|
166
|
+
/** A refusal named sent durable stamps: a reattach's hello decides them, after `resumeAt`. */
|
|
167
|
+
reattach: boolean;
|
|
165
168
|
idle: boolean;
|
|
166
169
|
onSessionLost: StatewireClient.OnSessionLost | undefined;
|
|
167
170
|
onDeprecatedPath: StatewireClient.OnDeprecatedPath | undefined;
|
|
@@ -182,6 +185,10 @@ type Session = {
|
|
|
182
185
|
lanesOnAttach: boolean;
|
|
183
186
|
attachEntries: ReadonlySet<Pending>;
|
|
184
187
|
frameFailures: number;
|
|
188
|
+
/** A later hello decides stamps this one could not: another live sharer's stamp sat below them. */
|
|
189
|
+
recheck: ReturnType<typeof setTimeout> | undefined;
|
|
190
|
+
/** Hellos in a row that left stamps waiting at the same watermarks: the recheck backs off. */
|
|
191
|
+
rechecks: { watermarks: string; count: number };
|
|
185
192
|
pending: Pending[];
|
|
186
193
|
canonical: unknown;
|
|
187
194
|
documentListeners: Set<() => void>;
|
|
@@ -1321,14 +1328,16 @@ const sessionLostInner = (s: Session, protocol: string, seqWm: number) => {
|
|
|
1321
1328
|
},
|
|
1322
1329
|
);
|
|
1323
1330
|
} else {
|
|
1324
|
-
// Default: the
|
|
1331
|
+
// Default: the queue goes, each command a failure notice. Held entries
|
|
1332
|
+
// never went out: they stay held where they are.
|
|
1325
1333
|
const stamps = mergeBacklog(s, protocol);
|
|
1326
|
-
const
|
|
1327
|
-
const
|
|
1334
|
+
const held = outboxOf(lane).filter((c) => c.held);
|
|
1335
|
+
const queued = outboxOf(lane).filter((c) => !c.held);
|
|
1336
|
+
const keep = new Set(held.map((c) => c.id));
|
|
1328
1337
|
setRecord(
|
|
1329
1338
|
s,
|
|
1330
1339
|
withLane(s.record, protocol, {
|
|
1331
|
-
...withNotices(
|
|
1340
|
+
...withNotices(withOutbox(lane, held), [
|
|
1332
1341
|
...stamps.map((c) => lostNotice(c)),
|
|
1333
1342
|
...queued.map((c) => lostNotice(c)),
|
|
1334
1343
|
]),
|
|
@@ -1345,7 +1354,9 @@ const sessionLostInner = (s: Session, protocol: string, seqWm: number) => {
|
|
|
1345
1354
|
),
|
|
1346
1355
|
}),
|
|
1347
1356
|
);
|
|
1348
|
-
for (const p of s.pending.filter(
|
|
1357
|
+
for (const p of s.pending.filter(
|
|
1358
|
+
(q) => q.command.protocol === protocol && !keep.has(q.id),
|
|
1359
|
+
)) {
|
|
1349
1360
|
s.lifecycle?.onCovered?.(p.inv);
|
|
1350
1361
|
settleEntry(s, p, { ok: false, failure }, "noticed");
|
|
1351
1362
|
}
|
|
@@ -1355,14 +1366,17 @@ const sessionLostInner = (s: Session, protocol: string, seqWm: number) => {
|
|
|
1355
1366
|
wake(s);
|
|
1356
1367
|
};
|
|
1357
1368
|
|
|
1358
|
-
// Every stamp above the watermark
|
|
1359
|
-
//
|
|
1360
|
-
// admitted
|
|
1369
|
+
// Every stamp above the watermark returns to the front of the outbox. A stamp
|
|
1370
|
+
// past watermark+1, or one this client saw admitted, proves the server record
|
|
1371
|
+
// that admitted it is gone. Another live sharer's stamp may still be on its
|
|
1372
|
+
// way on that sharer's attach: it and every stamp after it wait for a later
|
|
1373
|
+
// hello; the stamps before it never arrived and resend under their seqs.
|
|
1361
1374
|
const demote = (
|
|
1362
1375
|
s: Session,
|
|
1363
1376
|
protocol: string,
|
|
1364
1377
|
watermark: number,
|
|
1365
1378
|
demoted: Set<string>,
|
|
1379
|
+
waiting: Set<Pending>,
|
|
1366
1380
|
) => {
|
|
1367
1381
|
const lane = laneOf(s.record, protocol);
|
|
1368
1382
|
const local = laneEntries(s, protocol).filter((p) => p.seq > watermark);
|
|
@@ -1380,6 +1394,17 @@ const demote = (
|
|
|
1380
1394
|
sessionLostInner(s, protocol, watermark);
|
|
1381
1395
|
return;
|
|
1382
1396
|
}
|
|
1397
|
+
const me = s.store.owner?.id;
|
|
1398
|
+
const foreign = moved.find(
|
|
1399
|
+
(c) =>
|
|
1400
|
+
c.owner !== undefined &&
|
|
1401
|
+
c.owner !== me &&
|
|
1402
|
+
!local.some((p) => p.id === c.id),
|
|
1403
|
+
);
|
|
1404
|
+
if (foreign !== undefined) {
|
|
1405
|
+
for (const p of local) if (p.seq > foreign.seq) waiting.add(p);
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1383
1408
|
setRecord(
|
|
1384
1409
|
s,
|
|
1385
1410
|
withLane(s.record, protocol, {
|
|
@@ -1399,6 +1424,24 @@ const demote = (
|
|
|
1399
1424
|
sortPending(s);
|
|
1400
1425
|
};
|
|
1401
1426
|
|
|
1427
|
+
const recheckLater = (s: Session, watermarks: string) => {
|
|
1428
|
+
if (s.rechecks.watermarks !== watermarks)
|
|
1429
|
+
s.rechecks = { watermarks, count: 0 };
|
|
1430
|
+
s.rechecks.count += 1;
|
|
1431
|
+
if (s.recheck !== undefined) return;
|
|
1432
|
+
s.recheck = setTimeout(
|
|
1433
|
+
() => {
|
|
1434
|
+
s.recheck = undefined;
|
|
1435
|
+
if (s.running && s.attach === "attached")
|
|
1436
|
+
s.transport?.connection.reconnect();
|
|
1437
|
+
},
|
|
1438
|
+
backoff(s.rechecks.count + 1),
|
|
1439
|
+
);
|
|
1440
|
+
};
|
|
1441
|
+
|
|
1442
|
+
const gapFill = (s: Session) =>
|
|
1443
|
+
s.selection !== undefined && s.selection.version >= GAP_FILL_VERSION;
|
|
1444
|
+
|
|
1402
1445
|
const selectionOf = (
|
|
1403
1446
|
syn: StatewireSyn,
|
|
1404
1447
|
): StatewireProtocol.Selection | undefined => {
|
|
@@ -1414,6 +1457,21 @@ const selectionOf = (
|
|
|
1414
1457
|
};
|
|
1415
1458
|
};
|
|
1416
1459
|
|
|
1460
|
+
// The host admits an attach's durable statements right after its hello.
|
|
1461
|
+
const carriedThrough = (
|
|
1462
|
+
lane: { name: string; seq?: number },
|
|
1463
|
+
attached: ReadonlySet<Pending>,
|
|
1464
|
+
) => {
|
|
1465
|
+
const carried = new Set(
|
|
1466
|
+
[...attached]
|
|
1467
|
+
.filter((p) => p.command.protocol === lane.name)
|
|
1468
|
+
.map((p) => p.seq),
|
|
1469
|
+
);
|
|
1470
|
+
let seq = lane.seq ?? 0;
|
|
1471
|
+
while (carried.has(seq + 1)) seq += 1;
|
|
1472
|
+
return seq;
|
|
1473
|
+
};
|
|
1474
|
+
|
|
1417
1475
|
const handleHello = (
|
|
1418
1476
|
s: Session,
|
|
1419
1477
|
{ ops, syn }: { ops: unknown[]; syn: StatewireSyn },
|
|
@@ -1435,6 +1493,7 @@ const handleHello = (
|
|
|
1435
1493
|
s.attachEpoch += 1;
|
|
1436
1494
|
s.frameFailures = 0;
|
|
1437
1495
|
s.resumeAt = 0;
|
|
1496
|
+
s.reattach = false;
|
|
1438
1497
|
const lanes = syn.protocols ?? [];
|
|
1439
1498
|
s.ephemeral =
|
|
1440
1499
|
lanes.length > 0 && lanes.every((lane) => lane.record === "ephemeral");
|
|
@@ -1446,6 +1505,7 @@ const handleHello = (
|
|
|
1446
1505
|
// verdicts (the server never re-runs them); seqs above it never arrived
|
|
1447
1506
|
// and go back to the outbox for fresh ones.
|
|
1448
1507
|
const demoted = new Set<string>();
|
|
1508
|
+
const waiting = new Set<Pending>();
|
|
1449
1509
|
for (const lane of lanes) {
|
|
1450
1510
|
if (lane.record === "ephemeral") {
|
|
1451
1511
|
s.ephemeralSeqs[lane.name] = Math.max(
|
|
@@ -1456,17 +1516,24 @@ const handleHello = (
|
|
|
1456
1516
|
);
|
|
1457
1517
|
continue;
|
|
1458
1518
|
}
|
|
1459
|
-
demote(s, lane.name, lane
|
|
1519
|
+
demote(s, lane.name, carriedThrough(lane, attached), demoted, waiting);
|
|
1460
1520
|
s.durableSeq[lane.name] = lane.seq ?? 0;
|
|
1461
1521
|
}
|
|
1462
1522
|
for (const entry of s.pending) {
|
|
1463
1523
|
entry.posted =
|
|
1464
1524
|
attached.has(entry) ||
|
|
1525
|
+
waiting.has(entry) ||
|
|
1465
1526
|
(isStamped(entry) &&
|
|
1466
1527
|
entry.seq <= durableSeqOf(s, entry.command.protocol) &&
|
|
1467
1528
|
!isEphemeral(s, entry.command.protocol));
|
|
1468
1529
|
entry.stale = false;
|
|
1469
1530
|
}
|
|
1531
|
+
if (waiting.size > 0)
|
|
1532
|
+
recheckLater(
|
|
1533
|
+
s,
|
|
1534
|
+
JSON.stringify(lanes.map((lane) => [lane.name, lane.seq])),
|
|
1535
|
+
);
|
|
1536
|
+
else s.rechecks = { watermarks: "", count: 0 };
|
|
1470
1537
|
syncBacklogInner(s);
|
|
1471
1538
|
s.sessionKnown = true;
|
|
1472
1539
|
flushInner(s, demoted);
|
|
@@ -1498,10 +1565,26 @@ const handleOps = (
|
|
|
1498
1565
|
// A retry-less {syn} refusal is the one session-loss verdict: a gap against
|
|
1499
1566
|
// a durable lane's exact watermark settles that lane's history lost; a
|
|
1500
1567
|
// pushback (retry present) or a gapless refusal only reconciles the resend queues.
|
|
1568
|
+
// Under the gap-fill wire a refusal can predate frames sent since: durable
|
|
1569
|
+
// lanes never resend from it, a reattach's hello decides them.
|
|
1501
1570
|
const handleRefusal = (s: Session, syn: StatewireSyn) => {
|
|
1502
|
-
const
|
|
1571
|
+
const stated = (syn.protocols ?? []).filter(
|
|
1503
1572
|
(lane): lane is { name: string; seq: number } => lane.seq !== undefined,
|
|
1504
1573
|
);
|
|
1574
|
+
const lanes = gapFill(s)
|
|
1575
|
+
? stated.filter(({ name }) => isEphemeral(s, name))
|
|
1576
|
+
: stated;
|
|
1577
|
+
if (
|
|
1578
|
+
gapFill(s) &&
|
|
1579
|
+
s.pending.some((p) => {
|
|
1580
|
+
if (!isStamped(p) || !p.attempted) return false;
|
|
1581
|
+
const protocol = p.command.protocol;
|
|
1582
|
+
if (isEphemeral(s, protocol)) return false;
|
|
1583
|
+
const seq = stated.find((lane) => lane.name === protocol)?.seq ?? 0;
|
|
1584
|
+
return p.seq > Math.max(seq, durableSeqOf(s, protocol));
|
|
1585
|
+
})
|
|
1586
|
+
)
|
|
1587
|
+
s.reattach = true;
|
|
1505
1588
|
for (const { name, seq } of lanes) {
|
|
1506
1589
|
const watermark = isEphemeral(s, name)
|
|
1507
1590
|
? seq
|
|
@@ -1683,21 +1766,47 @@ const pump = async (s: Session) => {
|
|
|
1683
1766
|
while (s.running && !isFinished(s)) {
|
|
1684
1767
|
if (!s.hydrated) await s.ready;
|
|
1685
1768
|
const transport = s.transport;
|
|
1769
|
+
if (s.reattach && s.attach === "attached" && transport) {
|
|
1770
|
+
const delay = s.resumeAt - Date.now();
|
|
1771
|
+
if (delay > 0) {
|
|
1772
|
+
await sleepUntilWake(s, delay);
|
|
1773
|
+
continue;
|
|
1774
|
+
}
|
|
1775
|
+
// Ephemeral answers ride only this attach: drain them first.
|
|
1776
|
+
if (
|
|
1777
|
+
s.pending.some(
|
|
1778
|
+
(p) =>
|
|
1779
|
+
isStamped(p) && p.attempted && isEphemeral(s, p.command.protocol),
|
|
1780
|
+
)
|
|
1781
|
+
) {
|
|
1782
|
+
await waitForWake(s);
|
|
1783
|
+
continue;
|
|
1784
|
+
}
|
|
1785
|
+
s.reattach = false;
|
|
1786
|
+
const epoch = s.attachEpoch;
|
|
1787
|
+
transport.connection.reconnect();
|
|
1788
|
+
while (s.running && !isFinished(s) && epoch === s.attachEpoch)
|
|
1789
|
+
await waitForWake(s);
|
|
1790
|
+
continue;
|
|
1791
|
+
}
|
|
1686
1792
|
const flushable = flushCandidates(s).length > 0;
|
|
1687
|
-
const queued =
|
|
1793
|
+
const queued =
|
|
1794
|
+
s.pending.some((p) => isOutboxed(p) && !p.inv.held) ||
|
|
1795
|
+
queuedInRecord(s);
|
|
1688
1796
|
if (
|
|
1689
1797
|
buildLanes(s) === null &&
|
|
1690
1798
|
!flushable &&
|
|
1691
1799
|
(!queued || s.attach === "attached")
|
|
1692
1800
|
)
|
|
1693
1801
|
return;
|
|
1694
|
-
|
|
1802
|
+
const waiting = s.pending.length > 0 || queued;
|
|
1803
|
+
if (transport?.parked && !waiting) return;
|
|
1695
1804
|
if (s.attach !== "attached" || !transport) {
|
|
1696
|
-
if (
|
|
1805
|
+
if (!waiting) return;
|
|
1697
1806
|
if (
|
|
1698
1807
|
(transport?.connection.status === "standby" &&
|
|
1699
1808
|
transport.connection.reason === "idle") ||
|
|
1700
|
-
|
|
1809
|
+
transport?.parked
|
|
1701
1810
|
) {
|
|
1702
1811
|
transport.connection.reconnect();
|
|
1703
1812
|
}
|
|
@@ -1743,6 +1852,18 @@ const pump = async (s: Session) => {
|
|
|
1743
1852
|
: send(lanes));
|
|
1744
1853
|
} catch {
|
|
1745
1854
|
if (!s.running || isFinished(s)) return;
|
|
1855
|
+
// The frame may have arrived. Under the gap-fill wire its seqs never
|
|
1856
|
+
// go out again: the next hello answers what arrived and frees the rest.
|
|
1857
|
+
if (lanes !== null && lanes.entries.length > 0 && gapFill(s)) {
|
|
1858
|
+
if (epoch !== s.attachEpoch) continue;
|
|
1859
|
+
for (const entry of lanes.entries)
|
|
1860
|
+
if (s.pending.includes(entry)) entry.posted = true;
|
|
1861
|
+
s.frameFailures += 1;
|
|
1862
|
+
await sleepUntilWake(s, backoff(s.frameFailures));
|
|
1863
|
+
if (s.running && epoch === s.attachEpoch && s.attach === "attached")
|
|
1864
|
+
transport.connection.reconnect();
|
|
1865
|
+
continue;
|
|
1866
|
+
}
|
|
1746
1867
|
if (lanes?.frame.ctx?.length) {
|
|
1747
1868
|
s.lanesOnAttach = true;
|
|
1748
1869
|
transport.connection.reconnect();
|
|
@@ -1852,6 +1973,8 @@ const start = (s: Session) => {
|
|
|
1852
1973
|
|
|
1853
1974
|
const stop = (s: Session) => {
|
|
1854
1975
|
s.running = false;
|
|
1976
|
+
clearTimeout(s.recheck);
|
|
1977
|
+
s.recheck = undefined;
|
|
1855
1978
|
if (s.shared) {
|
|
1856
1979
|
s.shared.unsubscribe?.();
|
|
1857
1980
|
s.shared.unsubscribe = null;
|
|
@@ -2038,6 +2161,16 @@ const flushCandidates = (s: Session): Candidate[] =>
|
|
|
2038
2161
|
return order.slice(0, free).map((stored) => ({ protocol, stored }));
|
|
2039
2162
|
});
|
|
2040
2163
|
|
|
2164
|
+
// An entry the record holds that a flush would stamp once attached, whether
|
|
2165
|
+
// or not this page made the send: inserted, or kept from an earlier mount or
|
|
2166
|
+
// another sharer. It dials a parked or idle transport as a send does.
|
|
2167
|
+
const queuedInRecord = (s: Session) =>
|
|
2168
|
+
Object.keys(s.record.lanes).some(
|
|
2169
|
+
(protocol) =>
|
|
2170
|
+
(s.selection ? negotiated(s, protocol) : offered(s, protocol)) &&
|
|
2171
|
+
bubble(s, protocol, laneQueue(s, protocol)).free > 0,
|
|
2172
|
+
);
|
|
2173
|
+
|
|
2041
2174
|
const reasonOf = (
|
|
2042
2175
|
s: Session,
|
|
2043
2176
|
stored: StatewireClient.OutboxCommand,
|
|
@@ -2372,7 +2505,7 @@ export const updateOutbox = (
|
|
|
2372
2505
|
const producedOf = (
|
|
2373
2506
|
s: Session,
|
|
2374
2507
|
): NonNullable<StatewireClient.SessionCommand["produced"]> => ({
|
|
2375
|
-
wire: s.options.wireVersion ??
|
|
2508
|
+
wire: s.options.wireVersion ?? CLIENT_WIRE,
|
|
2376
2509
|
protocols:
|
|
2377
2510
|
protocolOffers(s.options) === "*" && s.selection
|
|
2378
2511
|
? s.selection.protocols
|
|
@@ -2440,6 +2573,7 @@ export const insertOutbox = (
|
|
|
2440
2573
|
return;
|
|
2441
2574
|
}
|
|
2442
2575
|
let base = s.record;
|
|
2576
|
+
let entered = stored;
|
|
2443
2577
|
if (entry.from !== undefined) {
|
|
2444
2578
|
// Re-inserting a settled update consumes it in the same write: a
|
|
2445
2579
|
// redelivery finds it gone.
|
|
@@ -2447,16 +2581,27 @@ export const insertOutbox = (
|
|
|
2447
2581
|
l.inbox?.some((n) => n.id === entry.from),
|
|
2448
2582
|
);
|
|
2449
2583
|
if (source === undefined || locate(s, id) !== undefined) return;
|
|
2584
|
+
const notice = source[1].inbox!.find((n) => n.id === entry.from)!;
|
|
2585
|
+
entered = { ...stored, ts: notice.ts };
|
|
2450
2586
|
base = withLane(base, source[0], withoutNotice(source[1], entry.from));
|
|
2451
2587
|
}
|
|
2452
2588
|
const lane = laneOf(base, protocol);
|
|
2589
|
+
// A re-queued command keeps its place: ahead of what was queued after it.
|
|
2590
|
+
const later = outboxOf(lane).find((c) => c.ts >= entered.ts);
|
|
2591
|
+
const position =
|
|
2592
|
+
entry.from !== undefined &&
|
|
2593
|
+
entry.after === undefined &&
|
|
2594
|
+
entry.before === undefined &&
|
|
2595
|
+
later !== undefined
|
|
2596
|
+
? { before: later.id }
|
|
2597
|
+
: entry;
|
|
2453
2598
|
try {
|
|
2454
2599
|
setRecord(
|
|
2455
2600
|
s,
|
|
2456
2601
|
withLane(
|
|
2457
2602
|
base,
|
|
2458
2603
|
protocol,
|
|
2459
|
-
withOutbox(lane, placed(outboxOf(lane),
|
|
2604
|
+
withOutbox(lane, placed(outboxOf(lane), entered, position)),
|
|
2460
2605
|
),
|
|
2461
2606
|
);
|
|
2462
2607
|
} catch (error) {
|
|
@@ -2728,7 +2873,7 @@ const loadSessionRecord = (
|
|
|
2728
2873
|
let stored = store.load();
|
|
2729
2874
|
if (stored !== null) validateLanes(stored);
|
|
2730
2875
|
const current = {
|
|
2731
|
-
wire: options.wireVersion ??
|
|
2876
|
+
wire: options.wireVersion ?? CLIENT_WIRE,
|
|
2732
2877
|
protocols: protocolOffers(options),
|
|
2733
2878
|
};
|
|
2734
2879
|
const compatible = (record: StatewireClient.SessionRecord) =>
|
|
@@ -2861,6 +3006,7 @@ export const useStatewireSession = (
|
|
|
2861
3006
|
durableSeq: {},
|
|
2862
3007
|
ephemeralSeqs: {},
|
|
2863
3008
|
resumeAt: 0,
|
|
3009
|
+
reattach: false,
|
|
2864
3010
|
idle: false,
|
|
2865
3011
|
onSessionLost: undefined,
|
|
2866
3012
|
onDeprecatedPath: undefined,
|
|
@@ -2878,6 +3024,8 @@ export const useStatewireSession = (
|
|
|
2878
3024
|
lanesOnAttach: false,
|
|
2879
3025
|
attachEntries: new Set(),
|
|
2880
3026
|
frameFailures: 0,
|
|
3027
|
+
recheck: undefined,
|
|
3028
|
+
rechecks: { watermarks: "", count: 0 },
|
|
2881
3029
|
pending: [],
|
|
2882
3030
|
canonical: undefined,
|
|
2883
3031
|
documentListeners: new Set(),
|
package/src/host/host.ts
CHANGED
|
@@ -182,6 +182,7 @@ export type CommandsSubmission = {
|
|
|
182
182
|
clientId?: string;
|
|
183
183
|
caller?: StatewireHost.Caller;
|
|
184
184
|
context?: Statewire.Context;
|
|
185
|
+
version?: string;
|
|
185
186
|
attach?: StatewireHost.Attach;
|
|
186
187
|
onApplied?: () => void;
|
|
187
188
|
onSettle?: (outcome: CommandsOutcome) => void;
|
|
@@ -206,6 +207,7 @@ export const bindCommandsHost = (commands: object, host: CommandsHost) => {
|
|
|
206
207
|
|
|
207
208
|
type QueuedCall = {
|
|
208
209
|
context?: Statewire.Context;
|
|
210
|
+
version?: string | undefined;
|
|
209
211
|
method: string;
|
|
210
212
|
params: readonly unknown[];
|
|
211
213
|
ts: number;
|
|
@@ -223,6 +225,7 @@ type ActiveInvocation = CommandsInvocation & {
|
|
|
223
225
|
|
|
224
226
|
type CommandsCell = {
|
|
225
227
|
context: (() => Statewire.Context) | undefined;
|
|
228
|
+
version: string | undefined;
|
|
226
229
|
handlers: Record<string, (...args: never[]) => unknown> | null;
|
|
227
230
|
queue: QueuedCall[];
|
|
228
231
|
active: Map<ActiveInvocation, QueuedCall>;
|
|
@@ -364,9 +367,16 @@ const dispatch = (cell: CommandsCell, call: QueuedCall) => {
|
|
|
364
367
|
cell.listener?.onStart(inv);
|
|
365
368
|
cell.listener?.onPosted?.(inv);
|
|
366
369
|
const attach = call.origin?.attach;
|
|
370
|
+
const version =
|
|
371
|
+
call.origin !== undefined ? call.origin.version : call.version;
|
|
367
372
|
const ctx: StatewireHost.Ctx = {
|
|
368
373
|
caller: call.origin?.caller ?? LOCAL_CALLER,
|
|
369
374
|
context: call.context ?? call.origin?.context ?? Object.freeze({}),
|
|
375
|
+
get version() {
|
|
376
|
+
if (version === undefined)
|
|
377
|
+
throw new Error("statewire: in-process dispatch has no version");
|
|
378
|
+
return version;
|
|
379
|
+
},
|
|
370
380
|
get attach() {
|
|
371
381
|
if (attach === undefined)
|
|
372
382
|
throw new Error("statewire: in-process dispatch has no attach");
|
|
@@ -445,6 +455,7 @@ export const useStatewireCommands = <THandlers extends Statewire.Commands>(
|
|
|
445
455
|
const [built] = useState(() => {
|
|
446
456
|
const cell: CommandsCell = {
|
|
447
457
|
context: undefined,
|
|
458
|
+
version: undefined,
|
|
448
459
|
handlers: null,
|
|
449
460
|
queue: [],
|
|
450
461
|
active: new Map(),
|
|
@@ -465,6 +476,7 @@ export const useStatewireCommands = <THandlers extends Statewire.Commands>(
|
|
|
465
476
|
}
|
|
466
477
|
cell.queue.push({
|
|
467
478
|
context: captureContext(cell.context?.() ?? {}),
|
|
479
|
+
version: cell.version,
|
|
468
480
|
method,
|
|
469
481
|
params,
|
|
470
482
|
ts: Date.now(),
|
|
@@ -507,6 +519,7 @@ export const useStatewireCommands = <THandlers extends Statewire.Commands>(
|
|
|
507
519
|
});
|
|
508
520
|
|
|
509
521
|
built.cell.context = options?.context;
|
|
522
|
+
built.cell.version = options?.version;
|
|
510
523
|
useEffect(() => {
|
|
511
524
|
const { cell } = built;
|
|
512
525
|
cell.unmounted = false;
|
package/src/host/socket.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { MAX_CONTEXT_BYTES } from "../wire/limits";
|
|
|
3
3
|
import { encodeDocument } from "../document";
|
|
4
4
|
import {
|
|
5
5
|
DEFAULT_PROTOCOL,
|
|
6
|
-
WIRE_VERSION,
|
|
7
6
|
DEFAULT_PROTOCOL_VERSION,
|
|
8
7
|
GAP_FILL_VERSION,
|
|
9
8
|
MIN_SERVED_WIRE_VERSION,
|
|
@@ -183,8 +182,9 @@ export type ParsedFrame = {
|
|
|
183
182
|
res?: { protocol: string; seq: number }[];
|
|
184
183
|
};
|
|
185
184
|
|
|
185
|
+
// This host serves through the gap-fill wire: it names no attach to retire.
|
|
186
186
|
const SERVED_WIRE: StatewireProtocol.Range = {
|
|
187
|
-
version:
|
|
187
|
+
version: GAP_FILL_VERSION,
|
|
188
188
|
minVersion: MIN_SERVED_WIRE_VERSION,
|
|
189
189
|
};
|
|
190
190
|
|
|
@@ -1147,6 +1147,9 @@ const admitCmd = (
|
|
|
1147
1147
|
caller: callerOf(h, rec, conn),
|
|
1148
1148
|
attach: conn.attach,
|
|
1149
1149
|
context: captureContext(conn.contexts.get(statement.protocol) ?? {}),
|
|
1150
|
+
version: conn.selection.protocols.find(
|
|
1151
|
+
(p) => p.name === statement.protocol,
|
|
1152
|
+
)!.version,
|
|
1150
1153
|
onApplied: () => {
|
|
1151
1154
|
if (adm.settled || adm.applied) return;
|
|
1152
1155
|
adm.applied = true;
|
package/src/host.ts
CHANGED
|
@@ -45,19 +45,27 @@ export namespace StatewireHost {
|
|
|
45
45
|
setOnDocuments: (listener: (() => void) | null) => void;
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
/** What `this.context` and `this.version` read for a call made on the proxy (in-process dispatch). */
|
|
49
|
+
export type CommandsOptions = {
|
|
50
|
+
context?: () => Statewire.Context;
|
|
51
|
+
version?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
48
54
|
/**
|
|
49
55
|
* Per-invocation handle bound as `this` of a command handler: `caller` is
|
|
50
56
|
* the submitting client, `attach` the admitting attach's document view
|
|
51
|
-
* (in-process dispatch has none — reading it throws), `
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
57
|
+
* (in-process dispatch has none — reading it throws), `version` the
|
|
58
|
+
* command protocol's version that attach negotiated (in-process dispatch
|
|
59
|
+
* reads `CommandsOptions.version`; without one, reading it throws),
|
|
60
|
+
* `applied()` states the broadcast state now contains the command's effects
|
|
61
|
+
* — the client drops its optimistic overlay — before the handler settles
|
|
62
|
+
* (idempotent, a no-op after settle). The command settles when the
|
|
63
|
+
* handler's returned promise does.
|
|
55
64
|
*/
|
|
56
|
-
export type CommandsOptions = { context?: () => Statewire.Context };
|
|
57
|
-
|
|
58
65
|
export type Ctx = {
|
|
59
66
|
readonly caller: Caller;
|
|
60
67
|
readonly context: Statewire.Context;
|
|
68
|
+
readonly version: string;
|
|
61
69
|
readonly attach: Attach;
|
|
62
70
|
readonly signal: AbortSignal;
|
|
63
71
|
readonly applied: () => void;
|
package/src/transports/sse.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { ResourceElement } from "@assistant-ui/tap";
|
|
|
4
4
|
import { createSseParser } from "./sse-parser";
|
|
5
5
|
import { timing } from "../client/timing";
|
|
6
6
|
import {
|
|
7
|
+
replacing,
|
|
7
8
|
settleFin,
|
|
8
9
|
classify,
|
|
9
10
|
handleEnvelope,
|
|
@@ -149,6 +150,7 @@ const attemptStream = async (
|
|
|
149
150
|
if (!headers.has(name)) headers.set(name, value);
|
|
150
151
|
}
|
|
151
152
|
c.negotiation = parseNegotiation(headers);
|
|
153
|
+
replacing(c, headers);
|
|
152
154
|
c.authStale = false;
|
|
153
155
|
const lanes = flushTapSync(() => c.ctx.takeAttachLanes());
|
|
154
156
|
const body = lanes === null ? undefined : JSON.stringify(lanes);
|
package/src/wire/protocol.ts
CHANGED
|
@@ -18,9 +18,17 @@ export namespace StatewireProtocol {
|
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export const WIRE_VERSION = "2026-11-
|
|
21
|
+
export const WIRE_VERSION = "2026-11-02";
|
|
22
22
|
/** Attach carries a results cursor and gets its verdicts gap-filled; a re-sent seq at or below the watermark is a protocol error. */
|
|
23
23
|
export const GAP_FILL_VERSION = "2026-11-01";
|
|
24
|
+
/** Every attach of a connection names its attacher; a host retires the attacher's earlier attaches before the hello. */
|
|
25
|
+
export const FENCE_VERSION = "2026-11-02";
|
|
26
|
+
/** The wire a client offers by default: the gap-fill contract and newer. */
|
|
27
|
+
export const CLIENT_WIRE: StatewireProtocol.Range = {
|
|
28
|
+
version: WIRE_VERSION,
|
|
29
|
+
minVersion: GAP_FILL_VERSION,
|
|
30
|
+
};
|
|
31
|
+
export const ATTACHER_HEADER = "Statewire-Attacher";
|
|
24
32
|
/** The oldest wire contract a host serves by default (re-sent seqs re-emit their verdicts). */
|
|
25
33
|
export const MIN_SERVED_WIRE_VERSION = "2026-09-30";
|
|
26
34
|
export const DEFAULT_PROTOCOL_VERSION = "2026-09-30";
|
|
@@ -81,7 +89,7 @@ export const negotiationHeaders = (
|
|
|
81
89
|
if (offers !== "*" && !offers.length)
|
|
82
90
|
invalid("protocol list must not be empty");
|
|
83
91
|
return {
|
|
84
|
-
[VERSION_HEADER]: suffix(options.wireVersion ??
|
|
92
|
+
[VERSION_HEADER]: suffix(options.wireVersion ?? CLIENT_WIRE),
|
|
85
93
|
[PROTOCOL_HEADER]:
|
|
86
94
|
offers === "*"
|
|
87
95
|
? "*"
|
|
@@ -201,7 +209,7 @@ export const validateSelection = (
|
|
|
201
209
|
return invalid("missing negotiation selection");
|
|
202
210
|
const syn = value as Record<string, unknown>;
|
|
203
211
|
const version = validateVersion(syn.version);
|
|
204
|
-
if (!inRange(version, options.wireVersion ??
|
|
212
|
+
if (!inRange(version, options.wireVersion ?? CLIENT_WIRE))
|
|
205
213
|
invalid("selected wire version is outside offer");
|
|
206
214
|
if (!Array.isArray(syn.protocols))
|
|
207
215
|
return invalid("syn.protocols must be an array");
|