experimental-a2 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/actor-D_54lz_1.d.ts +310 -0
  3. package/dist/actor-D_54lz_1.d.ts.map +1 -0
  4. package/dist/actor-client.d.ts +13 -4
  5. package/dist/actor-client.d.ts.map +1 -1
  6. package/dist/actor-client.js +63 -7
  7. package/dist/actor-client.js.map +1 -1
  8. package/dist/actor-react.d.ts +5 -4
  9. package/dist/actor-react.d.ts.map +1 -1
  10. package/dist/actor-react.js +16 -2
  11. package/dist/actor-react.js.map +1 -1
  12. package/dist/actor.d.ts +2 -176
  13. package/dist/actor.js +13 -2
  14. package/dist/actor.js.map +1 -1
  15. package/dist/ai-server.d.ts +1 -1
  16. package/dist/ai-server.js +2 -2
  17. package/dist/ai.d.ts +1 -1
  18. package/dist/ai.js +1 -1
  19. package/dist/client-Bf6uSEAk.js +1342 -0
  20. package/dist/client-Bf6uSEAk.js.map +1 -0
  21. package/dist/client-P_NNNRM-.d.ts +243 -0
  22. package/dist/client-P_NNNRM-.d.ts.map +1 -0
  23. package/dist/client.d.ts +2 -202
  24. package/dist/client.js +2 -1026
  25. package/dist/errors-DCk6ch5n.js.map +1 -1
  26. package/dist/errors-DvhSXnxk.d.ts +28 -0
  27. package/dist/errors-DvhSXnxk.d.ts.map +1 -0
  28. package/dist/index.d.ts +3 -35
  29. package/dist/{internal-DRXJ56EI.js → internal-Dq2qYxou.js} +2 -2
  30. package/dist/{internal-DRXJ56EI.js.map → internal-Dq2qYxou.js.map} +1 -1
  31. package/dist/platform-B4TnJtWu.js +34 -0
  32. package/dist/platform-B4TnJtWu.js.map +1 -0
  33. package/dist/react.d.ts +3 -1
  34. package/dist/react.d.ts.map +1 -1
  35. package/dist/react.js +2 -1
  36. package/dist/react.js.map +1 -1
  37. package/dist/scheduler-qstash.d.ts +2 -2
  38. package/dist/scheduler-qstash.js +3 -2
  39. package/dist/scheduler-qstash.js.map +1 -1
  40. package/dist/scheduler-vercel.d.ts +2 -2
  41. package/dist/scheduler-vercel.js +2 -2
  42. package/dist/{server-CBET-jSz.js → server-Dkz2a84E.js} +295 -133
  43. package/dist/server-Dkz2a84E.js.map +1 -0
  44. package/dist/{server-CKY3_lbw.d.ts → server-DwPrMqHB.d.ts} +4 -2
  45. package/dist/server-DwPrMqHB.d.ts.map +1 -0
  46. package/dist/server.d.ts +2 -2
  47. package/dist/server.js +1 -1
  48. package/dist/{store-DGHeBtIQ.d.ts → store-DtDOWLSn.d.ts} +4 -5
  49. package/dist/{store-DGHeBtIQ.d.ts.map → store-DtDOWLSn.d.ts.map} +1 -1
  50. package/dist/store-N8PXxDAS.js.map +1 -1
  51. package/dist/store-memory.d.ts +1 -1
  52. package/dist/store-memory.js +1 -1
  53. package/dist/{store-polling-6DW7F1DT.js → store-polling-CmxUbV93.js} +56 -7
  54. package/dist/store-polling-CmxUbV93.js.map +1 -0
  55. package/dist/store-postgres.d.ts +6 -4
  56. package/dist/store-postgres.d.ts.map +1 -1
  57. package/dist/store-postgres.js +701 -53
  58. package/dist/store-postgres.js.map +1 -1
  59. package/dist/store-presence-polling-C7-XZyW9.js +94 -0
  60. package/dist/store-presence-polling-C7-XZyW9.js.map +1 -0
  61. package/dist/store-redis-http.d.ts +2 -2
  62. package/dist/store-redis-http.d.ts.map +1 -1
  63. package/dist/store-redis-http.js +184 -38
  64. package/dist/store-redis-http.js.map +1 -1
  65. package/dist/{store-redis-core-z-ykbyMg.js → store-redis-notify-BUCyXOn0.js} +491 -27
  66. package/dist/store-redis-notify-BUCyXOn0.js.map +1 -0
  67. package/dist/store-redis.d.ts +5 -13
  68. package/dist/store-redis.d.ts.map +1 -1
  69. package/dist/store-redis.js +27 -271
  70. package/dist/store-redis.js.map +1 -1
  71. package/dist/store-sqlite.d.ts +1 -1
  72. package/dist/store-sqlite.js +1 -1
  73. package/dist/{wire--yji6mO3.js → wire-BO5wWCb1.js} +18 -16
  74. package/dist/wire-BO5wWCb1.js.map +1 -0
  75. package/docs/actors/04-routes.mdx +40 -8
  76. package/docs/guides/03-react.mdx +52 -10
  77. package/docs/guides/05-production.mdx +54 -5
  78. package/docs/guides/09-presence.mdx +12 -7
  79. package/docs/guides/10-transports.mdx +85 -22
  80. package/docs/reference/01-api.mdx +90 -28
  81. package/docs/reference/02-errors.mdx +45 -11
  82. package/examples/playground/package.json +1 -1
  83. package/package.json +1 -1
  84. package/src/actor-client.ts +114 -14
  85. package/src/actor-react.ts +18 -3
  86. package/src/actor.ts +16 -1
  87. package/src/client-errors.ts +51 -0
  88. package/src/client.ts +577 -175
  89. package/src/errors.ts +4 -9
  90. package/src/index.ts +1 -1
  91. package/src/internal.ts +1 -1
  92. package/src/postgres-notification-scope.ts +134 -0
  93. package/src/postgres-notifications.ts +244 -0
  94. package/src/postgres-pool.ts +65 -0
  95. package/src/postgres-resources.ts +185 -0
  96. package/src/presence-recovery.ts +120 -0
  97. package/src/react.ts +3 -0
  98. package/src/redis-http-subscriptions.ts +199 -0
  99. package/src/server-fetch.ts +51 -23
  100. package/src/server.ts +146 -19
  101. package/src/session-socket.ts +241 -88
  102. package/src/sse.ts +17 -0
  103. package/src/store-polling.ts +32 -12
  104. package/src/store-postgres.ts +292 -75
  105. package/src/store-presence-polling.ts +125 -0
  106. package/src/store-redis-core.ts +37 -30
  107. package/src/store-redis-http.ts +51 -45
  108. package/src/store-redis-notify.ts +464 -0
  109. package/src/store-redis.ts +9 -364
  110. package/src/store.ts +3 -4
  111. package/src/stream-activity.ts +32 -0
  112. package/src/wire.ts +39 -15
  113. package/dist/actor-shared-BACubf4x.d.ts +0 -136
  114. package/dist/actor-shared-BACubf4x.d.ts.map +0 -1
  115. package/dist/actor.d.ts.map +0 -1
  116. package/dist/client.d.ts.map +0 -1
  117. package/dist/client.js.map +0 -1
  118. package/dist/index.d.ts.map +0 -1
  119. package/dist/server-CBET-jSz.js.map +0 -1
  120. package/dist/server-CKY3_lbw.d.ts.map +0 -1
  121. package/dist/store-polling-6DW7F1DT.js.map +0 -1
  122. package/dist/store-redis-core-z-ykbyMg.js.map +0 -1
  123. package/dist/wire--yji6mO3.js.map +0 -1
