@shadow-js/realtime 0.0.0-stage → 1.0.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 (128) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +114 -2
  4. package/dist/dev/adapter.d.ts +75 -0
  5. package/dist/dev/client/core.d.ts +24 -0
  6. package/dist/dev/client/core.js +845 -0
  7. package/dist/dev/client/core.js.map +1 -0
  8. package/dist/dev/client/index.d.ts +5 -0
  9. package/dist/dev/client/index.js +3 -0
  10. package/dist/dev/client/presence.js +23 -0
  11. package/dist/dev/client/presence.js.map +1 -0
  12. package/dist/dev/client/sse.js +76 -0
  13. package/dist/dev/client/sse.js.map +1 -0
  14. package/dist/dev/client/types.d.ts +256 -0
  15. package/dist/dev/client/util.js +30 -0
  16. package/dist/dev/client/util.js.map +1 -0
  17. package/dist/dev/client/ws.js +39 -0
  18. package/dist/dev/client/ws.js.map +1 -0
  19. package/dist/dev/cloudflare/adapter.d.ts +80 -0
  20. package/dist/dev/cloudflare/adapter.js +347 -0
  21. package/dist/dev/cloudflare/adapter.js.map +1 -0
  22. package/dist/dev/cloudflare/core.js +701 -0
  23. package/dist/dev/cloudflare/core.js.map +1 -0
  24. package/dist/dev/cloudflare/link.js +397 -0
  25. package/dist/dev/cloudflare/link.js.map +1 -0
  26. package/dist/dev/cloudflare/object.d.ts +82 -0
  27. package/dist/dev/cloudflare/object.js +214 -0
  28. package/dist/dev/cloudflare/object.js.map +1 -0
  29. package/dist/dev/cloudflare/types.d.ts +100 -0
  30. package/dist/dev/cloudflare/wire.js +284 -0
  31. package/dist/dev/cloudflare/wire.js.map +1 -0
  32. package/dist/dev/cloudflare.d.ts +4 -0
  33. package/dist/dev/cloudflare.js +3 -0
  34. package/dist/dev/errors.d.ts +25 -0
  35. package/dist/dev/errors.js +24 -0
  36. package/dist/dev/errors.js.map +1 -0
  37. package/dist/dev/hooks.d.ts +110 -0
  38. package/dist/dev/hooks.js +162 -0
  39. package/dist/dev/hooks.js.map +1 -0
  40. package/dist/dev/index.d.ts +4 -0
  41. package/dist/dev/index.js +3 -0
  42. package/dist/dev/memory.d.ts +33 -0
  43. package/dist/dev/memory.js +255 -0
  44. package/dist/dev/memory.js.map +1 -0
  45. package/dist/dev/protocol.d.ts +211 -0
  46. package/dist/dev/protocol.js +400 -0
  47. package/dist/dev/protocol.js.map +1 -0
  48. package/dist/dev/redis/client.d.ts +139 -0
  49. package/dist/dev/redis/client.js +146 -0
  50. package/dist/dev/redis/client.js.map +1 -0
  51. package/dist/dev/redis/scripts.js +275 -0
  52. package/dist/dev/redis/scripts.js.map +1 -0
  53. package/dist/dev/redis/topics.d.ts +8 -0
  54. package/dist/dev/redis/topics.js +377 -0
  55. package/dist/dev/redis/topics.js.map +1 -0
  56. package/dist/dev/redis.d.ts +68 -0
  57. package/dist/dev/redis.js +422 -0
  58. package/dist/dev/redis.js.map +1 -0
  59. package/dist/dev/server/http.d.ts +48 -0
  60. package/dist/dev/server/http.js +286 -0
  61. package/dist/dev/server/http.js.map +1 -0
  62. package/dist/dev/server/hub.d.ts +114 -0
  63. package/dist/dev/server/hub.js +827 -0
  64. package/dist/dev/server/hub.js.map +1 -0
  65. package/dist/dev/server/index.d.ts +47 -0
  66. package/dist/dev/server/index.js +41 -0
  67. package/dist/dev/server/index.js.map +1 -0
  68. package/dist/dev/server/limits.d.ts +54 -0
  69. package/dist/dev/server/limits.js +85 -0
  70. package/dist/dev/server/limits.js.map +1 -0
  71. package/dist/dev/server/rules.d.ts +43 -0
  72. package/dist/dev/server/rules.js +72 -0
  73. package/dist/dev/server/rules.js.map +1 -0
  74. package/dist/dev/testing.d.ts +115 -0
  75. package/dist/dev/testing.js +427 -0
  76. package/dist/dev/testing.js.map +1 -0
  77. package/dist/prod/client/core.js +2 -0
  78. package/dist/prod/client/core.js.map +1 -0
  79. package/dist/prod/client/index.js +1 -0
  80. package/dist/prod/client/presence.js +2 -0
  81. package/dist/prod/client/presence.js.map +1 -0
  82. package/dist/prod/client/sse.js +5 -0
  83. package/dist/prod/client/sse.js.map +1 -0
  84. package/dist/prod/client/util.js +2 -0
  85. package/dist/prod/client/util.js.map +1 -0
  86. package/dist/prod/client/ws.js +2 -0
  87. package/dist/prod/client/ws.js.map +1 -0
  88. package/dist/prod/cloudflare/adapter.js +3 -0
  89. package/dist/prod/cloudflare/adapter.js.map +1 -0
  90. package/dist/prod/cloudflare/core.js +2 -0
  91. package/dist/prod/cloudflare/core.js.map +1 -0
  92. package/dist/prod/cloudflare/link.js +2 -0
  93. package/dist/prod/cloudflare/link.js.map +1 -0
  94. package/dist/prod/cloudflare/object.js +2 -0
  95. package/dist/prod/cloudflare/object.js.map +1 -0
  96. package/dist/prod/cloudflare/wire.js +2 -0
  97. package/dist/prod/cloudflare/wire.js.map +1 -0
  98. package/dist/prod/cloudflare.js +1 -0
  99. package/dist/prod/errors.js +2 -0
  100. package/dist/prod/errors.js.map +1 -0
  101. package/dist/prod/hooks.js +2 -0
  102. package/dist/prod/hooks.js.map +1 -0
  103. package/dist/prod/index.js +1 -0
  104. package/dist/prod/memory.js +2 -0
  105. package/dist/prod/memory.js.map +1 -0
  106. package/dist/prod/protocol.js +2 -0
  107. package/dist/prod/protocol.js.map +1 -0
  108. package/dist/prod/redis/client.js +2 -0
  109. package/dist/prod/redis/client.js.map +1 -0
  110. package/dist/prod/redis/scripts.js +157 -0
  111. package/dist/prod/redis/scripts.js.map +1 -0
  112. package/dist/prod/redis/topics.js +2 -0
  113. package/dist/prod/redis/topics.js.map +1 -0
  114. package/dist/prod/redis.js +2 -0
  115. package/dist/prod/redis.js.map +1 -0
  116. package/dist/prod/server/http.js +2 -0
  117. package/dist/prod/server/http.js.map +1 -0
  118. package/dist/prod/server/hub.js +2 -0
  119. package/dist/prod/server/hub.js.map +1 -0
  120. package/dist/prod/server/index.js +2 -0
  121. package/dist/prod/server/index.js.map +1 -0
  122. package/dist/prod/server/limits.js +2 -0
  123. package/dist/prod/server/limits.js.map +1 -0
  124. package/dist/prod/server/rules.js +2 -0
  125. package/dist/prod/server/rules.js.map +1 -0
  126. package/dist/prod/testing.js +2 -0
  127. package/dist/prod/testing.js.map +1 -0
  128. package/package.json +105 -4
