experimental-a2 0.3.0 → 0.5.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/CHANGELOG.md +207 -0
- package/dist/ai-CFNeCrRl.d.ts +334 -0
- package/dist/ai-CFNeCrRl.d.ts.map +1 -0
- package/dist/ai-server.browser.js +2 -0
- package/dist/ai-server.browser.js.map +1 -0
- package/dist/ai-server.d.ts +49 -9
- package/dist/ai-server.d.ts.map +1 -0
- package/dist/ai-server.js +123 -49
- package/dist/ai-server.js.map +1 -0
- package/dist/ai.d.ts +2 -303
- package/dist/ai.js +233 -86
- package/dist/ai.js.map +1 -0
- package/dist/cli-B3VuxoDe.js +599 -0
- package/dist/cli-B3VuxoDe.js.map +1 -0
- package/dist/cli-bin.d.ts +1 -0
- package/dist/cli-bin.js +7 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli.d.ts +20 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +2 -0
- package/dist/client-BKlyLiOU.js +1008 -0
- package/dist/client-BKlyLiOU.js.map +1 -0
- package/dist/client-D7mvIXrF.d.ts +191 -0
- package/dist/client-D7mvIXrF.d.ts.map +1 -0
- package/dist/client.d.ts +2 -90
- package/dist/client.js +1 -409
- package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +12 -2
- package/dist/contract-48bUMgcL.js.map +1 -0
- package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +63 -8
- package/dist/contract-jIfaR085.d.ts.map +1 -0
- package/dist/devtools-J_jZ2vQf.d.ts +152 -0
- package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
- package/dist/devtools-kJJaORn-.js +340 -0
- package/dist/devtools-kJJaORn-.js.map +1 -0
- package/dist/devtools-server.browser.js +3 -1
- package/dist/devtools-server.browser.js.map +1 -0
- package/dist/devtools-server.d.ts +4 -3
- package/dist/devtools-server.d.ts.map +1 -0
- package/dist/devtools-server.js +226 -43
- package/dist/devtools-server.js.map +1 -0
- package/dist/devtools.d.ts +2 -0
- package/dist/devtools.js +2 -0
- package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +6 -4
- package/dist/errors-BQuJpe82.js.map +1 -0
- package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +3 -2
- package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
- package/dist/http.d.ts +135 -28
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +647 -61
- package/dist/http.js.map +1 -0
- package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +4 -2
- package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +4 -1
- package/dist/inspection-DaxB5jM2.js.map +1 -0
- package/dist/internal-DstsI6Re.js +211 -0
- package/dist/internal-DstsI6Re.js.map +1 -0
- package/dist/otel.d.ts +3 -2
- package/dist/otel.d.ts.map +1 -0
- package/dist/otel.js +2 -0
- package/dist/otel.js.map +1 -0
- package/dist/platform-B4TnJtWu.js +34 -0
- package/dist/platform-B4TnJtWu.js.map +1 -0
- package/dist/react.d.ts +47 -15
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +30 -15
- package/dist/react.js.map +1 -0
- package/dist/retryable-lazy-DZWmHpii.js +2 -0
- package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +79 -0
- package/dist/scheduler-qstash.d.ts.map +1 -0
- package/dist/scheduler-qstash.js +501 -0
- package/dist/scheduler-qstash.js.map +1 -0
- package/dist/scheduler-task-BpzhPnRS.js +56 -0
- package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
- package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +18 -24
- package/dist/scheduler-vercel.d.ts.map +1 -0
- package/dist/scheduler-vercel.js +228 -0
- package/dist/scheduler-vercel.js.map +1 -0
- package/dist/server-C72KOw51.js +1457 -0
- package/dist/server-C72KOw51.js.map +1 -0
- package/dist/server-DUF9pjsx.d.ts +261 -0
- package/dist/server-DUF9pjsx.d.ts.map +1 -0
- package/dist/server.browser.js +2 -0
- package/dist/server.browser.js.map +1 -0
- package/dist/server.d.ts +4 -155
- package/dist/server.js +2 -2
- package/dist/{log-ldf5g8Cx.d.ts → store-DysUkTH3.d.ts} +120 -35
- package/dist/store-DysUkTH3.d.ts.map +1 -0
- package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +3 -1
- package/dist/store-N8PXxDAS.js.map +1 -0
- package/dist/store-codec-DTG0Ftek.js +10 -0
- package/dist/store-codec-DTG0Ftek.js.map +1 -0
- package/dist/store-memory.d.ts +12 -0
- package/dist/store-memory.d.ts.map +1 -0
- package/dist/{log-memory.js → store-memory.js} +145 -34
- package/dist/store-memory.js.map +1 -0
- package/dist/{log-polling-DZ1MiKLg.js → store-polling-dSeLxzfb.js} +7 -5
- package/dist/store-polling-dSeLxzfb.js.map +1 -0
- package/dist/{log-postgres.d.ts → store-postgres.d.ts} +7 -6
- package/dist/store-postgres.d.ts.map +1 -0
- package/dist/{log-postgres.js → store-postgres.js} +214 -24
- package/dist/store-postgres.js.map +1 -0
- package/dist/{log-redis-core-CyJ5L8yR.js → store-redis-core-BFLwz0Wj.js} +271 -66
- package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
- package/dist/{log-redis-http.d.ts → store-redis-http.d.ts} +8 -7
- package/dist/store-redis-http.d.ts.map +1 -0
- package/dist/{log-redis-http.js → store-redis-http.js} +22 -12
- package/dist/store-redis-http.js.map +1 -0
- package/dist/{log-redis.d.ts → store-redis.d.ts} +7 -6
- package/dist/store-redis.d.ts.map +1 -0
- package/dist/{log-redis.js → store-redis.js} +101 -39
- package/dist/store-redis.js.map +1 -0
- package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +7 -6
- package/dist/store-sqlite.d.ts.map +1 -0
- package/dist/{log-sqlite.js → store-sqlite.js} +118 -22
- package/dist/store-sqlite.js.map +1 -0
- package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +3 -2
- package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
- package/dist/testing.browser.d.ts +1 -0
- package/dist/testing.browser.js +6 -0
- package/dist/testing.browser.js.map +1 -0
- package/dist/testing.d.ts +32 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +103 -0
- package/dist/testing.js.map +1 -0
- package/dist/validate-XKT4FSNn.js +2 -0
- package/dist/validate-XKT4FSNn.js.map +1 -0
- package/dist/wire-BFQmSJ-9.js +284 -0
- package/dist/wire-BFQmSJ-9.js.map +1 -0
- package/docs/01-quickstart.mdx +3 -3
- package/docs/concepts/01-contracts.mdx +18 -13
- package/docs/concepts/02-handlers.mdx +7 -7
- package/docs/concepts/03-durability.mdx +21 -21
- package/docs/concepts/04-state.mdx +18 -21
- package/docs/guides/01-timers.mdx +154 -49
- package/docs/guides/02-cancellation.mdx +30 -4
- package/docs/guides/03-react.mdx +76 -57
- package/docs/guides/04-local-first.mdx +1 -1
- package/docs/guides/05-production.mdx +310 -61
- package/docs/guides/06-ai-agents.mdx +252 -74
- package/docs/guides/07-devtools.mdx +137 -12
- package/docs/guides/08-application-data.mdx +1 -1
- package/docs/guides/09-presence.mdx +263 -0
- package/docs/guides/10-transports.mdx +140 -0
- package/docs/index.mdx +18 -14
- package/docs/reference/01-api.mdx +839 -110
- package/docs/reference/02-errors.mdx +13 -6
- package/package.json +24 -7
- package/src/ai-coordinator.ts +358 -0
- package/src/ai-projector.ts +524 -0
- package/src/ai-sdk-step.ts +261 -0
- package/src/ai-server.browser.ts +5 -0
- package/src/ai-server.ts +1719 -0
- package/src/ai.ts +2155 -0
- package/src/cache-indexeddb.ts +10 -0
- package/src/cli-bin.ts +5 -0
- package/src/cli.ts +1046 -0
- package/src/client.ts +1826 -0
- package/src/contract.ts +206 -0
- package/src/deterministic-id.ts +72 -0
- package/src/devtools-app.ts +989 -0
- package/src/devtools-server.browser.ts +5 -0
- package/src/devtools-server.ts +604 -0
- package/src/devtools.ts +716 -0
- package/src/errors.ts +50 -0
- package/src/http.ts +394 -0
- package/src/idempotent-replay.ts +53 -0
- package/src/index.ts +37 -0
- package/src/inspection.ts +39 -0
- package/src/internal.ts +426 -0
- package/src/otel.ts +59 -0
- package/src/platform.ts +60 -0
- package/src/push-envelope.ts +137 -0
- package/src/react.ts +284 -0
- package/src/reducer.ts +108 -0
- package/src/retryable-lazy.ts +27 -0
- package/src/scheduler-qstash.ts +915 -0
- package/src/scheduler-task.ts +106 -0
- package/src/scheduler-vercel.ts +437 -0
- package/src/server.browser.ts +12 -0
- package/src/server.ts +2700 -0
- package/src/session-socket.ts +548 -0
- package/src/sse.ts +141 -0
- package/src/standard-schema.ts +77 -0
- package/src/store-codec.ts +10 -0
- package/src/store-memory.ts +788 -0
- package/src/store-polling.ts +102 -0
- package/src/store-postgres.ts +1212 -0
- package/src/store-redis-core.ts +1494 -0
- package/src/store-redis-http.ts +116 -0
- package/src/store-redis.ts +458 -0
- package/src/store-sqlite.ts +1108 -0
- package/src/store.ts +385 -0
- package/src/telemetry.ts +47 -0
- package/src/testing.browser.ts +5 -0
- package/src/testing.ts +185 -0
- package/src/validate.ts +39 -0
- package/src/wire.ts +454 -0
- package/dist/internal-gCd5qMry.js +0 -44
- package/dist/log-memory.d.ts +0 -11
- package/dist/recovery-vercel.js +0 -119
- package/dist/server-BcLa4RFL.js +0 -877
- package/dist/wire-BVsgR8o9.js +0 -62
|
@@ -0,0 +1,1008 @@
|
|
|
1
|
+
import { n as validateSync } from "./validate-XKT4FSNn.js";
|
|
2
|
+
import { t as A2Error } from "./errors-BQuJpe82.js";
|
|
3
|
+
import { a as PRESENCE_TIMINGS, c as STREAM_TIMINGS, g as nullProtoRecord } from "./internal-DstsI6Re.js";
|
|
4
|
+
import { c as isWireEvent, d as parseSocketFrame, f as presencePatchFromWire, l as isWirePresencePatch, m as presenceSnapshotFromWire, o as eventFromWire, r as errorFromWire, u as isWirePresenceSnapshot } from "./wire-BFQmSJ-9.js";
|
|
5
|
+
//#region src/client.ts
|
|
6
|
+
/**
|
|
7
|
+
* experimental-a2/client — the framework-agnostic session client.
|
|
8
|
+
*
|
|
9
|
+
* Everything the browser needs to read a session live and push
|
|
10
|
+
* optimistically, with no framework attached: the SSE subscription with
|
|
11
|
+
* frontier resume and reconnection, the optimistic push queue with
|
|
12
|
+
* ack/rollback, and the local fold through the same reducer the server
|
|
13
|
+
* uses. `experimental-a2/react`'s `createReact` is a thin binding over it, and the
|
|
14
|
+
* store contract (`subscribe`/`getSnapshot`) is exactly what
|
|
15
|
+
* `useSyncExternalStore` wants.
|
|
16
|
+
*
|
|
17
|
+
* The client is a replica, never an access path — the routes it talks
|
|
18
|
+
* to authorize every read and write.
|
|
19
|
+
*/
|
|
20
|
+
const PUSH_ATTEMPTS = 3;
|
|
21
|
+
const DEFAULT_GC_TIME_MS = 3e5;
|
|
22
|
+
const DEFAULT_HISTORY_LIMIT = 50;
|
|
23
|
+
const httpTransport = (routes, fetchImpl, presence) => ({
|
|
24
|
+
async *connect({ sessionId, startAfter, signal }) {
|
|
25
|
+
const sep = routes.stream.includes("?") ? "&" : "?";
|
|
26
|
+
const res = await fetchImpl(`${routes.stream}${sep}sessionId=${encodeURIComponent(sessionId)}&index=${startAfter}`, {
|
|
27
|
+
headers: { accept: "text/event-stream" },
|
|
28
|
+
signal
|
|
29
|
+
});
|
|
30
|
+
if (!res.ok || !res.body) throw new Error(`stream failed with ${res.status}`);
|
|
31
|
+
yield { kind: "ping" };
|
|
32
|
+
const decoder = new TextDecoder();
|
|
33
|
+
const reader = res.body.getReader();
|
|
34
|
+
let buffer = "";
|
|
35
|
+
try {
|
|
36
|
+
for (;;) {
|
|
37
|
+
const { done, value } = await reader.read();
|
|
38
|
+
if (done) return;
|
|
39
|
+
yield { kind: "ping" };
|
|
40
|
+
buffer += decoder.decode(value, { stream: true });
|
|
41
|
+
for (;;) {
|
|
42
|
+
const boundary = buffer.indexOf("\n\n");
|
|
43
|
+
if (boundary === -1) break;
|
|
44
|
+
const frame = buffer.slice(0, boundary);
|
|
45
|
+
buffer = buffer.slice(boundary + 2);
|
|
46
|
+
let eventName;
|
|
47
|
+
const dataLines = [];
|
|
48
|
+
for (const line of frame.split("\n")) if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
|
|
49
|
+
else if (line.startsWith("event:")) eventName = line.slice(6).trim();
|
|
50
|
+
const data = dataLines.join("\n");
|
|
51
|
+
if (!data) continue;
|
|
52
|
+
if (eventName === void 0) {
|
|
53
|
+
const parsed = JSON.parse(data);
|
|
54
|
+
if (isWireEvent(parsed)) yield {
|
|
55
|
+
kind: "event",
|
|
56
|
+
event: parsed
|
|
57
|
+
};
|
|
58
|
+
} else if (presence && eventName === "presence-snapshot") {
|
|
59
|
+
const parsed = JSON.parse(data);
|
|
60
|
+
if (isWirePresenceSnapshot(parsed)) yield {
|
|
61
|
+
kind: "presence-snapshot",
|
|
62
|
+
snapshot: parsed
|
|
63
|
+
};
|
|
64
|
+
} else if (presence && eventName === "presence") {
|
|
65
|
+
const parsed = JSON.parse(data);
|
|
66
|
+
if (isWirePresencePatch(parsed)) yield {
|
|
67
|
+
kind: "presence",
|
|
68
|
+
patch: parsed
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} finally {
|
|
74
|
+
reader.cancel().catch(() => {});
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
async push(body) {
|
|
78
|
+
const res = await fetchImpl(routes.push, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { "content-type": "application/json" },
|
|
81
|
+
body: JSON.stringify(body)
|
|
82
|
+
});
|
|
83
|
+
if (res.ok) {
|
|
84
|
+
const rows = await res.json();
|
|
85
|
+
if (!Array.isArray(rows) || !rows.every(isWireEvent)) throw new A2Error("STORE_UNAVAILABLE", "push ack was not a list of events");
|
|
86
|
+
return rows;
|
|
87
|
+
}
|
|
88
|
+
throw errorFromWire(await res.json().catch(() => null)) ?? new A2Error("STORE_UNAVAILABLE", `push failed with ${res.status}`);
|
|
89
|
+
},
|
|
90
|
+
async history({ sessionId, gte, lte }) {
|
|
91
|
+
const sep = routes.stream.includes("?") ? "&" : "?";
|
|
92
|
+
const res = await fetchImpl(`${routes.stream}${sep}sessionId=${encodeURIComponent(sessionId)}>e=${gte}<e=${lte}`, { headers: { accept: "application/json" } });
|
|
93
|
+
if (!res.ok) throw errorFromWire(await res.json().catch(() => null)) ?? new A2Error("STORE_UNAVAILABLE", `history failed with ${res.status}`);
|
|
94
|
+
const rows = await res.json();
|
|
95
|
+
if (!Array.isArray(rows) || !rows.every(isWireEvent)) throw new A2Error("STORE_UNAVAILABLE", "history response was not a list of events");
|
|
96
|
+
return rows;
|
|
97
|
+
},
|
|
98
|
+
sendPresence(envelope) {
|
|
99
|
+
(async () => {
|
|
100
|
+
try {
|
|
101
|
+
await (await fetchImpl(routes.push, {
|
|
102
|
+
method: "POST",
|
|
103
|
+
headers: { "content-type": "application/json" },
|
|
104
|
+
body: JSON.stringify(envelope)
|
|
105
|
+
})).arrayBuffer();
|
|
106
|
+
} catch {}
|
|
107
|
+
})();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
/** `WebSocket.OPEN` — fixed by the spec, identical in every runtime. */
|
|
111
|
+
const WS_OPEN = 1;
|
|
112
|
+
const wakeChannel = (channel) => {
|
|
113
|
+
channel.wake?.();
|
|
114
|
+
channel.wake = null;
|
|
115
|
+
};
|
|
116
|
+
const subscribeFrame = (sessions) => {
|
|
117
|
+
return JSON.stringify({
|
|
118
|
+
kind: "subscribe",
|
|
119
|
+
sessions
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
const wsTransport = (url, webSocketImpl, presence) => {
|
|
123
|
+
const channels = /* @__PURE__ */ new Map();
|
|
124
|
+
const pending = /* @__PURE__ */ new Map();
|
|
125
|
+
/** Push retries parked in `awaitReady`, flushed per session on its
|
|
126
|
+
* subscribe confirmation. */
|
|
127
|
+
const readyWaiters = /* @__PURE__ */ new Map();
|
|
128
|
+
let socket = null;
|
|
129
|
+
let socketOpen = false;
|
|
130
|
+
let watchdog;
|
|
131
|
+
let nextReq = 1;
|
|
132
|
+
const flushReadyWaiters = (sessionId) => {
|
|
133
|
+
const waiters = readyWaiters.get(sessionId);
|
|
134
|
+
if (!waiters) return;
|
|
135
|
+
readyWaiters.delete(sessionId);
|
|
136
|
+
for (const waiter of waiters) waiter();
|
|
137
|
+
};
|
|
138
|
+
const socketUrl = () => {
|
|
139
|
+
let resolved;
|
|
140
|
+
if (/^(https?|wss?):\/\//i.test(url)) resolved = new URL(url);
|
|
141
|
+
else {
|
|
142
|
+
const base = globalThis.location?.href;
|
|
143
|
+
if (base === void 0) throw new TypeError(`cannot resolve the relative ws url '${url}' without a browser location — pass an absolute ws:// or wss:// url`);
|
|
144
|
+
resolved = new URL(url, base);
|
|
145
|
+
}
|
|
146
|
+
if (resolved.protocol === "http:") resolved.protocol = "ws:";
|
|
147
|
+
else if (resolved.protocol === "https:") resolved.protocol = "wss:";
|
|
148
|
+
return resolved.href;
|
|
149
|
+
};
|
|
150
|
+
const trySend = (data) => {
|
|
151
|
+
if (socket === null || !socketOpen) return;
|
|
152
|
+
try {
|
|
153
|
+
socket.send(data);
|
|
154
|
+
} catch {}
|
|
155
|
+
};
|
|
156
|
+
const deliver = (channel, frame) => {
|
|
157
|
+
channel.queue.push(frame);
|
|
158
|
+
wakeChannel(channel);
|
|
159
|
+
};
|
|
160
|
+
const settleChannel = (channel, failure) => {
|
|
161
|
+
if (channel.ended) return;
|
|
162
|
+
channel.ended = true;
|
|
163
|
+
channel.failure = failure;
|
|
164
|
+
wakeChannel(channel);
|
|
165
|
+
};
|
|
166
|
+
const releaseSocket = () => {
|
|
167
|
+
const current = socket;
|
|
168
|
+
socket = null;
|
|
169
|
+
socketOpen = false;
|
|
170
|
+
clearTimeout(watchdog);
|
|
171
|
+
watchdog = void 0;
|
|
172
|
+
if (pending.size > 0) {
|
|
173
|
+
const rejection = new A2Error("STORE_UNAVAILABLE", "socket closed with the ack outstanding");
|
|
174
|
+
for (const waiter of pending.values()) waiter.reject(rejection);
|
|
175
|
+
pending.clear();
|
|
176
|
+
}
|
|
177
|
+
return current;
|
|
178
|
+
};
|
|
179
|
+
const failSocket = (failure) => {
|
|
180
|
+
const current = releaseSocket();
|
|
181
|
+
for (const channel of channels.values()) settleChannel(channel, failure);
|
|
182
|
+
try {
|
|
183
|
+
current?.close(1e3);
|
|
184
|
+
} catch {}
|
|
185
|
+
};
|
|
186
|
+
const detach = (channel) => {
|
|
187
|
+
if (channels.get(channel.sessionId) !== channel) return;
|
|
188
|
+
channels.delete(channel.sessionId);
|
|
189
|
+
if (!channel.dropped) {
|
|
190
|
+
const frame = {
|
|
191
|
+
kind: "unsubscribe",
|
|
192
|
+
sessions: [channel.sessionId]
|
|
193
|
+
};
|
|
194
|
+
trySend(JSON.stringify(frame));
|
|
195
|
+
}
|
|
196
|
+
if (channels.size === 0) {
|
|
197
|
+
const current = releaseSocket();
|
|
198
|
+
try {
|
|
199
|
+
current?.close(1e3);
|
|
200
|
+
} catch {}
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
const armWatchdog = (created) => {
|
|
204
|
+
clearTimeout(watchdog);
|
|
205
|
+
watchdog = setTimeout(() => {
|
|
206
|
+
if (socket !== created) return;
|
|
207
|
+
failSocket(/* @__PURE__ */ new Error(`socket stalled: no data for ${STREAM_TIMINGS.stallTimeoutMs}ms`));
|
|
208
|
+
}, STREAM_TIMINGS.stallTimeoutMs);
|
|
209
|
+
watchdog.unref?.();
|
|
210
|
+
};
|
|
211
|
+
const route = (frame) => {
|
|
212
|
+
switch (frame.kind) {
|
|
213
|
+
case "ping":
|
|
214
|
+
for (const channel of channels.values()) if (channel.live && !channel.ended) deliver(channel, { kind: "ping" });
|
|
215
|
+
return;
|
|
216
|
+
case "subscribed": {
|
|
217
|
+
const channel = channels.get(frame.sessionId);
|
|
218
|
+
if (channel === void 0 || channel.ended || channel.live) return;
|
|
219
|
+
channel.live = true;
|
|
220
|
+
deliver(channel, { kind: "ping" });
|
|
221
|
+
flushReadyWaiters(frame.sessionId);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
case "unsubscribed": {
|
|
225
|
+
const channel = channels.get(frame.sessionId);
|
|
226
|
+
if (channel === void 0) return;
|
|
227
|
+
channel.dropped = true;
|
|
228
|
+
settleChannel(channel, frame.reason === void 0 ? null : new Error(frame.reason));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
case "ack": {
|
|
232
|
+
const waiter = pending.get(frame.req);
|
|
233
|
+
if (waiter === void 0) return;
|
|
234
|
+
pending.delete(frame.req);
|
|
235
|
+
if ("events" in frame) waiter.resolve(frame.events);
|
|
236
|
+
else waiter.reject(frame.error);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
case "event": {
|
|
240
|
+
const channel = channels.get(frame.event.sessionId);
|
|
241
|
+
if (channel !== void 0 && channel.live && !channel.ended) deliver(channel, {
|
|
242
|
+
kind: "event",
|
|
243
|
+
event: frame.event
|
|
244
|
+
});
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
case "presence": {
|
|
248
|
+
if (!presence || frame.sessionId === void 0) return;
|
|
249
|
+
const channel = channels.get(frame.sessionId);
|
|
250
|
+
if (channel !== void 0 && channel.live && !channel.ended) deliver(channel, {
|
|
251
|
+
kind: "presence",
|
|
252
|
+
patch: frame.patch
|
|
253
|
+
});
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
case "presence-snapshot": {
|
|
257
|
+
if (!presence || frame.sessionId === void 0) return;
|
|
258
|
+
const channel = channels.get(frame.sessionId);
|
|
259
|
+
if (channel !== void 0 && channel.live && !channel.ended) deliver(channel, {
|
|
260
|
+
kind: "presence-snapshot",
|
|
261
|
+
snapshot: frame.snapshot
|
|
262
|
+
});
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
const ensureSocket = () => {
|
|
268
|
+
if (socket !== null) return;
|
|
269
|
+
if (webSocketImpl === void 0) throw new TypeError("no WebSocket implementation available — pass one via createClient({ webSocket }) where the global is missing");
|
|
270
|
+
const created = new webSocketImpl(socketUrl());
|
|
271
|
+
socket = created;
|
|
272
|
+
socketOpen = false;
|
|
273
|
+
armWatchdog(created);
|
|
274
|
+
created.addEventListener("open", () => {
|
|
275
|
+
if (socket !== created) return;
|
|
276
|
+
socketOpen = true;
|
|
277
|
+
armWatchdog(created);
|
|
278
|
+
const sessions = [...channels.values()].filter((channel) => !channel.ended).map((channel) => ({
|
|
279
|
+
id: channel.sessionId,
|
|
280
|
+
index: channel.startAfter
|
|
281
|
+
}));
|
|
282
|
+
if (sessions.length > 0) trySend(subscribeFrame(sessions));
|
|
283
|
+
});
|
|
284
|
+
created.addEventListener("message", (event) => {
|
|
285
|
+
if (socket !== created) return;
|
|
286
|
+
if (typeof event.data !== "string") return;
|
|
287
|
+
armWatchdog(created);
|
|
288
|
+
const frame = parseSocketFrame(event.data);
|
|
289
|
+
if (frame === null) return;
|
|
290
|
+
route(frame);
|
|
291
|
+
});
|
|
292
|
+
created.addEventListener("close", (event) => {
|
|
293
|
+
if (socket !== created) return;
|
|
294
|
+
failSocket(event.code === 1e3 ? null : /* @__PURE__ */ new Error(`socket closed (${event.code}${event.reason ? `: ${event.reason}` : ""})`));
|
|
295
|
+
});
|
|
296
|
+
created.addEventListener("error", () => {
|
|
297
|
+
if (socket !== created) return;
|
|
298
|
+
failSocket(/* @__PURE__ */ new Error("socket error"));
|
|
299
|
+
});
|
|
300
|
+
};
|
|
301
|
+
return {
|
|
302
|
+
async *connect({ sessionId, startAfter, signal }) {
|
|
303
|
+
const channel = {
|
|
304
|
+
sessionId,
|
|
305
|
+
startAfter,
|
|
306
|
+
queue: [],
|
|
307
|
+
live: false,
|
|
308
|
+
dropped: false,
|
|
309
|
+
ended: false,
|
|
310
|
+
failure: null,
|
|
311
|
+
wake: null
|
|
312
|
+
};
|
|
313
|
+
channels.set(sessionId, channel);
|
|
314
|
+
try {
|
|
315
|
+
ensureSocket();
|
|
316
|
+
if (socketOpen) trySend(subscribeFrame([{
|
|
317
|
+
id: sessionId,
|
|
318
|
+
index: startAfter
|
|
319
|
+
}]));
|
|
320
|
+
} catch (error) {
|
|
321
|
+
if (channels.get(sessionId) === channel) channels.delete(sessionId);
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
const onAbort = () => {
|
|
325
|
+
settleChannel(channel, signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("stream aborted"));
|
|
326
|
+
detach(channel);
|
|
327
|
+
};
|
|
328
|
+
if (signal.aborted) onAbort();
|
|
329
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
330
|
+
const pendingSubscribe = setTimeout(() => {
|
|
331
|
+
if (channel.live) return;
|
|
332
|
+
settleChannel(channel, /* @__PURE__ */ new Error("subscribe not confirmed: is the ws route serving the multiplexed socket (handle with options.upgrade)?"));
|
|
333
|
+
}, STREAM_TIMINGS.stallTimeoutMs);
|
|
334
|
+
pendingSubscribe.unref?.();
|
|
335
|
+
try {
|
|
336
|
+
for (;;) {
|
|
337
|
+
while (channel.queue.length > 0) yield channel.queue.shift();
|
|
338
|
+
if (channel.ended) {
|
|
339
|
+
if (channel.failure) throw channel.failure;
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
await new Promise((resolve) => {
|
|
343
|
+
channel.wake = resolve;
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
} finally {
|
|
347
|
+
clearTimeout(pendingSubscribe);
|
|
348
|
+
signal.removeEventListener("abort", onAbort);
|
|
349
|
+
settleChannel(channel, null);
|
|
350
|
+
detach(channel);
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
async push(body) {
|
|
354
|
+
const channel = channels.get(body.sessionId);
|
|
355
|
+
const target = socket;
|
|
356
|
+
if (channel === void 0 || !channel.live || channel.ended || target === null || !socketOpen || target.readyState !== WS_OPEN) throw new A2Error("STORE_UNAVAILABLE", "no open socket for the session");
|
|
357
|
+
const req = nextReq;
|
|
358
|
+
nextReq += 1;
|
|
359
|
+
return await new Promise((resolve, reject) => {
|
|
360
|
+
pending.set(req, {
|
|
361
|
+
resolve,
|
|
362
|
+
reject
|
|
363
|
+
});
|
|
364
|
+
const frame = {
|
|
365
|
+
kind: "push",
|
|
366
|
+
req,
|
|
367
|
+
sessionId: body.sessionId,
|
|
368
|
+
events: body.events
|
|
369
|
+
};
|
|
370
|
+
try {
|
|
371
|
+
target.send(JSON.stringify(frame));
|
|
372
|
+
} catch (cause) {
|
|
373
|
+
pending.delete(req);
|
|
374
|
+
reject(new A2Error("STORE_UNAVAILABLE", "socket send failed", { cause }));
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
},
|
|
378
|
+
awaitReady(sessionId, maxWaitMs) {
|
|
379
|
+
const channel = channels.get(sessionId);
|
|
380
|
+
if (channel !== void 0 && channel.live && !channel.ended && socketOpen) return Promise.resolve();
|
|
381
|
+
return new Promise((resolve) => {
|
|
382
|
+
let waiters = readyWaiters.get(sessionId);
|
|
383
|
+
if (!waiters) {
|
|
384
|
+
waiters = /* @__PURE__ */ new Set();
|
|
385
|
+
readyWaiters.set(sessionId, waiters);
|
|
386
|
+
}
|
|
387
|
+
let settled = false;
|
|
388
|
+
const done = () => {
|
|
389
|
+
if (settled) return;
|
|
390
|
+
settled = true;
|
|
391
|
+
clearTimeout(timer);
|
|
392
|
+
waiters.delete(done);
|
|
393
|
+
if (waiters.size === 0) readyWaiters.delete(sessionId);
|
|
394
|
+
resolve();
|
|
395
|
+
};
|
|
396
|
+
const timer = setTimeout(done, maxWaitMs);
|
|
397
|
+
timer.unref?.();
|
|
398
|
+
waiters.add(done);
|
|
399
|
+
});
|
|
400
|
+
},
|
|
401
|
+
sendPresence(envelope) {
|
|
402
|
+
const channel = channels.get(envelope.sessionId);
|
|
403
|
+
if (channel === void 0 || !channel.live || channel.ended) return;
|
|
404
|
+
const frame = {
|
|
405
|
+
kind: "presence",
|
|
406
|
+
sessionId: envelope.sessionId,
|
|
407
|
+
...envelope.presence
|
|
408
|
+
};
|
|
409
|
+
trySend(JSON.stringify(frame));
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
};
|
|
413
|
+
const transportFor = (api, fetchImpl, webSocketImpl, presence) => {
|
|
414
|
+
if (typeof api === "string") return httpTransport({
|
|
415
|
+
push: api,
|
|
416
|
+
stream: api
|
|
417
|
+
}, fetchImpl, presence);
|
|
418
|
+
if (api.type === "http") return httpTransport({
|
|
419
|
+
push: api.push,
|
|
420
|
+
stream: api.stream
|
|
421
|
+
}, fetchImpl, presence);
|
|
422
|
+
return wsTransport(api.url, webSocketImpl, presence);
|
|
423
|
+
};
|
|
424
|
+
function createClient(options) {
|
|
425
|
+
const { reducer, api } = options;
|
|
426
|
+
const presenceDefs = reducer.presence;
|
|
427
|
+
const declaresPresence = Object.keys(presenceDefs).length > 0;
|
|
428
|
+
const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
429
|
+
const webSocketImpl = options.webSocket ?? globalThis.WebSocket;
|
|
430
|
+
const transport = transportFor(api, fetchImpl, webSocketImpl, declaresPresence);
|
|
431
|
+
const gcTime = options.gcTime ?? DEFAULT_GC_TIME_MS;
|
|
432
|
+
if (Number.isNaN(gcTime) || gcTime < 0) throw new RangeError("gcTime must be a non-negative number or Infinity");
|
|
433
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
434
|
+
const scheduleGc = (sessionId, delay = gcTime) => {
|
|
435
|
+
const entry = sessions.get(sessionId);
|
|
436
|
+
if (!entry) return;
|
|
437
|
+
clearTimeout(entry.timer);
|
|
438
|
+
if (gcTime === Infinity) return;
|
|
439
|
+
entry.timer = setTimeout(() => {
|
|
440
|
+
if (sessions.get(sessionId) !== entry) return;
|
|
441
|
+
if (entry.runtime.canEvict()) {
|
|
442
|
+
sessions.delete(sessionId);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
scheduleGc(sessionId, Math.max(gcTime, 1e3));
|
|
446
|
+
}, delay);
|
|
447
|
+
entry.timer.unref?.();
|
|
448
|
+
};
|
|
449
|
+
const touch = (sessionId) => {
|
|
450
|
+
scheduleGc(sessionId);
|
|
451
|
+
};
|
|
452
|
+
const makeSession = (sessionId, sessionOptions) => {
|
|
453
|
+
let frontier = sessionOptions?.initialIndex ?? 0;
|
|
454
|
+
let foldedState = sessionOptions?.initialState !== void 0 ? sessionOptions.initialState : reducer.initialState;
|
|
455
|
+
const serverEvents = (sessionOptions?.initialEvents ?? []).filter((event) => event.index <= frontier).toSorted((a, b) => a.index - b.index);
|
|
456
|
+
let pending = [];
|
|
457
|
+
let participant = sessionOptions?.participant;
|
|
458
|
+
let presenceState = nullProtoRecord();
|
|
459
|
+
let presenceLastSentAt = 0;
|
|
460
|
+
/** The last LWW stamp issued — see `nextPresenceAt`. */
|
|
461
|
+
let presenceLastAt = 0;
|
|
462
|
+
/** Values awaiting the trailing send — non-null exactly while
|
|
463
|
+
* `presenceTimer` is armed. */
|
|
464
|
+
let presenceBuffer = null;
|
|
465
|
+
let presenceTimer;
|
|
466
|
+
/** Own fields set while the stream was down — re-sent once on
|
|
467
|
+
* reconnect (their POSTs may have been lost with the network). */
|
|
468
|
+
let presenceResend = null;
|
|
469
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
470
|
+
let snapshot = null;
|
|
471
|
+
let status = "idle";
|
|
472
|
+
let reconnects = 0;
|
|
473
|
+
let lastError = null;
|
|
474
|
+
let inFlightPushes = 0;
|
|
475
|
+
let pushWireTail = Promise.resolve();
|
|
476
|
+
let deferredHydration;
|
|
477
|
+
let hydrationNotification;
|
|
478
|
+
const emit = () => {
|
|
479
|
+
for (const listener of [...listeners]) listener();
|
|
480
|
+
};
|
|
481
|
+
const notify = () => {
|
|
482
|
+
snapshot = null;
|
|
483
|
+
emit();
|
|
484
|
+
};
|
|
485
|
+
const notifyHydrated = () => {
|
|
486
|
+
snapshot = null;
|
|
487
|
+
clearTimeout(hydrationNotification);
|
|
488
|
+
hydrationNotification = setTimeout(emit, 0);
|
|
489
|
+
hydrationNotification.unref?.();
|
|
490
|
+
};
|
|
491
|
+
const mergeServerEvents = (events, throughIndex) => {
|
|
492
|
+
if (!events || events.length === 0) return false;
|
|
493
|
+
const byIndex = new Map(serverEvents.map((event) => [event.index, event]));
|
|
494
|
+
let changed = false;
|
|
495
|
+
for (const event of events) {
|
|
496
|
+
if (event.index > throughIndex) continue;
|
|
497
|
+
if (byIndex.get(event.index)?.id === event.id) continue;
|
|
498
|
+
byIndex.set(event.index, event);
|
|
499
|
+
changed = true;
|
|
500
|
+
}
|
|
501
|
+
if (!changed) return false;
|
|
502
|
+
serverEvents.splice(0, serverEvents.length, ...[...byIndex.values()].toSorted((a, b) => a.index - b.index));
|
|
503
|
+
return true;
|
|
504
|
+
};
|
|
505
|
+
const overlayEvents = () => {
|
|
506
|
+
const acked = pending.filter((p) => p.acked).map((p) => p.acked).toSorted((a, b) => a.index - b.index);
|
|
507
|
+
const maxKnown = acked.at(-1)?.index ?? frontier;
|
|
508
|
+
const unacked = pending.filter((p) => !p.acked).map((p, i) => ({
|
|
509
|
+
id: p.id,
|
|
510
|
+
type: p.type,
|
|
511
|
+
payload: p.payload,
|
|
512
|
+
index: maxKnown + 1 + i,
|
|
513
|
+
sessionId,
|
|
514
|
+
createdAt: p.createdAt
|
|
515
|
+
}));
|
|
516
|
+
return [...acked, ...unacked];
|
|
517
|
+
};
|
|
518
|
+
const connection = () => {
|
|
519
|
+
switch (status) {
|
|
520
|
+
case "idle": return { status: "idle" };
|
|
521
|
+
case "closed": return { status: "closed" };
|
|
522
|
+
case "live": return {
|
|
523
|
+
status: "live",
|
|
524
|
+
reconnects
|
|
525
|
+
};
|
|
526
|
+
case "connecting": return {
|
|
527
|
+
status: "connecting",
|
|
528
|
+
reconnects,
|
|
529
|
+
error: lastError
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
const historyState = () => {
|
|
534
|
+
const oldestLoaded = serverEvents[0]?.index ?? null;
|
|
535
|
+
return {
|
|
536
|
+
loading: historyLoads > 0,
|
|
537
|
+
complete: oldestLoaded === 1 || frontier === 0,
|
|
538
|
+
oldestLoaded
|
|
539
|
+
};
|
|
540
|
+
};
|
|
541
|
+
const buildSnapshot = () => {
|
|
542
|
+
const overlay = overlayEvents();
|
|
543
|
+
let state = foldedState;
|
|
544
|
+
for (const event of overlay) state = reducer.fold(state, event);
|
|
545
|
+
return {
|
|
546
|
+
state,
|
|
547
|
+
events: [...serverEvents, ...overlay],
|
|
548
|
+
index: frontier,
|
|
549
|
+
history: historyState(),
|
|
550
|
+
connection: connection(),
|
|
551
|
+
...declaresPresence ? { presence: presenceState } : {}
|
|
552
|
+
};
|
|
553
|
+
};
|
|
554
|
+
/**
|
|
555
|
+
* The per-session monotonic LWW stamp: wall-seeded, guarded
|
|
556
|
+
* `max(now, last + 1)`, so overlapping sends can never invert —
|
|
557
|
+
* the wall clock seeds it because it is the only monotonic state
|
|
558
|
+
* that survives a reload without A2 owning persistence.
|
|
559
|
+
*/
|
|
560
|
+
const nextPresenceAt = () => {
|
|
561
|
+
presenceLastAt = Math.max(Date.now(), presenceLastAt + 1);
|
|
562
|
+
return presenceLastAt;
|
|
563
|
+
};
|
|
564
|
+
const applyPresencePatch = (patch) => {
|
|
565
|
+
if (patch.participant === participant) return;
|
|
566
|
+
const entry = Object.assign(nullProtoRecord(), presenceState[patch.participant]);
|
|
567
|
+
let changed = false;
|
|
568
|
+
for (const [field, value] of Object.entries(patch.values)) {
|
|
569
|
+
if (value === void 0) continue;
|
|
570
|
+
const existing = entry[field];
|
|
571
|
+
if (existing && existing.at.getTime() > patch.at.getTime()) continue;
|
|
572
|
+
if (value === null) {
|
|
573
|
+
if (existing) {
|
|
574
|
+
delete entry[field];
|
|
575
|
+
changed = true;
|
|
576
|
+
}
|
|
577
|
+
} else {
|
|
578
|
+
entry[field] = {
|
|
579
|
+
value,
|
|
580
|
+
seen: patch.seen,
|
|
581
|
+
at: patch.at
|
|
582
|
+
};
|
|
583
|
+
changed = true;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (!changed) return;
|
|
587
|
+
const next = Object.assign(nullProtoRecord(), presenceState);
|
|
588
|
+
if (Object.keys(entry).length === 0) delete next[patch.participant];
|
|
589
|
+
else next[patch.participant] = entry;
|
|
590
|
+
presenceState = next;
|
|
591
|
+
notify();
|
|
592
|
+
};
|
|
593
|
+
const applyPresenceSnapshot = (incoming) => {
|
|
594
|
+
const next = nullProtoRecord();
|
|
595
|
+
for (const [id, fields] of Object.entries(incoming)) {
|
|
596
|
+
if (id === participant) continue;
|
|
597
|
+
next[id] = fields;
|
|
598
|
+
}
|
|
599
|
+
const own = participant === void 0 ? void 0 : presenceState[participant];
|
|
600
|
+
if (participant !== void 0 && own !== void 0) next[participant] = own;
|
|
601
|
+
presenceState = next;
|
|
602
|
+
notify();
|
|
603
|
+
};
|
|
604
|
+
const echoPresence = (id, values, atMs) => {
|
|
605
|
+
const entry = Object.assign(nullProtoRecord(), presenceState[id]);
|
|
606
|
+
const at = new Date(atMs);
|
|
607
|
+
for (const [field, value] of Object.entries(values)) if (value === null) delete entry[field];
|
|
608
|
+
else entry[field] = {
|
|
609
|
+
value,
|
|
610
|
+
seen: frontier,
|
|
611
|
+
at
|
|
612
|
+
};
|
|
613
|
+
const next = Object.assign(nullProtoRecord(), presenceState);
|
|
614
|
+
if (Object.keys(entry).length === 0) delete next[id];
|
|
615
|
+
else next[id] = entry;
|
|
616
|
+
presenceState = next;
|
|
617
|
+
notify();
|
|
618
|
+
};
|
|
619
|
+
const sendPresence = (values, at) => {
|
|
620
|
+
presenceLastSentAt = Date.now();
|
|
621
|
+
transport.sendPresence({
|
|
622
|
+
sessionId,
|
|
623
|
+
presence: {
|
|
624
|
+
participant,
|
|
625
|
+
values,
|
|
626
|
+
seen: frontier,
|
|
627
|
+
at
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
touch(sessionId);
|
|
631
|
+
};
|
|
632
|
+
const queuePresenceSend = (values, at) => {
|
|
633
|
+
if (presenceTimer !== void 0) {
|
|
634
|
+
presenceBuffer = {
|
|
635
|
+
...presenceBuffer,
|
|
636
|
+
...values
|
|
637
|
+
};
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
const wait = presenceLastSentAt + PRESENCE_TIMINGS.sendIntervalMs - Date.now();
|
|
641
|
+
if (wait <= 0) {
|
|
642
|
+
sendPresence(values, at ?? nextPresenceAt());
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
presenceBuffer = {
|
|
646
|
+
...presenceBuffer,
|
|
647
|
+
...values
|
|
648
|
+
};
|
|
649
|
+
presenceTimer = setTimeout(() => {
|
|
650
|
+
presenceTimer = void 0;
|
|
651
|
+
const buffered = presenceBuffer;
|
|
652
|
+
presenceBuffer = null;
|
|
653
|
+
if (buffered) sendPresence(buffered, nextPresenceAt());
|
|
654
|
+
}, wait);
|
|
655
|
+
presenceTimer.unref?.();
|
|
656
|
+
};
|
|
657
|
+
const setPresence = (values) => {
|
|
658
|
+
if (participant === void 0) throw new TypeError("setPresence requires a participant — pass one in the session options (the SessionProvider participant prop)");
|
|
659
|
+
const validated = nullProtoRecord();
|
|
660
|
+
for (const [field, value] of Object.entries(values)) {
|
|
661
|
+
if (value === void 0) continue;
|
|
662
|
+
const schema = Object.hasOwn(presenceDefs, field) ? presenceDefs[field] : void 0;
|
|
663
|
+
if (!schema) throw new A2Error("UNKNOWN_PRESENCE_FIELD", `no presence field '${field}' in the reducer's vocabulary`);
|
|
664
|
+
if (value === null) {
|
|
665
|
+
validated[field] = null;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
const result = validateSync(schema, value, `presence field '${field}'`);
|
|
669
|
+
if (result.issues) throw new A2Error("INVALID_PAYLOAD", `invalid value for presence field '${field}'`, { details: result.issues });
|
|
670
|
+
validated[field] = result.value;
|
|
671
|
+
}
|
|
672
|
+
if (Object.keys(validated).length === 0) return;
|
|
673
|
+
const at = nextPresenceAt();
|
|
674
|
+
echoPresence(participant, validated, at);
|
|
675
|
+
if (status !== "live") presenceResend = {
|
|
676
|
+
...presenceResend,
|
|
677
|
+
...validated
|
|
678
|
+
};
|
|
679
|
+
queuePresenceSend(validated, at);
|
|
680
|
+
touch(sessionId);
|
|
681
|
+
};
|
|
682
|
+
/** Pushes awaiting stream confirmation — resolved by `ingest` the
|
|
683
|
+
* moment the frontier passes their batch. */
|
|
684
|
+
let confirmWatchers = [];
|
|
685
|
+
/** The single ingest point: every server-confirmed event, in log
|
|
686
|
+
* order, from the stream. */
|
|
687
|
+
const ingest = (event) => {
|
|
688
|
+
if (event.index <= frontier) return;
|
|
689
|
+
frontier = event.index;
|
|
690
|
+
serverEvents.push(event);
|
|
691
|
+
foldedState = reducer.fold(foldedState, event);
|
|
692
|
+
pending = pending.filter((p) => p.id !== event.id);
|
|
693
|
+
if (confirmWatchers.some((w) => w.index <= frontier)) {
|
|
694
|
+
const due = confirmWatchers.filter((w) => w.index <= frontier);
|
|
695
|
+
confirmWatchers = confirmWatchers.filter((w) => w.index > frontier);
|
|
696
|
+
for (const watcher of due) watcher.resolve();
|
|
697
|
+
}
|
|
698
|
+
notify();
|
|
699
|
+
touch(sessionId);
|
|
700
|
+
};
|
|
701
|
+
const validated = (events) => events.map((event) => {
|
|
702
|
+
const schema = Object.hasOwn(reducer.events, event.type) ? reducer.events[event.type] : void 0;
|
|
703
|
+
if (!schema) throw new A2Error("UNKNOWN_EVENT_TYPE", `no event type '${String(event.type)}' in the reducer's vocabulary`);
|
|
704
|
+
const result = validateSync(schema, event.payload, `event '${String(event.type)}'`);
|
|
705
|
+
if (result.issues) throw new A2Error("INVALID_PAYLOAD", `invalid payload for event '${String(event.type)}'`, { details: result.issues });
|
|
706
|
+
return {
|
|
707
|
+
id: event.id ?? crypto.randomUUID(),
|
|
708
|
+
type: event.type,
|
|
709
|
+
payload: result.value
|
|
710
|
+
};
|
|
711
|
+
});
|
|
712
|
+
const post = async (body) => {
|
|
713
|
+
let lastPushError = new A2Error("STORE_UNAVAILABLE", "push failed");
|
|
714
|
+
for (let attempt = 1; attempt <= PUSH_ATTEMPTS; attempt += 1) {
|
|
715
|
+
try {
|
|
716
|
+
return (await transport.push(body)).map((row) => eventFromWire(row));
|
|
717
|
+
} catch (err) {
|
|
718
|
+
lastPushError = err instanceof A2Error ? err : new A2Error("STORE_UNAVAILABLE", "push request failed", { cause: err });
|
|
719
|
+
}
|
|
720
|
+
if (lastPushError.code !== "STORE_UNAVAILABLE") throw lastPushError;
|
|
721
|
+
if (attempt < PUSH_ATTEMPTS) {
|
|
722
|
+
const sleepMs = 250 * attempt;
|
|
723
|
+
await Promise.all([new Promise((resolve) => setTimeout(resolve, sleepMs)), transport.awaitReady?.(sessionId, sleepMs + STREAM_TIMINGS.reconnectBaseMs * attempt)]);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
throw lastPushError;
|
|
727
|
+
};
|
|
728
|
+
/**
|
|
729
|
+
* Decorate the ack promise into a PushResult. `confirmed` is a
|
|
730
|
+
* lazy getter — materialized on first access, so callers that
|
|
731
|
+
* ignore it can't leak an unhandled rejection.
|
|
732
|
+
*/
|
|
733
|
+
const withConfirmed = (ack) => {
|
|
734
|
+
let confirmed;
|
|
735
|
+
return Object.defineProperty(ack, "confirmed", { get() {
|
|
736
|
+
confirmed ??= ack.then((acked) => new Promise((resolve) => {
|
|
737
|
+
const last = acked.at(-1)?.index ?? 0;
|
|
738
|
+
if (frontier >= last) {
|
|
739
|
+
resolve(acked);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
confirmWatchers.push({
|
|
743
|
+
index: last,
|
|
744
|
+
resolve: () => resolve(acked)
|
|
745
|
+
});
|
|
746
|
+
}));
|
|
747
|
+
return confirmed;
|
|
748
|
+
} });
|
|
749
|
+
};
|
|
750
|
+
const push = (...events) => {
|
|
751
|
+
touch(sessionId);
|
|
752
|
+
return withConfirmed((async () => {
|
|
753
|
+
if (events.length === 0) throw new TypeError("push requires at least one event");
|
|
754
|
+
const entries = validated(events).map((e) => ({
|
|
755
|
+
id: e.id,
|
|
756
|
+
type: e.type,
|
|
757
|
+
payload: e.payload,
|
|
758
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
759
|
+
}));
|
|
760
|
+
const waitForPriorPush = pushWireTail;
|
|
761
|
+
let releasePush;
|
|
762
|
+
pushWireTail = new Promise((resolve) => {
|
|
763
|
+
releasePush = resolve;
|
|
764
|
+
});
|
|
765
|
+
pending.push(...entries);
|
|
766
|
+
inFlightPushes += 1;
|
|
767
|
+
try {
|
|
768
|
+
notify();
|
|
769
|
+
await waitForPriorPush;
|
|
770
|
+
const acked = await post({
|
|
771
|
+
sessionId,
|
|
772
|
+
events: entries.map(({ id, type, payload }) => ({
|
|
773
|
+
id,
|
|
774
|
+
type,
|
|
775
|
+
payload
|
|
776
|
+
}))
|
|
777
|
+
});
|
|
778
|
+
for (const event of acked) {
|
|
779
|
+
const entry = pending.find((p) => p.id === event.id);
|
|
780
|
+
if (entry) entry.acked = event;
|
|
781
|
+
}
|
|
782
|
+
notify();
|
|
783
|
+
return acked;
|
|
784
|
+
} catch (err) {
|
|
785
|
+
const ids = new Set(entries.map((e) => e.id));
|
|
786
|
+
pending = pending.filter((p) => !ids.has(p.id));
|
|
787
|
+
notify();
|
|
788
|
+
throw err;
|
|
789
|
+
} finally {
|
|
790
|
+
releasePush();
|
|
791
|
+
inFlightPushes -= 1;
|
|
792
|
+
if (inFlightPushes === 0 && deferredHydration) {
|
|
793
|
+
const next = deferredHydration;
|
|
794
|
+
deferredHydration = void 0;
|
|
795
|
+
runtime.hydrate(next);
|
|
796
|
+
}
|
|
797
|
+
touch(sessionId);
|
|
798
|
+
}
|
|
799
|
+
})());
|
|
800
|
+
};
|
|
801
|
+
let historyLoads = 0;
|
|
802
|
+
/** Calls chain per session: each resolves its bounds after the
|
|
803
|
+
* prior call's merge, so overlapping backscrolls page backward
|
|
804
|
+
* instead of re-fetching the same range. */
|
|
805
|
+
let historyTail = Promise.resolve();
|
|
806
|
+
const eventsBetween = (gte, lte) => serverEvents.filter((event) => event.index >= gte && event.index <= lte);
|
|
807
|
+
/** The narrowest sub-range of [gte, lte] missing from the feed, or
|
|
808
|
+
* null when the range is fully loaded (nothing to fetch). */
|
|
809
|
+
const missingBetween = (gte, lte) => {
|
|
810
|
+
const present = new Set(eventsBetween(gte, lte).map((e) => e.index));
|
|
811
|
+
let lo = gte;
|
|
812
|
+
while (lo <= lte && present.has(lo)) lo += 1;
|
|
813
|
+
if (lo > lte) return null;
|
|
814
|
+
let hi = lte;
|
|
815
|
+
while (hi > lo && present.has(hi)) hi -= 1;
|
|
816
|
+
return {
|
|
817
|
+
gte: lo,
|
|
818
|
+
lte: hi
|
|
819
|
+
};
|
|
820
|
+
};
|
|
821
|
+
const loadHistory = (loadOptions) => {
|
|
822
|
+
const readHistory = transport.history;
|
|
823
|
+
if (readHistory === void 0) throw new TypeError("loadHistory requires an http api — the ws transport has no history lane; serve the session over handle() and pass its route url as the string or { type: \"http\" } api");
|
|
824
|
+
const before = loadOptions?.before;
|
|
825
|
+
const limit = loadOptions?.limit;
|
|
826
|
+
if (before !== void 0 && (!Number.isSafeInteger(before) || before < 1)) throw new TypeError("loadHistory before must be a positive safe integer");
|
|
827
|
+
if (limit !== void 0 && (!Number.isSafeInteger(limit) || limit < 1)) throw new TypeError("loadHistory limit must be a positive safe integer");
|
|
828
|
+
touch(sessionId);
|
|
829
|
+
const run = historyTail.then(async () => {
|
|
830
|
+
const upper = before ?? serverEvents[0]?.index ?? frontier + 1;
|
|
831
|
+
const lte = Math.min(upper - 1, frontier);
|
|
832
|
+
const gte = Math.max(1, upper - (limit ?? DEFAULT_HISTORY_LIMIT));
|
|
833
|
+
if (gte > lte) return [];
|
|
834
|
+
const missing = missingBetween(gte, lte);
|
|
835
|
+
if (missing === null) return eventsBetween(gte, lte);
|
|
836
|
+
historyLoads += 1;
|
|
837
|
+
notify();
|
|
838
|
+
try {
|
|
839
|
+
const rows = await readHistory({
|
|
840
|
+
sessionId,
|
|
841
|
+
...missing
|
|
842
|
+
});
|
|
843
|
+
mergeServerEvents(rows.map((row) => eventFromWire(row)), frontier);
|
|
844
|
+
return eventsBetween(gte, lte);
|
|
845
|
+
} finally {
|
|
846
|
+
historyLoads -= 1;
|
|
847
|
+
notify();
|
|
848
|
+
touch(sessionId);
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
historyTail = run.catch(() => {});
|
|
852
|
+
return run;
|
|
853
|
+
};
|
|
854
|
+
let generation = 0;
|
|
855
|
+
let active = false;
|
|
856
|
+
let abort = null;
|
|
857
|
+
const runStream = async (run) => {
|
|
858
|
+
let backoff = STREAM_TIMINGS.reconnectBaseMs;
|
|
859
|
+
while (generation === run) {
|
|
860
|
+
const controller = new AbortController();
|
|
861
|
+
abort = controller;
|
|
862
|
+
let stall;
|
|
863
|
+
let stalled = false;
|
|
864
|
+
const armStall = () => {
|
|
865
|
+
clearTimeout(stall);
|
|
866
|
+
stall = setTimeout(() => {
|
|
867
|
+
stalled = true;
|
|
868
|
+
controller.abort();
|
|
869
|
+
}, STREAM_TIMINGS.stallTimeoutMs);
|
|
870
|
+
stall.unref?.();
|
|
871
|
+
};
|
|
872
|
+
let wasLive = false;
|
|
873
|
+
try {
|
|
874
|
+
const frames = transport.connect({
|
|
875
|
+
sessionId,
|
|
876
|
+
startAfter: frontier,
|
|
877
|
+
signal: controller.signal
|
|
878
|
+
});
|
|
879
|
+
for await (const frame of frames) {
|
|
880
|
+
if (generation !== run) break;
|
|
881
|
+
armStall();
|
|
882
|
+
if (!wasLive) {
|
|
883
|
+
backoff = STREAM_TIMINGS.reconnectBaseMs;
|
|
884
|
+
wasLive = true;
|
|
885
|
+
status = "live";
|
|
886
|
+
lastError = null;
|
|
887
|
+
notify();
|
|
888
|
+
if (presenceResend) {
|
|
889
|
+
const values = presenceResend;
|
|
890
|
+
presenceResend = null;
|
|
891
|
+
queuePresenceSend(values);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
if (frame.kind === "event") ingest(eventFromWire(frame.event));
|
|
895
|
+
else if (frame.kind === "presence") applyPresencePatch(presencePatchFromWire(frame.patch));
|
|
896
|
+
else if (frame.kind === "presence-snapshot") applyPresenceSnapshot(presenceSnapshotFromWire(frame.snapshot).snapshot);
|
|
897
|
+
}
|
|
898
|
+
if (generation === run) lastError = null;
|
|
899
|
+
} catch (err) {
|
|
900
|
+
if (generation === run) lastError = stalled ? /* @__PURE__ */ new Error(`stream stalled: no data for ${STREAM_TIMINGS.stallTimeoutMs}ms`) : err instanceof Error ? err : new Error(String(err));
|
|
901
|
+
} finally {
|
|
902
|
+
clearTimeout(stall);
|
|
903
|
+
}
|
|
904
|
+
if (generation !== run) break;
|
|
905
|
+
if (wasLive) reconnects += 1;
|
|
906
|
+
status = "connecting";
|
|
907
|
+
notify();
|
|
908
|
+
await new Promise((resolve) => {
|
|
909
|
+
let settled = false;
|
|
910
|
+
const finish = () => {
|
|
911
|
+
if (settled) return;
|
|
912
|
+
settled = true;
|
|
913
|
+
clearTimeout(timer);
|
|
914
|
+
resolve();
|
|
915
|
+
};
|
|
916
|
+
const timer = setTimeout(finish, backoff);
|
|
917
|
+
timer.unref?.();
|
|
918
|
+
abort?.signal.addEventListener("abort", finish);
|
|
919
|
+
});
|
|
920
|
+
backoff = Math.min(backoff * 2, STREAM_TIMINGS.reconnectMaxMs);
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
const runtime = {
|
|
924
|
+
sessionId,
|
|
925
|
+
...declaresPresence ? { setPresence } : {},
|
|
926
|
+
subscribe(listener) {
|
|
927
|
+
touch(sessionId);
|
|
928
|
+
listeners.add(listener);
|
|
929
|
+
return () => {
|
|
930
|
+
listeners.delete(listener);
|
|
931
|
+
touch(sessionId);
|
|
932
|
+
};
|
|
933
|
+
},
|
|
934
|
+
getSnapshot() {
|
|
935
|
+
snapshot ??= buildSnapshot();
|
|
936
|
+
return snapshot;
|
|
937
|
+
},
|
|
938
|
+
push,
|
|
939
|
+
loadHistory,
|
|
940
|
+
connect() {
|
|
941
|
+
touch(sessionId);
|
|
942
|
+
if (active) return;
|
|
943
|
+
active = true;
|
|
944
|
+
status = "connecting";
|
|
945
|
+
notify();
|
|
946
|
+
runStream(generation);
|
|
947
|
+
},
|
|
948
|
+
close() {
|
|
949
|
+
touch(sessionId);
|
|
950
|
+
if (!active) return;
|
|
951
|
+
active = false;
|
|
952
|
+
generation += 1;
|
|
953
|
+
abort?.abort();
|
|
954
|
+
abort = null;
|
|
955
|
+
clearTimeout(presenceTimer);
|
|
956
|
+
presenceTimer = void 0;
|
|
957
|
+
presenceBuffer = null;
|
|
958
|
+
status = "closed";
|
|
959
|
+
notify();
|
|
960
|
+
},
|
|
961
|
+
hydrate(next) {
|
|
962
|
+
touch(sessionId);
|
|
963
|
+
if (!next) return;
|
|
964
|
+
participant ??= next.participant;
|
|
965
|
+
const nextIndex = next.initialIndex ?? 0;
|
|
966
|
+
if (nextIndex > frontier && inFlightPushes > 0) {
|
|
967
|
+
if (!deferredHydration || nextIndex > (deferredHydration.initialIndex ?? 0)) deferredHydration = next;
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
const historyChanged = mergeServerEvents(next.initialEvents, Math.min(nextIndex, frontier));
|
|
971
|
+
if (next.initialState === void 0 || nextIndex <= frontier) {
|
|
972
|
+
if (historyChanged) notifyHydrated();
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
frontier = nextIndex;
|
|
976
|
+
foldedState = next.initialState;
|
|
977
|
+
mergeServerEvents(next.initialEvents, frontier);
|
|
978
|
+
pending = pending.filter((entry) => !entry.acked || entry.acked.index > frontier);
|
|
979
|
+
if (confirmWatchers.some((watcher) => watcher.index <= frontier)) {
|
|
980
|
+
const due = confirmWatchers.filter((watcher) => watcher.index <= frontier);
|
|
981
|
+
confirmWatchers = confirmWatchers.filter((watcher) => watcher.index > frontier);
|
|
982
|
+
for (const watcher of due) watcher.resolve();
|
|
983
|
+
}
|
|
984
|
+
notifyHydrated();
|
|
985
|
+
},
|
|
986
|
+
canEvict() {
|
|
987
|
+
return listeners.size === 0 && !active && inFlightPushes === 0 && historyLoads === 0 && confirmWatchers.length === 0 && presenceTimer === void 0;
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
return runtime;
|
|
991
|
+
};
|
|
992
|
+
return { session(sessionId, sessionOptions) {
|
|
993
|
+
const existing = sessions.get(sessionId);
|
|
994
|
+
if (existing) {
|
|
995
|
+
existing.runtime.hydrate(sessionOptions);
|
|
996
|
+
touch(sessionId);
|
|
997
|
+
return existing.runtime;
|
|
998
|
+
}
|
|
999
|
+
const runtime = makeSession(sessionId, sessionOptions);
|
|
1000
|
+
sessions.set(sessionId, { runtime });
|
|
1001
|
+
touch(sessionId);
|
|
1002
|
+
return runtime;
|
|
1003
|
+
} };
|
|
1004
|
+
}
|
|
1005
|
+
//#endregion
|
|
1006
|
+
export { createClient as t };
|
|
1007
|
+
|
|
1008
|
+
//# sourceMappingURL=client-BKlyLiOU.js.map
|