@@ -0,0 +1,1342 @@
1
+ import { n as validateSync } from "./validate-XKT4FSNn.js";
2
+ import { t as A2Error } from "./errors-DCk6ch5n.js";
3
+ import { a as PRESENCE_TIMINGS, c as STREAM_TIMINGS, h as nullProtoRecord, o as RESERVED_PARTICIPANT_IDS } from "./internal-Dq2qYxou.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-BO5wWCb1.js";
5
+ //#region src/client-errors.ts
6
+ var A2ClientError = class extends A2Error {
7
+ status;
8
+ closeCode;
9
+ closeReason;
10
+ phase;
11
+ timeoutMs;
12
+ constructor(code, message, options) {
13
+ super(code, message, options);
14
+ this.name = "A2ClientError";
15
+ this.status = options?.status;
16
+ this.closeCode = options?.closeCode;
17
+ this.closeReason = options?.closeReason;
18
+ this.phase = options?.phase;
19
+ this.timeoutMs = options?.timeoutMs;
20
+ }
21
+ };
22
+ function asClientError(error, options) {
23
+ if (error instanceof A2ClientError) return error;
24
+ if (error instanceof A2Error) return new A2ClientError(error.code, error.message, {
25
+ details: error.details,
26
+ cause: error
27
+ });
28
+ return new A2ClientError(options?.code ?? "UNKNOWN", error instanceof Error ? error.message : "client operation failed", { cause: error });
29
+ }
30
+ //#endregion
31
+ //#region src/client.ts
32
+ /**
33
+ * experimental-a2/client — the framework-agnostic session client.
34
+ *
35
+ * Everything the browser needs to read a session live and push
36
+ * optimistically, with no framework attached: the SSE subscription with
37
+ * frontier resume and reconnection, the optimistic push queue with
38
+ * ack/rollback, and the local fold through the same reducer the server
39
+ * uses. `experimental-a2/react`'s `createReact` is a thin binding over it, and the
40
+ * store contract (`subscribe`/`getSnapshot`) is exactly what
41
+ * `useSyncExternalStore` wants.
42
+ *
43
+ * The client is a replica, never an access path — the routes it talks
44
+ * to authorize every read and write.
45
+ */
46
+ const PUSH_ATTEMPTS = 3;
47
+ const RETRYABLE_PUSH_CODES = /* @__PURE__ */ new Set([
48
+ "STORE_UNAVAILABLE",
49
+ "TIMEOUT",
50
+ "HTTP_ERROR",
51
+ "CONNECTION_FAILED",
52
+ "UNKNOWN"
53
+ ]);
54
+ const DEFAULT_GC_TIME_MS = 3e5;
55
+ const DEFAULT_HISTORY_LIMIT = 50;
56
+ const sessionUrl = (url, sessionId) => {
57
+ const resolved = typeof url === "function" ? url(sessionId) : url;
58
+ if (typeof resolved !== "string") throw new TypeError("a session URL function must return a string");
59
+ return resolved;
60
+ };
61
+ const httpError = async (response) => {
62
+ const error = errorFromWire(await response.json().catch(() => null));
63
+ return error === null ? new A2ClientError("HTTP_ERROR", `request failed with ${response.status}`, { status: response.status }) : new A2ClientError(error.code, error.message, {
64
+ details: error.details,
65
+ status: response.status,
66
+ cause: error
67
+ });
68
+ };
69
+ const httpTransport = (routes, fetchImpl, presence) => ({
70
+ async *connect({ sessionId, startAfter, signal }) {
71
+ const streamUrl = sessionUrl(routes.stream, sessionId);
72
+ const res = await fetchImpl(`${streamUrl}${streamUrl.includes("?") ? "&" : "?"}sessionId=${encodeURIComponent(sessionId)}&index=${startAfter}`, {
73
+ headers: { accept: "text/event-stream" },
74
+ signal
75
+ });
76
+ if (!res.ok) throw await httpError(res);
77
+ if (!res.body) throw new A2ClientError("UNKNOWN", "stream response has no body", { status: res.status });
78
+ yield { kind: "ping" };
79
+ const decoder = new TextDecoder();
80
+ const reader = res.body.getReader();
81
+ let buffer = "";
82
+ try {
83
+ for (;;) {
84
+ const { done, value } = await reader.read();
85
+ if (done) return;
86
+ yield { kind: "ping" };
87
+ buffer += decoder.decode(value, { stream: true });
88
+ for (;;) {
89
+ const boundary = buffer.indexOf("\n\n");
90
+ if (boundary === -1) break;
91
+ const frame = buffer.slice(0, boundary);
92
+ buffer = buffer.slice(boundary + 2);
93
+ let eventName;
94
+ const dataLines = [];
95
+ for (const line of frame.split("\n")) if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
96
+ else if (line.startsWith("event:")) eventName = line.slice(6).trim();
97
+ const data = dataLines.join("\n");
98
+ if (!data) continue;
99
+ if (eventName === void 0) {
100
+ const parsed = JSON.parse(data);
101
+ if (isWireEvent(parsed)) yield {
102
+ kind: "event",
103
+ event: parsed
104
+ };
105
+ } else if (presence && eventName === "presence-snapshot") {
106
+ const parsed = JSON.parse(data);
107
+ if (isWirePresenceSnapshot(parsed)) yield {
108
+ kind: "presence-snapshot",
109
+ snapshot: parsed
110
+ };
111
+ } else if (presence && eventName === "presence") {
112
+ const parsed = JSON.parse(data);
113
+ if (isWirePresencePatch(parsed)) yield {
114
+ kind: "presence",
115
+ patch: parsed
116
+ };
117
+ } else if (eventName === "a2-activity" && (data === "idle" || data === "active")) yield {
118
+ kind: "activity",
119
+ idle: data === "idle"
120
+ };
121
+ }
122
+ }
123
+ } finally {
124
+ reader.cancel().catch(() => {});
125
+ }
126
+ },
127
+ async push(body) {
128
+ const res = await fetchImpl(sessionUrl(routes.push, body.sessionId), {
129
+ method: "POST",
130
+ headers: { "content-type": "application/json" },
131
+ body: JSON.stringify(body)
132
+ });
133
+ if (res.ok) {
134
+ const rows = await res.json();
135
+ if (!Array.isArray(rows) || !rows.every(isWireEvent)) throw new A2ClientError("STORE_UNAVAILABLE", "push ack was not a list of events");
136
+ return rows;
137
+ }
138
+ throw await httpError(res);
139
+ },
140
+ async history({ sessionId, gte, lte }) {
141
+ const streamUrl = sessionUrl(routes.stream, sessionId);
142
+ const res = await fetchImpl(`${streamUrl}${streamUrl.includes("?") ? "&" : "?"}sessionId=${encodeURIComponent(sessionId)}&gte=${gte}&lte=${lte}`, { headers: { accept: "application/json" } });
143
+ if (!res.ok) throw await httpError(res);
144
+ const rows = await res.json();
145
+ if (!Array.isArray(rows) || !rows.every(isWireEvent)) throw new A2ClientError("STORE_UNAVAILABLE", "history response was not a list of events");
146
+ return rows;
147
+ },
148
+ sendPresence(envelope) {
149
+ (async () => {
150
+ try {
151
+ await (await fetchImpl(sessionUrl(routes.push, envelope.sessionId), {
152
+ method: "POST",
153
+ headers: { "content-type": "application/json" },
154
+ body: JSON.stringify(envelope)
155
+ })).arrayBuffer();
156
+ } catch {}
157
+ })();
158
+ }
159
+ });
160
+ /** `WebSocket.OPEN` — fixed by the spec, identical in every runtime. */
161
+ const WS_OPEN = 1;
162
+ const webSocketUrl = (value) => {
163
+ let resolved;
164
+ if (/^(https?|wss?):\/\//i.test(value)) resolved = new URL(value);
165
+ else {
166
+ const base = globalThis.location?.href;
167
+ if (base === void 0) throw new TypeError(`cannot resolve the relative ws url '${value}' without a browser location — pass an absolute ws:// or wss:// url`);
168
+ resolved = new URL(value, base);
169
+ }
170
+ if (resolved.protocol === "http:") resolved.protocol = "ws:";
171
+ else if (resolved.protocol === "https:") resolved.protocol = "wss:";
172
+ return resolved;
173
+ };
174
+ const socketReadyWaiters = () => {
175
+ const sessions = /* @__PURE__ */ new Map();
176
+ const notify = (sessionId) => {
177
+ const waiters = sessions.get(sessionId);
178
+ if (!waiters) return;
179
+ sessions.delete(sessionId);
180
+ for (const waiter of waiters) waiter();
181
+ };
182
+ const wait = (sessionId, maxWaitMs) => new Promise((resolve) => {
183
+ let waiters = sessions.get(sessionId);
184
+ if (!waiters) {
185
+ waiters = /* @__PURE__ */ new Set();
186
+ sessions.set(sessionId, waiters);
187
+ }
188
+ let settled = false;
189
+ const done = () => {
190
+ if (settled) return;
191
+ settled = true;
192
+ clearTimeout(timer);
193
+ waiters.delete(done);
194
+ if (waiters.size === 0) sessions.delete(sessionId);
195
+ resolve();
196
+ };
197
+ const timer = setTimeout(done, maxWaitMs);
198
+ timer.unref?.();
199
+ waiters.add(done);
200
+ });
201
+ return {
202
+ notify,
203
+ wait
204
+ };
205
+ };
206
+ const rejectPendingAcks = (pending) => {
207
+ if (pending.size === 0) return;
208
+ const error = new A2ClientError("STORE_UNAVAILABLE", "socket closed with the ack outstanding");
209
+ for (const waiter of pending.values()) waiter.reject(error);
210
+ pending.clear();
211
+ };
212
+ const sessionWsTransport = (url, webSocketImpl, presence) => {
213
+ const connections = /* @__PURE__ */ new Map();
214
+ const ready = socketReadyWaiters();
215
+ let nextReq = 1;
216
+ const socketUrl = (sessionId, startAfter) => {
217
+ const resolved = webSocketUrl(sessionUrl(url, sessionId));
218
+ resolved.searchParams.set("sessionId", sessionId);
219
+ resolved.searchParams.set("index", String(startAfter));
220
+ return resolved.href;
221
+ };
222
+ return {
223
+ async *connect({ sessionId, startAfter, signal }) {
224
+ if (webSocketImpl === void 0) throw new TypeError("no WebSocket implementation available — pass one via createClient({ webSocket }) where the global is missing");
225
+ const socket = new webSocketImpl(socketUrl(sessionId, startAfter));
226
+ const connection = {
227
+ socket,
228
+ pending: /* @__PURE__ */ new Map()
229
+ };
230
+ connections.set(sessionId, connection);
231
+ const queue = [];
232
+ let ended = false;
233
+ let failure = null;
234
+ let wake = null;
235
+ const notify = () => {
236
+ wake?.();
237
+ wake = null;
238
+ };
239
+ const finish = (error) => {
240
+ if (ended) return;
241
+ ended = true;
242
+ failure = error;
243
+ if (connections.get(sessionId) === connection) connections.delete(sessionId);
244
+ rejectPendingAcks(connection.pending);
245
+ notify();
246
+ };
247
+ socket.addEventListener("open", () => {
248
+ if (connections.get(sessionId) !== connection || ended) return;
249
+ queue.push({ kind: "ping" });
250
+ notify();
251
+ ready.notify(sessionId);
252
+ });
253
+ socket.addEventListener("message", (event) => {
254
+ if (connections.get(sessionId) !== connection || ended) return;
255
+ if (typeof event.data !== "string") return;
256
+ const frame = parseSocketFrame(event.data);
257
+ if (frame === null) return;
258
+ switch (frame.kind) {
259
+ case "ack": {
260
+ const waiter = connection.pending.get(frame.req);
261
+ if (waiter === void 0) return;
262
+ connection.pending.delete(frame.req);
263
+ if (frame.sessionId !== void 0 || "events" in frame && frame.events.some((row) => row.sessionId !== sessionId)) {
264
+ waiter.reject(new A2ClientError("STORE_UNAVAILABLE", "ack does not belong to the socket session"));
265
+ return;
266
+ }
267
+ if ("events" in frame) waiter.resolve(frame.events);
268
+ else waiter.reject(asClientError(frame.error));
269
+ return;
270
+ }
271
+ case "event":
272
+ if (frame.event.sessionId !== sessionId) return;
273
+ queue.push({
274
+ kind: "event",
275
+ event: frame.event
276
+ });
277
+ break;
278
+ case "presence":
279
+ if (presence && frame.sessionId === void 0) queue.push({
280
+ kind: "presence",
281
+ patch: frame.patch
282
+ });
283
+ break;
284
+ case "presence-snapshot":
285
+ if (presence && frame.sessionId === void 0) queue.push({
286
+ kind: "presence-snapshot",
287
+ snapshot: frame.snapshot
288
+ });
289
+ break;
290
+ case "ping":
291
+ queue.push(frame);
292
+ break;
293
+ case "subscribed":
294
+ case "unsubscribed": return;
295
+ }
296
+ notify();
297
+ });
298
+ socket.addEventListener("close", (event) => {
299
+ finish(event.code === 1e3 ? null : new A2ClientError("CONNECTION_FAILED", `socket closed (${event.code}${event.reason ? `: ${event.reason}` : ""})`, {
300
+ closeCode: event.code,
301
+ closeReason: event.reason
302
+ }));
303
+ });
304
+ socket.addEventListener("error", () => {
305
+ finish(new A2ClientError("CONNECTION_FAILED", "socket error"));
306
+ });
307
+ const onAbort = () => {
308
+ finish(signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("stream aborted"));
309
+ try {
310
+ socket.close(1e3);
311
+ } catch {}
312
+ };
313
+ if (signal.aborted) onAbort();
314
+ else signal.addEventListener("abort", onAbort, { once: true });
315
+ try {
316
+ for (;;) {
317
+ while (queue.length > 0) yield queue.shift();
318
+ if (ended) {
319
+ if (failure) throw failure;
320
+ return;
321
+ }
322
+ await new Promise((resolve) => {
323
+ wake = resolve;
324
+ });
325
+ }
326
+ } finally {
327
+ signal.removeEventListener("abort", onAbort);
328
+ finish(null);
329
+ try {
330
+ socket.close(1e3);
331
+ } catch {}
332
+ }
333
+ },
334
+ async push(body) {
335
+ const connection = connections.get(body.sessionId);
336
+ if (connection === void 0 || connection.socket.readyState !== WS_OPEN) throw new A2ClientError("STORE_UNAVAILABLE", "no open socket for the session");
337
+ const req = nextReq;
338
+ nextReq += 1;
339
+ return await new Promise((resolve, reject) => {
340
+ connection.pending.set(req, {
341
+ resolve,
342
+ reject
343
+ });
344
+ const frame = {
345
+ kind: "push",
346
+ req,
347
+ events: body.events
348
+ };
349
+ try {
350
+ connection.socket.send(JSON.stringify(frame));
351
+ } catch (cause) {
352
+ connection.pending.delete(req);
353
+ reject(new A2ClientError("STORE_UNAVAILABLE", "socket send failed", { cause }));
354
+ }
355
+ });
356
+ },
357
+ awaitReady(sessionId, maxWaitMs) {
358
+ if (connections.get(sessionId)?.socket.readyState === WS_OPEN) return Promise.resolve();
359
+ return ready.wait(sessionId, maxWaitMs);
360
+ },
361
+ sendPresence(envelope) {
362
+ const connection = connections.get(envelope.sessionId);
363
+ if (connection === void 0 || connection.socket.readyState !== WS_OPEN) return;
364
+ const frame = {
365
+ kind: "presence",
366
+ ...envelope.presence
367
+ };
368
+ try {
369
+ connection.socket.send(JSON.stringify(frame));
370
+ } catch {
371
+ return;
372
+ }
373
+ }
374
+ };
375
+ };
376
+ const wakeChannel = (channel) => {
377
+ channel.wake?.();
378
+ channel.wake = null;
379
+ };
380
+ const subscribeFrame = (sessions) => {
381
+ return JSON.stringify({
382
+ kind: "subscribe",
383
+ sessions
384
+ });
385
+ };
386
+ const multiplexedWsTransport = (url, webSocketImpl, presence) => {
387
+ const channels = /* @__PURE__ */ new Map();
388
+ const pending = /* @__PURE__ */ new Map();
389
+ /** Push retries parked in `awaitReady`, flushed per session on its
390
+ * subscribe confirmation. */
391
+ const ready = socketReadyWaiters();
392
+ let socket = null;
393
+ let socketOpen = false;
394
+ let watchdog;
395
+ let nextReq = 1;
396
+ const trySend = (data) => {
397
+ if (socket === null || !socketOpen) return;
398
+ try {
399
+ socket.send(data);
400
+ } catch {}
401
+ };
402
+ const deliver = (channel, frame) => {
403
+ channel.queue.push(frame);
404
+ wakeChannel(channel);
405
+ };
406
+ const settleChannel = (channel, failure) => {
407
+ if (channel.ended) return;
408
+ channel.ended = true;
409
+ channel.failure = failure;
410
+ wakeChannel(channel);
411
+ };
412
+ const releaseSocket = () => {
413
+ const current = socket;
414
+ socket = null;
415
+ socketOpen = false;
416
+ clearTimeout(watchdog);
417
+ watchdog = void 0;
418
+ rejectPendingAcks(pending);
419
+ return current;
420
+ };
421
+ const failSocket = (failure) => {
422
+ const current = releaseSocket();
423
+ for (const channel of channels.values()) settleChannel(channel, failure);
424
+ try {
425
+ current?.close(1e3);
426
+ } catch {}
427
+ };
428
+ const detach = (channel) => {
429
+ if (channels.get(channel.sessionId) !== channel) return;
430
+ channels.delete(channel.sessionId);
431
+ if (!channel.dropped) {
432
+ const frame = {
433
+ kind: "unsubscribe",
434
+ sessions: [channel.sessionId]
435
+ };
436
+ trySend(JSON.stringify(frame));
437
+ }
438
+ if (channels.size === 0) {
439
+ const current = releaseSocket();
440
+ try {
441
+ current?.close(1e3);
442
+ } catch {}
443
+ }
444
+ };
445
+ const armWatchdog = (created) => {
446
+ clearTimeout(watchdog);
447
+ watchdog = setTimeout(() => {
448
+ if (socket !== created) return;
449
+ failSocket(new A2ClientError("TIMEOUT", `socket stalled: no data for ${STREAM_TIMINGS.stallTimeoutMs}ms`, {
450
+ phase: "stream",
451
+ timeoutMs: STREAM_TIMINGS.stallTimeoutMs
452
+ }));
453
+ }, STREAM_TIMINGS.stallTimeoutMs);
454
+ watchdog.unref?.();
455
+ };
456
+ const route = (frame) => {
457
+ switch (frame.kind) {
458
+ case "ping":
459
+ for (const channel of channels.values()) if (channel.live && !channel.ended) deliver(channel, { kind: "ping" });
460
+ return;
461
+ case "subscribed": {
462
+ const channel = channels.get(frame.sessionId);
463
+ if (channel === void 0 || channel.ended || channel.live) return;
464
+ channel.live = true;
465
+ deliver(channel, { kind: "ping" });
466
+ ready.notify(frame.sessionId);
467
+ return;
468
+ }
469
+ case "unsubscribed": {
470
+ const channel = channels.get(frame.sessionId);
471
+ if (channel === void 0) return;
472
+ channel.dropped = true;
473
+ settleChannel(channel, frame.error ? asClientError(frame.error) : frame.reason === void 0 ? null : new A2ClientError("CONNECTION_FAILED", frame.reason));
474
+ return;
475
+ }
476
+ case "ack": {
477
+ const waiter = pending.get(frame.req);
478
+ if (waiter === void 0) return;
479
+ pending.delete(frame.req);
480
+ if ("events" in frame) waiter.resolve(frame.events);
481
+ else waiter.reject(asClientError(frame.error));
482
+ return;
483
+ }
484
+ case "event": {
485
+ const channel = channels.get(frame.event.sessionId);
486
+ if (channel !== void 0 && channel.live && !channel.ended) deliver(channel, {
487
+ kind: "event",
488
+ event: frame.event
489
+ });
490
+ return;
491
+ }
492
+ case "presence": {
493
+ if (!presence || frame.sessionId === void 0) return;
494
+ const channel = channels.get(frame.sessionId);
495
+ if (channel !== void 0 && channel.live && !channel.ended) deliver(channel, {
496
+ kind: "presence",
497
+ patch: frame.patch
498
+ });
499
+ return;
500
+ }
501
+ case "presence-snapshot": {
502
+ if (!presence || frame.sessionId === void 0) return;
503
+ const channel = channels.get(frame.sessionId);
504
+ if (channel !== void 0 && channel.live && !channel.ended) deliver(channel, {
505
+ kind: "presence-snapshot",
506
+ snapshot: frame.snapshot
507
+ });
508
+ return;
509
+ }
510
+ }
511
+ };
512
+ const ensureSocket = () => {
513
+ if (socket !== null) return;
514
+ if (webSocketImpl === void 0) throw new TypeError("no WebSocket implementation available — pass one via createClient({ webSocket }) where the global is missing");
515
+ const created = new webSocketImpl(webSocketUrl(url).href);
516
+ socket = created;
517
+ socketOpen = false;
518
+ armWatchdog(created);
519
+ created.addEventListener("open", () => {
520
+ if (socket !== created) return;
521
+ socketOpen = true;
522
+ armWatchdog(created);
523
+ const sessions = [...channels.values()].filter((channel) => !channel.ended).map((channel) => ({
524
+ id: channel.sessionId,
525
+ index: channel.startAfter
526
+ }));
527
+ if (sessions.length > 0) trySend(subscribeFrame(sessions));
528
+ });
529
+ created.addEventListener("message", (event) => {
530
+ if (socket !== created) return;
531
+ if (typeof event.data !== "string") return;
532
+ armWatchdog(created);
533
+ const frame = parseSocketFrame(event.data);
534
+ if (frame === null) return;
535
+ route(frame);
536
+ });
537
+ created.addEventListener("close", (event) => {
538
+ if (socket !== created) return;
539
+ failSocket(event.code === 1e3 ? null : new A2ClientError("CONNECTION_FAILED", `socket closed (${event.code}${event.reason ? `: ${event.reason}` : ""})`, {
540
+ closeCode: event.code,
541
+ closeReason: event.reason
542
+ }));
543
+ });
544
+ created.addEventListener("error", () => {
545
+ if (socket !== created) return;
546
+ failSocket(new A2ClientError("CONNECTION_FAILED", "socket error"));
547
+ });
548
+ };
549
+ return {
550
+ async *connect({ sessionId, startAfter, signal }) {
551
+ const channel = {
552
+ sessionId,
553
+ startAfter,
554
+ queue: [],
555
+ live: false,
556
+ dropped: false,
557
+ ended: false,
558
+ failure: null,
559
+ wake: null
560
+ };
561
+ channels.set(sessionId, channel);
562
+ try {
563
+ ensureSocket();
564
+ if (socketOpen) trySend(subscribeFrame([{
565
+ id: sessionId,
566
+ index: startAfter
567
+ }]));
568
+ } catch (error) {
569
+ if (channels.get(sessionId) === channel) channels.delete(sessionId);
570
+ throw error;
571
+ }
572
+ const onAbort = () => {
573
+ settleChannel(channel, signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("stream aborted"));
574
+ detach(channel);
575
+ };
576
+ if (signal.aborted) onAbort();
577
+ else signal.addEventListener("abort", onAbort, { once: true });
578
+ const pendingSubscribe = setTimeout(() => {
579
+ if (channel.live) return;
580
+ settleChannel(channel, new A2ClientError("TIMEOUT", "subscribe not confirmed: is the ws route calling server.fetch with upgradeWebSocket?", {
581
+ phase: "subscribe",
582
+ timeoutMs: STREAM_TIMINGS.stallTimeoutMs
583
+ }));
584
+ }, STREAM_TIMINGS.stallTimeoutMs);
585
+ pendingSubscribe.unref?.();
586
+ try {
587
+ for (;;) {
588
+ while (channel.queue.length > 0) yield channel.queue.shift();
589
+ if (channel.ended) {
590
+ if (channel.failure) throw channel.failure;
591
+ return;
592
+ }
593
+ await new Promise((resolve) => {
594
+ channel.wake = resolve;
595
+ });
596
+ }
597
+ } finally {
598
+ clearTimeout(pendingSubscribe);
599
+ signal.removeEventListener("abort", onAbort);
600
+ settleChannel(channel, null);
601
+ detach(channel);
602
+ }
603
+ },
604
+ async push(body) {
605
+ const channel = channels.get(body.sessionId);
606
+ const target = socket;
607
+ if (channel === void 0 || !channel.live || channel.ended || target === null || !socketOpen || target.readyState !== WS_OPEN) throw new A2ClientError("STORE_UNAVAILABLE", "no open socket for the session");
608
+ const req = nextReq;
609
+ nextReq += 1;
610
+ return await new Promise((resolve, reject) => {
611
+ pending.set(req, {
612
+ resolve,
613
+ reject
614
+ });
615
+ const frame = {
616
+ kind: "push",
617
+ req,
618
+ sessionId: body.sessionId,
619
+ events: body.events
620
+ };
621
+ try {
622
+ target.send(JSON.stringify(frame));
623
+ } catch (cause) {
624
+ pending.delete(req);
625
+ reject(new A2ClientError("STORE_UNAVAILABLE", "socket send failed", { cause }));
626
+ }
627
+ });
628
+ },
629
+ awaitReady(sessionId, maxWaitMs) {
630
+ const channel = channels.get(sessionId);
631
+ if (channel !== void 0 && channel.live && !channel.ended && socketOpen) return Promise.resolve();
632
+ return ready.wait(sessionId, maxWaitMs);
633
+ },
634
+ sendPresence(envelope) {
635
+ const channel = channels.get(envelope.sessionId);
636
+ if (channel === void 0 || !channel.live || channel.ended) return;
637
+ const frame = {
638
+ kind: "presence",
639
+ sessionId: envelope.sessionId,
640
+ ...envelope.presence
641
+ };
642
+ trySend(JSON.stringify(frame));
643
+ }
644
+ };
645
+ };
646
+ const transportFor = (api, fetchImpl, webSocketImpl, presence) => {
647
+ if (typeof api === "string" || typeof api === "function") return httpTransport({
648
+ push: api,
649
+ stream: api
650
+ }, fetchImpl, presence);
651
+ if (api.type === "http") return httpTransport({
652
+ push: api.push,
653
+ stream: api.stream
654
+ }, fetchImpl, presence);
655
+ if (api.multiplex === true) {
656
+ if (typeof api.url !== "string") throw new TypeError("a multiplexed WebSocket URL must be a string");
657
+ return multiplexedWsTransport(api.url, webSocketImpl, presence);
658
+ }
659
+ return sessionWsTransport(api.url, webSocketImpl, presence);
660
+ };
661
+ function createClient(options) {
662
+ const { reducer, api } = options;
663
+ const presenceDefs = reducer.presence;
664
+ const declaresPresence = Object.keys(presenceDefs).length > 0;
665
+ const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
666
+ const clientFetch = async (input, init) => {
667
+ try {
668
+ return await fetchImpl(input, init);
669
+ } catch (error) {
670
+ throw asClientError(error, { code: "CONNECTION_FAILED" });
671
+ }
672
+ };
673
+ const webSocketImpl = options.webSocket ?? globalThis.WebSocket;
674
+ const transport = transportFor(api, clientFetch, webSocketImpl, declaresPresence);
675
+ const gcTime = options.gcTime ?? DEFAULT_GC_TIME_MS;
676
+ if (Number.isNaN(gcTime) || gcTime < 0) throw new RangeError("gcTime must be a non-negative number or Infinity");
677
+ const sessions = /* @__PURE__ */ new Map();
678
+ const scheduleGc = (sessionId, delay = gcTime) => {
679
+ const entry = sessions.get(sessionId);
680
+ if (!entry) return;
681
+ clearTimeout(entry.timer);
682
+ if (gcTime === Infinity) return;
683
+ entry.timer = setTimeout(() => {
684
+ if (sessions.get(sessionId) !== entry) return;
685
+ if (entry.runtime.canEvict()) {
686
+ sessions.delete(sessionId);
687
+ return;
688
+ }
689
+ scheduleGc(sessionId, Math.max(gcTime, 1e3));
690
+ }, delay);
691
+ entry.timer.unref?.();
692
+ };
693
+ const touch = (sessionId) => {
694
+ scheduleGc(sessionId);
695
+ };
696
+ const makeSession = (sessionId, sessionOptions) => {
697
+ let stateIndex = sessionOptions?.initialIndex ?? 0;
698
+ let streamIndex = sessionOptions?.initialState === void 0 ? sessionOptions?.initialIndex : stateIndex;
699
+ let foldedState = sessionOptions?.initialState !== void 0 ? sessionOptions.initialState : reducer.initialState;
700
+ const serverEvents = (sessionOptions?.initialEvents ?? []).filter((event) => event.index <= stateIndex).toSorted((a, b) => a.index - b.index);
701
+ let pending = [];
702
+ let participant = sessionOptions?.participant ?? options.participant;
703
+ let presenceState = nullProtoRecord();
704
+ let presenceLastSentAt = 0;
705
+ /** The last LWW stamp issued — see `nextPresenceAt`. */
706
+ let presenceLastAt = 0;
707
+ /** Values awaiting the trailing send — non-null exactly while
708
+ * `presenceTimer` is armed. */
709
+ let presenceBuffer = null;
710
+ let presenceTimer;
711
+ /** Own fields set while the stream was down — re-sent once on
712
+ * reconnect (their POSTs may have been lost with the network). */
713
+ let presenceResend = null;
714
+ const listeners = /* @__PURE__ */ new Set();
715
+ let snapshot = null;
716
+ let status = "idle";
717
+ let reconnects = 0;
718
+ let lastError = null;
719
+ let inFlightPushes = 0;
720
+ let pushWireTail = Promise.resolve();
721
+ let deferredHydration;
722
+ let hydrationNotification;
723
+ const emit = () => {
724
+ for (const listener of [...listeners]) listener();
725
+ };
726
+ const notify = () => {
727
+ snapshot = null;
728
+ emit();
729
+ };
730
+ const notifyHydrated = () => {
731
+ snapshot = null;
732
+ clearTimeout(hydrationNotification);
733
+ hydrationNotification = setTimeout(emit, 0);
734
+ hydrationNotification.unref?.();
735
+ };
736
+ const mergeServerEvents = (events, throughIndex) => {
737
+ if (!events || events.length === 0) return false;
738
+ const byIndex = new Map(serverEvents.map((event) => [event.index, event]));
739
+ let changed = false;
740
+ for (const event of events) {
741
+ if (event.index > throughIndex) continue;
742
+ if (byIndex.get(event.index)?.id === event.id) continue;
743
+ byIndex.set(event.index, event);
744
+ changed = true;
745
+ }
746
+ if (!changed) return false;
747
+ serverEvents.splice(0, serverEvents.length, ...[...byIndex.values()].toSorted((a, b) => a.index - b.index));
748
+ return true;
749
+ };
750
+ const overlayEvents = () => {
751
+ const acked = pending.filter((p) => p.acked).map((p) => p.acked).toSorted((a, b) => a.index - b.index);
752
+ const maxKnown = acked.at(-1)?.index ?? stateIndex;
753
+ const unacked = pending.filter((p) => !p.acked).map((p, i) => ({
754
+ id: p.id,
755
+ type: p.type,
756
+ payload: p.payload,
757
+ index: maxKnown + 1 + i,
758
+ sessionId,
759
+ createdAt: p.createdAt
760
+ }));
761
+ return [...acked, ...unacked];
762
+ };
763
+ const connection = () => {
764
+ switch (status) {
765
+ case "idle": return { status: "idle" };
766
+ case "closed": return { status: "closed" };
767
+ case "live": return {
768
+ status: "live",
769
+ reconnects
770
+ };
771
+ case "connecting":
772
+ case "paused": return {
773
+ status,
774
+ reconnects,
775
+ error: lastError
776
+ };
777
+ }
778
+ };
779
+ const historyState = () => {
780
+ const oldestLoaded = serverEvents[0]?.index ?? null;
781
+ return {
782
+ loading: historyLoads > 0,
783
+ complete: oldestLoaded === 1 || stateIndex === 0,
784
+ oldestLoaded
785
+ };
786
+ };
787
+ const buildSnapshot = () => {
788
+ const overlay = overlayEvents();
789
+ let state = foldedState;
790
+ for (const event of overlay) state = reducer.fold(state, event);
791
+ return {
792
+ state,
793
+ events: [...serverEvents, ...overlay],
794
+ index: stateIndex,
795
+ history: historyState(),
796
+ connection: connection(),
797
+ ...declaresPresence ? { presence: presenceState } : {}
798
+ };
799
+ };
800
+ /**
801
+ * The per-session monotonic LWW stamp: wall-seeded, guarded
802
+ * `max(now, last + 1)`, so overlapping sends can never invert —
803
+ * the wall clock seeds it because it is the only monotonic state
804
+ * that survives a reload without A2 owning persistence.
805
+ */
806
+ const nextPresenceAt = () => {
807
+ presenceLastAt = Math.max(Date.now(), presenceLastAt + 1);
808
+ return presenceLastAt;
809
+ };
810
+ const applyPresencePatch = (patch) => {
811
+ if (patch.participant === participant) return;
812
+ const entry = Object.assign(nullProtoRecord(), presenceState[patch.participant]);
813
+ let changed = false;
814
+ for (const [field, value] of Object.entries(patch.values)) {
815
+ if (value === void 0) continue;
816
+ const existing = entry[field];
817
+ if (existing && existing.at.getTime() > patch.at.getTime()) continue;
818
+ if (value === null) {
819
+ if (existing) {
820
+ delete entry[field];
821
+ changed = true;
822
+ }
823
+ } else {
824
+ entry[field] = {
825
+ value,
826
+ seen: patch.seen,
827
+ at: patch.at
828
+ };
829
+ changed = true;
830
+ }
831
+ }
832
+ if (!changed) return;
833
+ const next = Object.assign(nullProtoRecord(), presenceState);
834
+ if (Object.keys(entry).length === 0) delete next[patch.participant];
835
+ else next[patch.participant] = entry;
836
+ presenceState = next;
837
+ notify();
838
+ };
839
+ const applyPresenceSnapshot = (incoming) => {
840
+ const next = nullProtoRecord();
841
+ for (const [id, fields] of Object.entries(incoming)) {
842
+ if (id === participant) continue;
843
+ next[id] = fields;
844
+ }
845
+ const own = participant === void 0 ? void 0 : presenceState[participant];
846
+ if (participant !== void 0 && own !== void 0) next[participant] = own;
847
+ presenceState = next;
848
+ notify();
849
+ };
850
+ const echoPresence = (id, values, atMs) => {
851
+ const entry = Object.assign(nullProtoRecord(), presenceState[id]);
852
+ const at = new Date(atMs);
853
+ for (const [field, value] of Object.entries(values)) if (value === null) delete entry[field];
854
+ else entry[field] = {
855
+ value,
856
+ seen: stateIndex,
857
+ at
858
+ };
859
+ const next = Object.assign(nullProtoRecord(), presenceState);
860
+ if (Object.keys(entry).length === 0) delete next[id];
861
+ else next[id] = entry;
862
+ presenceState = next;
863
+ notify();
864
+ };
865
+ const sendPresence = (values, at) => {
866
+ presenceLastSentAt = Date.now();
867
+ transport.sendPresence({
868
+ sessionId,
869
+ presence: {
870
+ participant,
871
+ values,
872
+ seen: stateIndex,
873
+ at
874
+ }
875
+ });
876
+ touch(sessionId);
877
+ };
878
+ const queuePresenceSend = (values, at) => {
879
+ if (presenceTimer !== void 0) {
880
+ presenceBuffer = {
881
+ ...presenceBuffer,
882
+ ...values
883
+ };
884
+ return;
885
+ }
886
+ const wait = presenceLastSentAt + PRESENCE_TIMINGS.sendIntervalMs - Date.now();
887
+ if (wait <= 0) {
888
+ sendPresence(values, at ?? nextPresenceAt());
889
+ return;
890
+ }
891
+ presenceBuffer = {
892
+ ...presenceBuffer,
893
+ ...values
894
+ };
895
+ presenceTimer = setTimeout(() => {
896
+ presenceTimer = void 0;
897
+ const buffered = presenceBuffer;
898
+ presenceBuffer = null;
899
+ if (buffered) sendPresence(buffered, nextPresenceAt());
900
+ }, wait);
901
+ presenceTimer.unref?.();
902
+ };
903
+ const setPresence = (values) => {
904
+ if (participant === void 0) throw new TypeError("setPresence requires a participant — pass one to createClient({ participant }) or in the session options (the SessionProvider/useSession participant prop)");
905
+ const validated = nullProtoRecord();
906
+ for (const [field, value] of Object.entries(values)) {
907
+ if (value === void 0) continue;
908
+ const schema = Object.hasOwn(presenceDefs, field) ? presenceDefs[field] : RESERVED_PARTICIPANT_IDS.has(field) ? void 0 : presenceDefs["*"];
909
+ if (!schema) throw new A2ClientError("UNKNOWN_PRESENCE_FIELD", `no presence field '${field}' in the reducer's vocabulary`);
910
+ if (value === null) {
911
+ validated[field] = null;
912
+ continue;
913
+ }
914
+ const result = validateSync(schema, value, `presence field '${field}'`);
915
+ if (result.issues) throw new A2ClientError("INVALID_PAYLOAD", `invalid value for presence field '${field}'`, { details: result.issues });
916
+ validated[field] = result.value;
917
+ }
918
+ if (Object.keys(validated).length === 0) return;
919
+ const at = nextPresenceAt();
920
+ echoPresence(participant, validated, at);
921
+ if (status !== "live") presenceResend = {
922
+ ...presenceResend,
923
+ ...validated
924
+ };
925
+ queuePresenceSend(validated, at);
926
+ touch(sessionId);
927
+ };
928
+ /** Pushes awaiting stream confirmation — resolved by `ingest` the
929
+ * moment the state index passes their batch. */
930
+ let confirmWatchers = [];
931
+ /** The single ingest point: every server-confirmed event, in log
932
+ * order, from the stream. */
933
+ const ingest = (event) => {
934
+ if (event.index <= (streamIndex ?? 0)) return;
935
+ streamIndex = event.index;
936
+ if (event.index > stateIndex) {
937
+ stateIndex = event.index;
938
+ serverEvents.push(event);
939
+ foldedState = reducer.fold(foldedState, event);
940
+ } else mergeServerEvents([event], stateIndex);
941
+ pending = pending.filter((p) => p.id !== event.id);
942
+ if (confirmWatchers.some((w) => w.index <= stateIndex)) {
943
+ const due = confirmWatchers.filter((w) => w.index <= stateIndex);
944
+ confirmWatchers = confirmWatchers.filter((w) => w.index > stateIndex);
945
+ for (const watcher of due) watcher.resolve();
946
+ }
947
+ notify();
948
+ touch(sessionId);
949
+ };
950
+ const validated = (events) => events.map((event) => {
951
+ const schema = Object.hasOwn(reducer.events, event.type) ? reducer.events[event.type] : void 0;
952
+ if (!schema) throw new A2ClientError("UNKNOWN_EVENT_TYPE", `no event type '${String(event.type)}' in the reducer's vocabulary`);
953
+ const result = validateSync(schema, event.payload, `event '${String(event.type)}'`);
954
+ if (result.issues) throw new A2ClientError("INVALID_PAYLOAD", `invalid payload for event '${String(event.type)}'`, { details: result.issues });
955
+ return {
956
+ id: event.id ?? crypto.randomUUID(),
957
+ type: event.type,
958
+ payload: result.value
959
+ };
960
+ });
961
+ const post = async (body) => {
962
+ let lastPushError = new A2ClientError("STORE_UNAVAILABLE", "push failed");
963
+ for (let attempt = 1; attempt <= PUSH_ATTEMPTS; attempt += 1) {
964
+ try {
965
+ return (await transport.push(body)).map((row) => eventFromWire(row));
966
+ } catch (err) {
967
+ lastPushError = asClientError(err);
968
+ }
969
+ if (!RETRYABLE_PUSH_CODES.has(lastPushError.code)) throw lastPushError;
970
+ if (attempt < PUSH_ATTEMPTS) {
971
+ const sleepMs = 250 * attempt;
972
+ await Promise.all([new Promise((resolve) => setTimeout(resolve, sleepMs)), transport.awaitReady?.(sessionId, sleepMs + STREAM_TIMINGS.reconnectBaseMs * attempt)]);
973
+ }
974
+ }
975
+ throw lastPushError;
976
+ };
977
+ /**
978
+ * Decorate the ack promise into a PushResult. `confirmed` is a
979
+ * lazy getter — materialized on first access, so callers that
980
+ * ignore it can't leak an unhandled rejection.
981
+ */
982
+ const withConfirmed = (ack) => {
983
+ let confirmed;
984
+ return Object.defineProperty(ack, "confirmed", { get() {
985
+ confirmed ??= ack.then((acked) => new Promise((resolve) => {
986
+ const last = acked.at(-1)?.index ?? 0;
987
+ if (stateIndex >= last) {
988
+ resolve(acked);
989
+ return;
990
+ }
991
+ confirmWatchers.push({
992
+ index: last,
993
+ resolve: () => resolve(acked)
994
+ });
995
+ }));
996
+ return confirmed;
997
+ } });
998
+ };
999
+ const push = (...events) => {
1000
+ touch(sessionId);
1001
+ return withConfirmed((async () => {
1002
+ if (events.length === 0) throw new TypeError("push requires at least one event");
1003
+ const entries = validated(events).map((e) => ({
1004
+ id: e.id,
1005
+ type: e.type,
1006
+ payload: e.payload,
1007
+ createdAt: /* @__PURE__ */ new Date()
1008
+ }));
1009
+ const waitForPriorPush = pushWireTail;
1010
+ let releasePush;
1011
+ pushWireTail = new Promise((resolve) => {
1012
+ releasePush = resolve;
1013
+ });
1014
+ pending.push(...entries);
1015
+ inFlightPushes += 1;
1016
+ try {
1017
+ refreshIdleStream();
1018
+ notify();
1019
+ await waitForPriorPush;
1020
+ const acked = await post({
1021
+ sessionId,
1022
+ events: entries.map(({ id, type, payload }) => ({
1023
+ id,
1024
+ type,
1025
+ payload
1026
+ }))
1027
+ });
1028
+ for (const event of acked) {
1029
+ const entry = pending.find((p) => p.id === event.id);
1030
+ if (entry) entry.acked = event;
1031
+ }
1032
+ if ((acked.at(-1)?.index ?? 0) > (streamIndex ?? stateIndex)) refreshIdleStream();
1033
+ notify();
1034
+ return acked;
1035
+ } catch (err) {
1036
+ const ids = new Set(entries.map((e) => e.id));
1037
+ pending = pending.filter((p) => !ids.has(p.id));
1038
+ notify();
1039
+ throw err;
1040
+ } finally {
1041
+ releasePush();
1042
+ inFlightPushes -= 1;
1043
+ if (inFlightPushes === 0 && deferredHydration) {
1044
+ const next = deferredHydration;
1045
+ deferredHydration = void 0;
1046
+ runtime.hydrate(next);
1047
+ }
1048
+ touch(sessionId);
1049
+ }
1050
+ })());
1051
+ };
1052
+ let historyLoads = 0;
1053
+ /** Calls chain per session: each resolves its bounds after the
1054
+ * prior call's merge, so overlapping backscrolls page backward
1055
+ * instead of re-fetching the same range. */
1056
+ let historyTail = Promise.resolve();
1057
+ const eventsBetween = (gte, lte) => serverEvents.filter((event) => event.index >= gte && event.index <= lte);
1058
+ /** The narrowest sub-range of [gte, lte] missing from the feed, or
1059
+ * null when the range is fully loaded (nothing to fetch). */
1060
+ const missingBetween = (gte, lte) => {
1061
+ const present = new Set(eventsBetween(gte, lte).map((e) => e.index));
1062
+ let lo = gte;
1063
+ while (lo <= lte && present.has(lo)) lo += 1;
1064
+ if (lo > lte) return null;
1065
+ let hi = lte;
1066
+ while (hi > lo && present.has(hi)) hi -= 1;
1067
+ return {
1068
+ gte: lo,
1069
+ lte: hi
1070
+ };
1071
+ };
1072
+ const loadHistory = (loadOptions) => {
1073
+ const readHistory = transport.history;
1074
+ if (readHistory === void 0) throw new TypeError("loadHistory requires an http api — the ws transport has no history lane; serve the session with server.fetch and pass its route url as the string or { type: \"http\" } api");
1075
+ const before = loadOptions?.before;
1076
+ const limit = loadOptions?.limit;
1077
+ if (before !== void 0 && (!Number.isSafeInteger(before) || before < 1)) throw new TypeError("loadHistory before must be a positive safe integer");
1078
+ if (limit !== void 0 && (!Number.isSafeInteger(limit) || limit < 1)) throw new TypeError("loadHistory limit must be a positive safe integer");
1079
+ touch(sessionId);
1080
+ const run = historyTail.then(async () => {
1081
+ const upper = before ?? serverEvents[0]?.index ?? stateIndex + 1;
1082
+ const lte = Math.min(upper - 1, stateIndex);
1083
+ const gte = Math.max(1, upper - (limit ?? DEFAULT_HISTORY_LIMIT));
1084
+ if (gte > lte) return [];
1085
+ const missing = missingBetween(gte, lte);
1086
+ if (missing === null) return eventsBetween(gte, lte);
1087
+ historyLoads += 1;
1088
+ notify();
1089
+ try {
1090
+ const rows = await readHistory({
1091
+ sessionId,
1092
+ ...missing
1093
+ });
1094
+ mergeServerEvents(rows.map((row) => eventFromWire(row)), stateIndex);
1095
+ return eventsBetween(gte, lte);
1096
+ } catch (error) {
1097
+ throw asClientError(error);
1098
+ } finally {
1099
+ historyLoads -= 1;
1100
+ notify();
1101
+ touch(sessionId);
1102
+ }
1103
+ });
1104
+ historyTail = run.catch(() => {});
1105
+ return run;
1106
+ };
1107
+ let generation = 0;
1108
+ let active = false;
1109
+ let streamIdle = false;
1110
+ let abort = null;
1111
+ let leases = 0;
1112
+ let leaseEpoch = 0;
1113
+ const stopStream = () => {
1114
+ touch(sessionId);
1115
+ if (!active) return;
1116
+ active = false;
1117
+ streamIdle = false;
1118
+ generation += 1;
1119
+ abort?.abort();
1120
+ abort = null;
1121
+ clearTimeout(presenceTimer);
1122
+ presenceTimer = void 0;
1123
+ presenceBuffer = null;
1124
+ status = "closed";
1125
+ notify();
1126
+ };
1127
+ const runStream = async (run) => {
1128
+ let backoff = STREAM_TIMINGS.reconnectBaseMs;
1129
+ let attempt = 0;
1130
+ while (generation === run) {
1131
+ streamIdle = false;
1132
+ const controller = new AbortController();
1133
+ abort = controller;
1134
+ let stall;
1135
+ let stalled = false;
1136
+ const armStall = () => {
1137
+ clearTimeout(stall);
1138
+ stall = setTimeout(() => {
1139
+ stalled = true;
1140
+ controller.abort();
1141
+ }, STREAM_TIMINGS.stallTimeoutMs);
1142
+ stall.unref?.();
1143
+ };
1144
+ let wasLive = false;
1145
+ try {
1146
+ const frames = transport.connect({
1147
+ sessionId,
1148
+ startAfter: streamIndex ??= stateIndex,
1149
+ signal: controller.signal
1150
+ });
1151
+ for await (const frame of frames) {
1152
+ if (generation !== run) break;
1153
+ armStall();
1154
+ if (!wasLive) {
1155
+ backoff = STREAM_TIMINGS.reconnectBaseMs;
1156
+ attempt = 0;
1157
+ wasLive = true;
1158
+ status = "live";
1159
+ lastError = null;
1160
+ notify();
1161
+ if (generation !== run) break;
1162
+ if (presenceResend) {
1163
+ const values = presenceResend;
1164
+ presenceResend = null;
1165
+ queuePresenceSend(values);
1166
+ }
1167
+ }
1168
+ if (frame.kind === "event") {
1169
+ streamIdle = false;
1170
+ ingest(eventFromWire(frame.event));
1171
+ } else if (frame.kind === "activity") {
1172
+ streamIdle = frame.idle;
1173
+ if (streamIdle && (inFlightPushes > 0 || pending.some((entry) => (entry.acked?.index ?? 0) > (streamIndex ?? stateIndex)))) refreshIdleStream();
1174
+ } else if (frame.kind === "presence") applyPresencePatch(presencePatchFromWire(frame.patch));
1175
+ else if (frame.kind === "presence-snapshot") applyPresenceSnapshot(presenceSnapshotFromWire(frame.snapshot).snapshot);
1176
+ }
1177
+ if (generation === run) lastError = null;
1178
+ } catch (err) {
1179
+ if (generation === run) lastError = stalled ? new A2ClientError("TIMEOUT", `stream stalled: no data for ${STREAM_TIMINGS.stallTimeoutMs}ms`, {
1180
+ phase: "stream",
1181
+ timeoutMs: STREAM_TIMINGS.stallTimeoutMs
1182
+ }) : asClientError(err);
1183
+ } finally {
1184
+ clearTimeout(stall);
1185
+ }
1186
+ if (generation !== run) break;
1187
+ streamIdle = false;
1188
+ if (wasLive) reconnects += 1;
1189
+ attempt += 1;
1190
+ let delay;
1191
+ try {
1192
+ delay = options.reconnect ? options.reconnect({
1193
+ sessionId,
1194
+ error: lastError,
1195
+ attempt
1196
+ }) : backoff;
1197
+ if (delay !== false && (!Number.isFinite(delay) || delay < 0 || delay > 2147483647)) throw new RangeError("reconnect must return false or a delay from 0 to 2147483647 ms");
1198
+ } catch (error) {
1199
+ if (generation !== run) break;
1200
+ lastError = asClientError(error);
1201
+ delay = false;
1202
+ }
1203
+ if (generation !== run) break;
1204
+ if (delay === false) {
1205
+ abort = null;
1206
+ status = "paused";
1207
+ notify();
1208
+ return;
1209
+ }
1210
+ const waiting = new AbortController();
1211
+ abort = waiting;
1212
+ status = "connecting";
1213
+ notify();
1214
+ if (generation !== run) break;
1215
+ await new Promise((resolve) => {
1216
+ let settled = false;
1217
+ const finish = () => {
1218
+ if (settled) return;
1219
+ settled = true;
1220
+ clearTimeout(timer);
1221
+ waiting.signal.removeEventListener("abort", finish);
1222
+ resolve();
1223
+ };
1224
+ const timer = setTimeout(finish, delay);
1225
+ timer.unref?.();
1226
+ if (waiting.signal.aborted) finish();
1227
+ else waiting.signal.addEventListener("abort", finish, { once: true });
1228
+ });
1229
+ backoff = Math.min(backoff * 2, STREAM_TIMINGS.reconnectMaxMs);
1230
+ }
1231
+ };
1232
+ const refreshIdleStream = () => {
1233
+ if (streamIdle && status !== "paused") reconnectStream();
1234
+ };
1235
+ const reconnectStream = () => {
1236
+ touch(sessionId);
1237
+ if (!active) return;
1238
+ streamIdle = false;
1239
+ generation += 1;
1240
+ const run = generation;
1241
+ const previous = abort;
1242
+ abort = null;
1243
+ reconnects += 1;
1244
+ status = "connecting";
1245
+ lastError = null;
1246
+ notify();
1247
+ if (active && generation === run) runStream(run);
1248
+ previous?.abort();
1249
+ };
1250
+ const runtime = {
1251
+ sessionId,
1252
+ ...declaresPresence ? { setPresence } : {},
1253
+ subscribe(listener) {
1254
+ touch(sessionId);
1255
+ listeners.add(listener);
1256
+ return () => {
1257
+ listeners.delete(listener);
1258
+ touch(sessionId);
1259
+ };
1260
+ },
1261
+ getSnapshot() {
1262
+ snapshot ??= buildSnapshot();
1263
+ return snapshot;
1264
+ },
1265
+ push,
1266
+ loadHistory,
1267
+ reconnect: reconnectStream,
1268
+ connect() {
1269
+ touch(sessionId);
1270
+ leases += 1;
1271
+ const epoch = leaseEpoch;
1272
+ if (!active) {
1273
+ active = true;
1274
+ status = "connecting";
1275
+ lastError = null;
1276
+ const run = generation;
1277
+ notify();
1278
+ if (active && generation === run) runStream(run);
1279
+ }
1280
+ let released = false;
1281
+ return () => {
1282
+ if (released || leaseEpoch !== epoch) return;
1283
+ released = true;
1284
+ touch(sessionId);
1285
+ leases -= 1;
1286
+ if (leases === 0) stopStream();
1287
+ };
1288
+ },
1289
+ close() {
1290
+ leases = 0;
1291
+ leaseEpoch += 1;
1292
+ stopStream();
1293
+ },
1294
+ hydrate(next) {
1295
+ touch(sessionId);
1296
+ if (!next) return;
1297
+ participant ??= next.participant;
1298
+ const nextIndex = next.initialIndex ?? 0;
1299
+ if (nextIndex > stateIndex && inFlightPushes > 0) {
1300
+ if (!deferredHydration || nextIndex > (deferredHydration.initialIndex ?? 0)) deferredHydration = next;
1301
+ return;
1302
+ }
1303
+ const historyChanged = mergeServerEvents(next.initialEvents, Math.min(nextIndex, stateIndex));
1304
+ if (next.initialState !== void 0) streamIndex ??= nextIndex;
1305
+ if (next.initialState === void 0 || nextIndex <= stateIndex) {
1306
+ if (historyChanged) notifyHydrated();
1307
+ return;
1308
+ }
1309
+ stateIndex = nextIndex;
1310
+ foldedState = next.initialState;
1311
+ mergeServerEvents(next.initialEvents, stateIndex);
1312
+ pending = pending.filter((entry) => !entry.acked || entry.acked.index > stateIndex);
1313
+ if (confirmWatchers.some((watcher) => watcher.index <= stateIndex)) {
1314
+ const due = confirmWatchers.filter((watcher) => watcher.index <= stateIndex);
1315
+ confirmWatchers = confirmWatchers.filter((watcher) => watcher.index > stateIndex);
1316
+ for (const watcher of due) watcher.resolve();
1317
+ }
1318
+ notifyHydrated();
1319
+ },
1320
+ canEvict() {
1321
+ return listeners.size === 0 && !active && inFlightPushes === 0 && historyLoads === 0 && confirmWatchers.length === 0 && presenceTimer === void 0;
1322
+ }
1323
+ };
1324
+ return runtime;
1325
+ };
1326
+ return { session(sessionId, sessionOptions) {
1327
+ const existing = sessions.get(sessionId);
1328
+ if (existing) {
1329
+ existing.runtime.hydrate(sessionOptions);
1330
+ touch(sessionId);
1331
+ return existing.runtime;
1332
+ }
1333
+ const runtime = makeSession(sessionId, sessionOptions);
1334
+ sessions.set(sessionId, { runtime });
1335
+ touch(sessionId);
1336
+ return runtime;
1337
+ } };
1338
+ }
1339
+ //#endregion
1340
+ export { A2ClientError as n, createClient as t };
1341
+
1342
+ //# sourceMappingURL=client-Bf6uSEAk.js.map