@@ -0,0 +1,845 @@
1
+ import { RealtimeError } from "../errors.js";
2
+ import { byteLength, decodeServerFrame, isChannelName, isEventName, isIdempotencyKey } from "../protocol.js";
3
+ import { applyPresence } from "./presence.js";
4
+ import { openStream } from "./sse.js";
5
+ import { backoff, clientId, resolveUrl, sendUrl } from "./util.js";
6
+ import { openSocket } from "./ws.js";
7
+ //#region src/client/core.ts
8
+ /** Close codes that mean the client did something the server will not accept again. */
9
+ const FATAL = /* @__PURE__ */ new Set([
10
+ 1002,
11
+ 1008,
12
+ 1009
13
+ ]);
14
+ /** Refusals that may heal by asking again. */
15
+ const TRANSIENT = /* @__PURE__ */ new Set([
16
+ "unavailable",
17
+ "rate_limited",
18
+ "timeout"
19
+ ]);
20
+ /** HTTP sl of a stream request that are worth retrying among the 4xx. */
21
+ const RETRYABLE_STATUS = /* @__PURE__ */ new Set([
22
+ 408,
23
+ 425,
24
+ 429
25
+ ]);
26
+ const MAX_FRAMES_PER_POST = 64;
27
+ const MAX_POST_BYTES = 256e3;
28
+ const MAX_STREAM_CHANNELS = 64;
29
+ const DEFAULT_REQUEST_MS = 1e4;
30
+ /**
31
+ * Creates the realtime client: one transport (WebSocket, falling back to server-sent ev), reconnects with backoff, a
32
+ * bounded offline queue, resume from the last sequence number and typed channels. It connects when the first channel
33
+ * listens or the first message is sent, never earlier.
34
+ *
35
+ * @param options The stream URL and the policy.
36
+ * @returns The client.
37
+ * @example
38
+ * import { createRealtime } from "@shadow-js/realtime/client";
39
+ *
40
+ * type Schema = { [name: `chat:${string}`]: { ev: { message: { text: string } } } };
41
+ * const rt = createRealtime<Schema>({ url: "/rt" });
42
+ * const lobby = rt.channel("chat:lobby");
43
+ * const stop = lobby.on("message", (data) => console.log(data.text));
44
+ * await lobby.publish("message", { text: "hello" });
45
+ * stop();
46
+ * rt.close();
47
+ * @since 1.0.0
48
+ */
49
+ function createRealtime(options) {
50
+ const reconnect = options.reconnect ?? {};
51
+ const minDelay = reconnect.minDelayMs ?? 250;
52
+ const maxDelay = reconnect.maxDelayMs ?? 3e4;
53
+ const stableMs = reconnect.stableMs ?? 5e3;
54
+ const random = reconnect.random ?? Math.random;
55
+ const maxQueued = options.maxQueued ?? 100;
56
+ const queueTtl = options.queueTtlMs ?? 3e4;
57
+ const ackTtl = options.ackTimeoutMs ?? 1e4;
58
+ const auto = (options.transport ?? "auto") === "auto";
59
+ const id = clientId();
60
+ const life = new AbortController();
61
+ const chans = /* @__PURE__ */ new Map();
62
+ const dirty = /* @__PURE__ */ new Set();
63
+ const queue = [];
64
+ const inflight = /* @__PURE__ */ new Map();
65
+ const statusListeners = /* @__PURE__ */ new Set();
66
+ let status = "connecting";
67
+ let mode = options.transport === "sse" ? "sse" : "ws";
68
+ let finished = false;
69
+ let scheduled = false;
70
+ let link;
71
+ let gen = 0;
72
+ let ready = false;
73
+ let connId;
74
+ let maxBytes = 65536;
75
+ let streamSig = "";
76
+ let byeCode;
77
+ let wsFails = 0;
78
+ let attempt = 0;
79
+ let counter = 0;
80
+ let keys = 0;
81
+ let orders = 0;
82
+ let received = false;
83
+ let silent = 0;
84
+ let connectTimer;
85
+ let reconnectTimer;
86
+ let stableTimer;
87
+ let beatTimer;
88
+ let posting = false;
89
+ let postTimer;
90
+ let postFails = 0;
91
+ let listening = false;
92
+ const report = (error) => {
93
+ try {
94
+ if (options.onError !== void 0) options.onError(error);
95
+ else console.error("[realtime]", error);
96
+ } catch {}
97
+ };
98
+ const guard = (run) => {
99
+ try {
100
+ run();
101
+ } catch (error) {
102
+ report(error);
103
+ }
104
+ };
105
+ const doFetch = (input, init) => (options.fetch ?? globalThis.fetch)(input, init);
106
+ const getHeaders = () => typeof options.headers === "function" ? options.headers() : options.headers;
107
+ const emit = (listeners, value) => {
108
+ for (const listener of [...listeners]) guard(() => listener(value));
109
+ };
110
+ const setStatus = (next) => {
111
+ if (next === status) return;
112
+ status = next;
113
+ if (options.onStatus !== void 0) guard(() => options.onStatus?.(next));
114
+ emit(statusListeners, next);
115
+ };
116
+ const setChanStatus = (chan, next) => {
117
+ if (chan.status === next) return;
118
+ chan.status = next;
119
+ emit(chan.sl, next);
120
+ };
121
+ /** Settles an operation once; an unacknowledged publish that fails is reported instead. */
122
+ const finish = (op, error, value) => {
123
+ clearTimeout(op.timer);
124
+ const settle = op.settle;
125
+ op.settle = void 0;
126
+ if (settle !== void 0) settle(error, value);
127
+ else if (error !== void 0 && op.kind === "pub") report(error);
128
+ };
129
+ const fail = (op, error) => finish(op, error);
130
+ const succeed = (op, value) => finish(op, void 0, value);
131
+ const arm = (op, ms) => {
132
+ clearTimeout(op.timer);
133
+ op.timer = setTimeout(() => {
134
+ const at = queue.indexOf(op);
135
+ if (at >= 0) queue.splice(at, 1);
136
+ if (inflight.get(op.n) === op) inflight.delete(op.n);
137
+ fail(op, new RealtimeError("timeout"));
138
+ }, ms);
139
+ };
140
+ const take = (n) => {
141
+ const op = inflight.get(n);
142
+ if (op !== void 0) inflight.delete(n);
143
+ return op;
144
+ };
145
+ const enqueue = (op) => {
146
+ if (finished) return new RealtimeError("closed");
147
+ const flowing = ready && mode === "ws";
148
+ if (op.kind !== "pres" && !flowing && queue.length >= maxQueued) return new RealtimeError("queue_full");
149
+ if (op.kind === "pres") {
150
+ const old = queue.findIndex((item) => item.kind === "pres" && item.chan === op.chan);
151
+ if (old >= 0) queue.splice(old, 1);
152
+ } else arm(op, queueTtl);
153
+ queue.push(op);
154
+ if (ready) flush();
155
+ else schedule();
156
+ };
157
+ const newOp = (kind, chan, event, data, extra) => ({
158
+ kind,
159
+ chan,
160
+ event,
161
+ data,
162
+ key: extra.key,
163
+ ack: extra.ack ?? true,
164
+ ms: extra.ms ?? 0,
165
+ order: ++orders,
166
+ n: 0,
167
+ timer: void 0,
168
+ settle: extra.settle
169
+ });
170
+ /** The frame text of an operation, numbered for an answer. Undefined (and the operation failed) when it is too large. */
171
+ const encode = (op) => {
172
+ const c = JSON.stringify(op.chan.name);
173
+ const wantsAnswer = op.kind === "req" || op.kind === "pub" && op.ack;
174
+ op.n = wantsAnswer ? ++counter : 0;
175
+ const text = op.kind === "pres" ? `{"t":"pres","c":${c},"s":${op.data}}` : op.kind === "req" ? `{"t":"req","n":${op.n},"c":${c},"e":"${op.event}","d":${op.data},"ms":${op.ms}}` : `{"t":"pub","c":${c},"e":"${op.event}","d":${op.data}${wantsAnswer ? `,"n":${op.n}` : ""},"k":${JSON.stringify(op.key)}}`;
176
+ if (byteLength(text) > maxBytes) {
177
+ fail(op, new RealtimeError("too_large"));
178
+ return;
179
+ }
180
+ return text;
181
+ };
182
+ /** Records that an operation went out: it now waits for an answer, or is done. */
183
+ const sent = (op) => {
184
+ if (op.kind === "pres") return;
185
+ if (op.kind === "req" || op.ack) {
186
+ inflight.set(op.n, op);
187
+ arm(op, op.kind === "req" ? op.ms + 1e3 : ackTtl);
188
+ } else clearTimeout(op.timer);
189
+ };
190
+ /** Puts operations that went out on a connection that died back at the head of the queue, in their original order. */
191
+ const requeue = (ops) => {
192
+ ops.sort((a, b) => a.order - b.order);
193
+ const again = [];
194
+ for (const op of ops) if (op.kind === "req") fail(op, new RealtimeError("closed"));
195
+ else {
196
+ if (op.kind === "pub") arm(op, queueTtl);
197
+ again.push(op);
198
+ }
199
+ queue.unshift(...again);
200
+ };
201
+ const wants = (chan) => !chan.closed && (chan.refs > 0 || chan.state !== void 0 || chan.options.watch === true || chan.hd > 0 && mode === "ws");
202
+ const joins = (chan) => chan.state !== void 0;
203
+ const watches = (chan) => !joins(chan) && (chan.presence.size > 0 || chan.options.watch === true);
204
+ const touch = (chan) => {
205
+ if (chan.status === "idle" && wants(chan)) setChanStatus(chan, "subscribing");
206
+ dirty.add(chan);
207
+ schedule();
208
+ };
209
+ /** The fields a subscription goes out with, over a WebSocket as a frame and over SSE as a query entry. */
210
+ const subscription = (chan, n) => {
211
+ const join = joins(chan);
212
+ const watch = watches(chan);
213
+ const after = chan.sy ? chan.last : chan.options.replay === true ? 0 : void 0;
214
+ chan.w = {
215
+ n,
216
+ join,
217
+ watch,
218
+ after,
219
+ ver: chan.sv,
220
+ ready: !join
221
+ };
222
+ setChanStatus(chan, chan.sy ? "resuming" : "subscribing");
223
+ return { fields: {
224
+ c: chan.name,
225
+ ...after === void 0 ? {} : { after },
226
+ ...chan.options.echo === false ? { echo: false } : {},
227
+ ...join ? { presence: chan.state } : {},
228
+ ...watch ? { watch: true } : {}
229
+ } };
230
+ };
231
+ const sendPresence = (chan) => {
232
+ const w = chan.w;
233
+ if (w === void 0 || chan.state === void 0) return;
234
+ w.ver = chan.sv;
235
+ const op = newOp("pres", chan, "", JSON.stringify(chan.state), {});
236
+ if (mode === "ws") {
237
+ const text = encode(op);
238
+ if (text !== void 0) link?.send(text);
239
+ } else enqueue(op);
240
+ };
241
+ const goIdle = (chan) => {
242
+ chan.sy = false;
243
+ chan.last = 0;
244
+ chan.w = void 0;
245
+ clearTimeout(chan.retry);
246
+ chan.retry = void 0;
247
+ chan.fails = 0;
248
+ if (chan.members.size > 0) {
249
+ chan.members.clear();
250
+ chan.list = void 0;
251
+ }
252
+ setChanStatus(chan, chan.closed ? "closed" : "idle");
253
+ };
254
+ /** Brings the w in line with what a channel wants (WebSocket: subscribe, unsubscribe, presence). */
255
+ const syncChan = (chan) => {
256
+ const want = wants(chan);
257
+ const w = chan.w;
258
+ if (mode === "ws" && ready) {
259
+ if (w !== void 0 && (!want || w.join !== joins(chan) || w.watch !== watches(chan))) {
260
+ link?.send(`{"t":"unsub","c":${JSON.stringify(chan.name)}}`);
261
+ chan.w = void 0;
262
+ }
263
+ if (want && chan.w === void 0 && chan.retry === void 0) {
264
+ const n = ++counter;
265
+ link?.send(JSON.stringify({
266
+ t: "sub",
267
+ n,
268
+ ...subscription(chan, n).fields
269
+ }));
270
+ }
271
+ }
272
+ if (!want) {
273
+ if (chan.status !== "idle" && chan.status !== "closed") goIdle(chan);
274
+ return;
275
+ }
276
+ const current = chan.w;
277
+ if (current?.join && current.ready && current.ver !== chan.sv) sendPresence(chan);
278
+ };
279
+ const syncAll = () => {
280
+ const batch = [...dirty];
281
+ dirty.clear();
282
+ for (const chan of batch) syncChan(chan);
283
+ };
284
+ const closeChan = (chan, error) => {
285
+ if (chan.closed) return;
286
+ chan.closed = true;
287
+ if (chans.get(chan.name) === chan) chans.delete(chan.name);
288
+ if (chan.w !== void 0 && mode === "ws" && ready) link?.send(`{"t":"unsub","c":${JSON.stringify(chan.name)}}`);
289
+ goIdle(chan);
290
+ chan.ev.clear();
291
+ chan.any.clear();
292
+ chan.gl.clear();
293
+ chan.presence.clear();
294
+ chan.sl.clear();
295
+ chan.refs = 0;
296
+ dirty.delete(chan);
297
+ if (error !== void 0) report(error);
298
+ schedule();
299
+ };
300
+ /** Subscribes again after a refusal or a removal the server announced; `now` skips the delay. */
301
+ const resubscribe = (chan, now) => {
302
+ chan.w = void 0;
303
+ setChanStatus(chan, chan.sy ? "resuming" : "subscribing");
304
+ if (now) {
305
+ touch(chan);
306
+ return;
307
+ }
308
+ chan.retry = setTimeout(() => {
309
+ chan.retry = void 0;
310
+ touch(chan);
311
+ }, backoff(chan.fails, minDelay, maxDelay, random));
312
+ chan.fails += 1;
313
+ };
314
+ /** Declares the subscriptions of an SSE stream; returns the query entries. */
315
+ const declare = () => {
316
+ const entries = [];
317
+ for (const chan of [...chans.values()]) {
318
+ if (!wants(chan)) continue;
319
+ if (entries.length >= MAX_STREAM_CHANNELS) {
320
+ closeChan(chan, new RealtimeError("limit"));
321
+ continue;
322
+ }
323
+ entries.push(subscription(chan, entries.length + 1).fields);
324
+ }
325
+ streamSig = signature();
326
+ return entries;
327
+ };
328
+ /** What the open stream must be reopened for: the channels it carries and how they join. */
329
+ const signature = () => {
330
+ let sig = "";
331
+ for (const chan of chans.values()) if (wants(chan)) sig += `${chan.name}|${chan.options.echo === false ? 0 : 1}${joins(chan) ? 1 : 0}${watches(chan) ? 1 : 0};`;
332
+ return sig;
333
+ };
334
+ const connect = () => {
335
+ clearTimeout(reconnectTimer);
336
+ reconnectTimer = void 0;
337
+ if (finished || link !== void 0) return;
338
+ gen += 1;
339
+ const g = gen;
340
+ ready = false;
341
+ received = false;
342
+ silent = 0;
343
+ const ev = {
344
+ rx: () => {
345
+ if (g === gen) received = true;
346
+ },
347
+ frame: (frame) => {
348
+ if (g === gen) guard(() => onFrame(frame, false));
349
+ },
350
+ closed: (info) => {
351
+ if (g === gen) linkClosed(info);
352
+ }
353
+ };
354
+ try {
355
+ if (!listening && typeof addEventListener === "function") {
356
+ listening = true;
357
+ addEventListener("online", onOnline);
358
+ }
359
+ if (mode === "sse") {
360
+ const url = resolveUrl(options.url, false);
361
+ url.searchParams.set("s", JSON.stringify(declare()));
362
+ link = openStream(url.toString(), doFetch, getHeaders, ev);
363
+ } else link = openSocket(resolveUrl(options.url, true).toString(), options.WebSocket ?? globalThis.WebSocket, ev);
364
+ } catch (error) {
365
+ gen += 1;
366
+ shutdown(new RealtimeError("closed", "cannot open", { cause: error }));
367
+ return;
368
+ }
369
+ connectTimer = setTimeout(() => {
370
+ if (g === gen) linkClosed({});
371
+ }, ackTtl);
372
+ };
373
+ const onOnline = () => {
374
+ if (reconnectTimer !== void 0) connect();
375
+ };
376
+ /** Releases the current transport and everything tied to it. */
377
+ const drop = () => {
378
+ gen += 1;
379
+ const old = link;
380
+ link = void 0;
381
+ ready = false;
382
+ connId = void 0;
383
+ clearTimeout(connectTimer);
384
+ clearTimeout(stableTimer);
385
+ clearInterval(beatTimer);
386
+ beatTimer = void 0;
387
+ old?.close();
388
+ };
389
+ const linkClosed = (info) => {
390
+ const wasReady = ready;
391
+ const code = byeCode ?? info.code;
392
+ byeCode = void 0;
393
+ drop();
394
+ if (finished) return;
395
+ if (mode === "ws" && inflight.size > 0) {
396
+ const lost = [...inflight.values()];
397
+ inflight.clear();
398
+ requeue(lost);
399
+ }
400
+ for (const chan of chans.values()) {
401
+ chan.w = void 0;
402
+ clearTimeout(chan.retry);
403
+ chan.retry = void 0;
404
+ if (wants(chan)) setChanStatus(chan, chan.sy ? "resuming" : "subscribing");
405
+ }
406
+ const refused = info.status !== void 0 && info.status >= 400 && info.status < 500 && !RETRYABLE_STATUS.has(info.status);
407
+ if ((code !== void 0 && FATAL.has(code) || refused) && reconnect.retryFatal !== true) {
408
+ const why = code !== void 0 ? `the server closed the connection with ${code}` : `the server answered ${info.status}`;
409
+ shutdown(new RealtimeError("closed", why));
410
+ return;
411
+ }
412
+ let delay = backoff(attempt, minDelay, maxDelay, random);
413
+ attempt += 1;
414
+ if (wasReady) wsFails = 0;
415
+ else if (auto && mode === "ws") {
416
+ wsFails += 1;
417
+ if (wsFails >= 2) {
418
+ mode = "sse";
419
+ delay = 0;
420
+ }
421
+ }
422
+ setStatus("reconnecting");
423
+ reconnectTimer = setTimeout(connect, delay);
424
+ };
425
+ /** Heartbeat: every interval sends a ping; two intervals with nothing received declare the connection dead. */
426
+ const beat = () => {
427
+ if (received) {
428
+ received = false;
429
+ silent = 0;
430
+ } else {
431
+ silent += 1;
432
+ if (silent >= 2) {
433
+ linkClosed({});
434
+ return;
435
+ }
436
+ }
437
+ if (mode === "ws") link?.send("{\"t\":\"ping\"}");
438
+ };
439
+ const reopen = () => {
440
+ drop();
441
+ for (const chan of chans.values()) chan.w = void 0;
442
+ connect();
443
+ };
444
+ const onReady = (frame) => {
445
+ if (ready) return;
446
+ ready = true;
447
+ connId = frame.id;
448
+ maxBytes = frame.max;
449
+ wsFails = 0;
450
+ received = true;
451
+ silent = 0;
452
+ clearTimeout(connectTimer);
453
+ if (frame.hb > 0) beatTimer = setInterval(beat, frame.hb);
454
+ stableTimer = setTimeout(() => {
455
+ attempt = 0;
456
+ }, stableMs);
457
+ setStatus("open");
458
+ if (mode === "ws") for (const chan of chans.values()) dirty.add(chan);
459
+ flush();
460
+ };
461
+ const notifyPresence = (chan) => {
462
+ const members = [...chan.members.values()];
463
+ chan.list = members;
464
+ emit(chan.presence, members);
465
+ };
466
+ const onFrame = (frame, viaPost) => {
467
+ switch (frame.t) {
468
+ case "ready":
469
+ onReady(frame);
470
+ return;
471
+ case "bye":
472
+ byeCode = frame.code;
473
+ return;
474
+ case "subbed": {
475
+ const chan = chans.get(frame.c);
476
+ const w = chan?.w;
477
+ if (chan === void 0 || w === void 0 || w.n !== frame.n) return;
478
+ if (frame.reset || w.after === void 0) chan.last = frame.head;
479
+ const info = {
480
+ after: w.after ?? 0,
481
+ head: frame.head
482
+ };
483
+ const gap = frame.reset ? true : frame.gap === true && w.after !== void 0 && !(w.after === 0 && !chan.sy);
484
+ chan.sy = true;
485
+ chan.fails = 0;
486
+ setChanStatus(chan, "live");
487
+ if (gap) emit(chan.gl, {
488
+ ...info,
489
+ reset: frame.reset === true
490
+ });
491
+ return;
492
+ }
493
+ case "unsubbed": {
494
+ const chan = chans.get(frame.c);
495
+ if (chan === void 0) return;
496
+ if (frame.why === "revoked") {
497
+ chan.w = void 0;
498
+ closeChan(chan, new RealtimeError("forbidden", "revoked"));
499
+ } else resubscribe(chan, frame.why === "gap" || frame.why === "reset");
500
+ return;
501
+ }
502
+ case "msg": {
503
+ const chan = chans.get(frame.c);
504
+ if (chan === void 0 || frame.s <= chan.last) return;
505
+ chan.last = frame.s;
506
+ const meta = {
507
+ seq: frame.s,
508
+ replayed: frame.r === 1,
509
+ ...frame.p === void 0 ? {} : { publisher: frame.p },
510
+ ...frame.u === void 0 ? {} : { user: frame.u }
511
+ };
512
+ for (const handler of [...chan.ev.get(frame.e) ?? []]) guard(() => handler(frame.d, meta));
513
+ for (const handler of [...chan.any]) guard(() => handler({
514
+ event: frame.e,
515
+ data: frame.d,
516
+ meta
517
+ }));
518
+ return;
519
+ }
520
+ case "ack": {
521
+ const op = take(frame.n);
522
+ if (op !== void 0) succeed(op, {
523
+ seq: frame.s,
524
+ duplicate: frame.dup === true
525
+ });
526
+ return;
527
+ }
528
+ case "rep": {
529
+ const op = take(frame.n);
530
+ if (op !== void 0) succeed(op, frame.d);
531
+ return;
532
+ }
533
+ case "err": {
534
+ const error = new RealtimeError(frame.code, frame.msg);
535
+ const chan = frame.c === void 0 ? void 0 : chans.get(frame.c);
536
+ if (frame.n !== void 0) {
537
+ if (!viaPost && chan?.w?.n === frame.n) {
538
+ if (TRANSIENT.has(frame.code)) {
539
+ resubscribe(chan, false);
540
+ report(error);
541
+ } else {
542
+ chan.w = void 0;
543
+ closeChan(chan, error);
544
+ }
545
+ return;
546
+ }
547
+ const op = viaPost || mode === "ws" ? take(frame.n) : void 0;
548
+ if (op !== void 0) {
549
+ fail(op, error);
550
+ return;
551
+ }
552
+ }
553
+ report(error);
554
+ return;
555
+ }
556
+ case "pres": {
557
+ const chan = chans.get(frame.c);
558
+ if (chan === void 0) return;
559
+ if (applyPresence(chan.members, frame.op, frame.m)) notifyPresence(chan);
560
+ if (frame.op === "sync" && chan.w !== void 0) {
561
+ chan.w.ready = true;
562
+ if (chan.w.join && chan.w.ver !== chan.sv) sendPresence(chan);
563
+ }
564
+ return;
565
+ }
566
+ case "pong": return;
567
+ }
568
+ };
569
+ const flush = () => {
570
+ if (!ready || finished) return;
571
+ syncAll();
572
+ if (mode === "sse") {
573
+ postNext();
574
+ return;
575
+ }
576
+ for (let op = queue[0]; op !== void 0 && ready; op = queue[0]) {
577
+ const text = encode(op);
578
+ if (text !== void 0) {
579
+ if (link?.send(text) !== true) return;
580
+ sent(op);
581
+ }
582
+ queue.shift();
583
+ }
584
+ };
585
+ const postNext = () => {
586
+ if (posting || postTimer !== void 0 || connId === void 0) return;
587
+ const ops = [];
588
+ const texts = [];
589
+ let bytes = 0;
590
+ while (queue.length > 0 && ops.length < MAX_FRAMES_PER_POST && bytes < MAX_POST_BYTES) {
591
+ const op = queue.shift();
592
+ if (op === void 0) break;
593
+ const text = encode(op);
594
+ if (text === void 0) continue;
595
+ ops.push(op);
596
+ texts.push(text);
597
+ bytes += text.length;
598
+ }
599
+ if (ops.length === 0) return;
600
+ posting = true;
601
+ postBatch(ops, `{"cid":${JSON.stringify(connId)},"frames":[${texts.join(",")}]}`).then(void 0, report);
602
+ };
603
+ const postBatch = async (ops, body) => {
604
+ for (const op of ops) sent(op);
605
+ let again = false;
606
+ try {
607
+ const headers = new Headers(await getHeaders());
608
+ headers.set("content-type", "application/json");
609
+ const url = options.postUrl === void 0 ? sendUrl(resolveUrl(options.url, false)) : resolveUrl(options.postUrl, false);
610
+ const response = await doFetch(url, {
611
+ method: "POST",
612
+ headers,
613
+ body,
614
+ signal: life.signal
615
+ });
616
+ if (response.ok) {
617
+ const parsed = await response.json();
618
+ if (Array.isArray(parsed.frames)) for (const item of parsed.frames) {
619
+ const frame = decodeServerFrame(JSON.stringify(item));
620
+ if (frame !== void 0) guard(() => onFrame(frame, true));
621
+ }
622
+ postFails = 0;
623
+ } else if (response.status >= 500 || RETRYABLE_STATUS.has(response.status)) again = true;
624
+ else {
625
+ const refusal = await response.json().catch(() => void 0);
626
+ const code = refusal?.error?.code ?? "unavailable";
627
+ for (const op of ops) {
628
+ if (inflight.get(op.n) === op) inflight.delete(op.n);
629
+ fail(op, new RealtimeError(code, refusal?.error?.message));
630
+ }
631
+ }
632
+ } catch {
633
+ if (life.signal.aborted) return;
634
+ again = true;
635
+ }
636
+ posting = false;
637
+ const unanswered = ops.filter((op) => (op.kind === "req" || op.kind === "pub" && op.ack) && inflight.get(op.n) === op);
638
+ for (const op of unanswered) inflight.delete(op.n);
639
+ if (again) {
640
+ for (const op of unanswered) if (op.kind === "req") fail(op, new RealtimeError("unavailable"));
641
+ requeue(ops.filter((op) => op.kind === "pres" || op.kind === "pub" && (!op.ack || unanswered.includes(op))));
642
+ postFails += 1;
643
+ postTimer = setTimeout(() => {
644
+ postTimer = void 0;
645
+ flush();
646
+ }, backoff(postFails - 1, minDelay, maxDelay, random));
647
+ } else for (const op of unanswered) fail(op, new RealtimeError("unavailable"));
648
+ flush();
649
+ };
650
+ const needed = () => queue.length > 0 || [...chans.values()].some(wants);
651
+ const run = () => {
652
+ scheduled = false;
653
+ if (finished) return;
654
+ syncAll();
655
+ if (link === void 0) {
656
+ if (reconnectTimer === void 0 && needed()) connect();
657
+ return;
658
+ }
659
+ if (mode === "sse" && (signature() !== streamSig || [...chans.values()].some((c) => wants(c) && c.w === void 0 && c.retry === void 0))) reopen();
660
+ else flush();
661
+ };
662
+ function schedule() {
663
+ if (scheduled || finished) return;
664
+ scheduled = true;
665
+ queueMicrotask(run);
666
+ }
667
+ function shutdown(error) {
668
+ if (finished) return;
669
+ finished = true;
670
+ clearTimeout(reconnectTimer);
671
+ clearTimeout(postTimer);
672
+ reconnectTimer = void 0;
673
+ postTimer = void 0;
674
+ drop();
675
+ life.abort();
676
+ if (listening && typeof removeEventListener === "function") removeEventListener("online", onOnline);
677
+ listening = false;
678
+ const closed = error ?? new RealtimeError("closed");
679
+ const pending = [...queue.splice(0), ...inflight.values()];
680
+ inflight.clear();
681
+ for (const op of pending) if (op.settle === void 0) clearTimeout(op.timer);
682
+ else fail(op, closed);
683
+ for (const chan of [...chans.values()]) closeChan(chan);
684
+ chans.clear();
685
+ dirty.clear();
686
+ setStatus("closed");
687
+ statusListeners.clear();
688
+ if (error !== void 0) report(error);
689
+ }
690
+ const makeChannel = (name, channelOptions) => {
691
+ const chan = {
692
+ name,
693
+ options: channelOptions,
694
+ ev: /* @__PURE__ */ new Map(),
695
+ any: /* @__PURE__ */ new Set(),
696
+ gl: /* @__PURE__ */ new Set(),
697
+ sl: /* @__PURE__ */ new Set(),
698
+ presence: /* @__PURE__ */ new Set(),
699
+ members: /* @__PURE__ */ new Map(),
700
+ list: void 0,
701
+ status: "idle",
702
+ refs: 0,
703
+ hd: 0,
704
+ last: 0,
705
+ sy: false,
706
+ state: channelOptions.presence,
707
+ sv: 0,
708
+ w: void 0,
709
+ retry: void 0,
710
+ fails: 0,
711
+ closed: false,
712
+ hn: void 0
713
+ };
714
+ chans.set(name, chan);
715
+ if (wants(chan)) touch(chan);
716
+ /** Registers a listener; unless `passive`, it keeps the channel subscribed until removed. */
717
+ const listen = (set, item, passive, after) => {
718
+ if (chan.closed) return () => void 0;
719
+ set.add(item);
720
+ if (!passive) {
721
+ chan.refs += 1;
722
+ touch(chan);
723
+ }
724
+ return () => {
725
+ if (!set.delete(item) || passive) return;
726
+ chan.refs -= 1;
727
+ after?.();
728
+ touch(chan);
729
+ };
730
+ };
731
+ /** Validates and queues a publish or a request; the promise settles with its answer. */
732
+ const submit = (kind, event, data, key, ack, ms) => {
733
+ let text = "null";
734
+ let refusal;
735
+ if (chan.closed) refusal = new RealtimeError("closed");
736
+ else if (!isEventName(event) || key !== void 0 && !isIdempotencyKey(key)) refusal = new RealtimeError("bad_frame");
737
+ else try {
738
+ text = JSON.stringify(data ?? null) ?? "null";
739
+ } catch (cause) {
740
+ refusal = new RealtimeError("bad_frame", "not JSON", { cause });
741
+ }
742
+ if (refusal !== void 0) return Promise.reject(refusal);
743
+ return new Promise((resolve, reject) => {
744
+ const request = kind === "req";
745
+ const op = newOp(kind, chan, event, text, {
746
+ key: request ? void 0 : key ?? `${id}:${++keys}`,
747
+ ack,
748
+ ms,
749
+ settle: ack ? (error, value) => {
750
+ if (request) {
751
+ chan.hd -= 1;
752
+ touch(chan);
753
+ }
754
+ if (error) reject(error);
755
+ else resolve(value);
756
+ } : void 0
757
+ });
758
+ if (request) {
759
+ chan.hd += 1;
760
+ touch(chan);
761
+ }
762
+ const refused = enqueue(op);
763
+ if (refused !== void 0) {
764
+ op.settle = void 0;
765
+ if (request) chan.hd -= 1;
766
+ reject(refused);
767
+ } else if (!ack) resolve({
768
+ seq: 0,
769
+ duplicate: false
770
+ });
771
+ });
772
+ };
773
+ const hn = {
774
+ name,
775
+ get status() {
776
+ return chan.status;
777
+ },
778
+ get lastSeq() {
779
+ return chan.last;
780
+ },
781
+ on(event, handler) {
782
+ let set = chan.ev.get(event);
783
+ if (set === void 0) {
784
+ set = /* @__PURE__ */ new Set();
785
+ chan.ev.set(event, set);
786
+ }
787
+ const handlers = set;
788
+ return listen(handlers, handler, false, () => {
789
+ if (handlers.size === 0 && chan.ev.get(event) === handlers) chan.ev.delete(event);
790
+ });
791
+ },
792
+ onAny: (handler) => listen(chan.any, handler),
793
+ onGap: (handler) => listen(chan.gl, handler),
794
+ onStatus: (handler) => listen(chan.sl, handler, true),
795
+ publish: (event, data, publishOptions = {}) => submit("pub", event, data, publishOptions.key, publishOptions.ack !== false, 0),
796
+ request: (event, data, requestOptions = {}) => submit("req", event, data, void 0, true, requestOptions.timeoutMs ?? DEFAULT_REQUEST_MS),
797
+ presence: {
798
+ members: () => {
799
+ chan.list ??= [...chan.members.values()];
800
+ return chan.list;
801
+ },
802
+ set(state) {
803
+ if (chan.closed) return;
804
+ chan.state = state;
805
+ chan.sv += 1;
806
+ touch(chan);
807
+ },
808
+ leave() {
809
+ if (chan.closed || chan.state === void 0) return;
810
+ chan.state = void 0;
811
+ chan.sv += 1;
812
+ touch(chan);
813
+ },
814
+ on: (callback) => listen(chan.presence, callback)
815
+ },
816
+ close: () => closeChan(chan)
817
+ };
818
+ chan.hn = hn;
819
+ return hn;
820
+ };
821
+ return {
822
+ channel(name, channelOptions = {}) {
823
+ if (finished) throw new RealtimeError("closed");
824
+ if (!isChannelName(name)) throw new RealtimeError("bad_channel", name);
825
+ return chans.get(name)?.hn ?? makeChannel(name, channelOptions);
826
+ },
827
+ get status() {
828
+ return status;
829
+ },
830
+ get connectionId() {
831
+ return connId;
832
+ },
833
+ onStatus(handler) {
834
+ if (!finished) statusListeners.add(handler);
835
+ return () => {
836
+ statusListeners.delete(handler);
837
+ };
838
+ },
839
+ close: () => shutdown()
840
+ };
841
+ }
842
+ //#endregion
843
+ export { createRealtime };
844
+
845
+ //# sourceMappingURL=core.js.map