@shadow-js/realtime 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +35 -0
  3. package/dist/dev/adapter.d.ts +0 -2
  4. package/dist/dev/client/core.d.ts +0 -2
  5. package/dist/dev/client/core.js +105 -108
  6. package/dist/dev/client/core.js.map +1 -1
  7. package/dist/dev/client/presence.js +1 -2
  8. package/dist/dev/client/sse.js +34 -26
  9. package/dist/dev/client/sse.js.map +1 -1
  10. package/dist/dev/client/types.d.ts +6 -2
  11. package/dist/dev/client/util.js +3 -4
  12. package/dist/dev/client/util.js.map +1 -1
  13. package/dist/dev/client/ws.js +1 -2
  14. package/dist/dev/cloudflare/adapter.d.ts +0 -2
  15. package/dist/dev/cloudflare/adapter.js +1 -2
  16. package/dist/dev/cloudflare/core.js +1 -2
  17. package/dist/dev/cloudflare/link.js +70 -10
  18. package/dist/dev/cloudflare/link.js.map +1 -1
  19. package/dist/dev/cloudflare/object.d.ts +0 -2
  20. package/dist/dev/cloudflare/object.js +1 -2
  21. package/dist/dev/cloudflare/types.d.ts +0 -2
  22. package/dist/dev/cloudflare/wire.js +1 -2
  23. package/dist/dev/errors.d.ts +0 -2
  24. package/dist/dev/errors.js +1 -2
  25. package/dist/dev/hooks.d.ts +0 -2
  26. package/dist/dev/hooks.js +1 -2
  27. package/dist/dev/memory.d.ts +11 -2
  28. package/dist/dev/memory.js +39 -12
  29. package/dist/dev/memory.js.map +1 -1
  30. package/dist/dev/protocol.d.ts +0 -2
  31. package/dist/dev/protocol.js +14 -14
  32. package/dist/dev/protocol.js.map +1 -1
  33. package/dist/dev/redis/client.d.ts +0 -2
  34. package/dist/dev/redis/client.js +1 -2
  35. package/dist/dev/redis/scripts.js +1 -2
  36. package/dist/dev/redis/topics.d.ts +6 -3
  37. package/dist/dev/redis/topics.js +5 -3
  38. package/dist/dev/redis/topics.js.map +1 -1
  39. package/dist/dev/redis.d.ts +0 -2
  40. package/dist/dev/redis.js +1 -2
  41. package/dist/dev/server/http.d.ts +0 -2
  42. package/dist/dev/server/http.js +38 -26
  43. package/dist/dev/server/http.js.map +1 -1
  44. package/dist/dev/server/hub.d.ts +0 -2
  45. package/dist/dev/server/hub.js +67 -41
  46. package/dist/dev/server/hub.js.map +1 -1
  47. package/dist/dev/server/index.d.ts +2 -4
  48. package/dist/dev/server/index.js +1 -2
  49. package/dist/dev/server/index.js.map +1 -1
  50. package/dist/dev/server/limits.d.ts +4 -3
  51. package/dist/dev/server/limits.js +1 -2
  52. package/dist/dev/server/limits.js.map +1 -1
  53. package/dist/dev/server/rules.d.ts +0 -2
  54. package/dist/dev/server/rules.js +1 -2
  55. package/dist/dev/server/rules.js.map +1 -1
  56. package/dist/dev/testing.d.ts +0 -2
  57. package/dist/dev/testing.js +1 -2
  58. package/dist/prod/client/core.js +1 -1
  59. package/dist/prod/client/core.js.map +1 -1
  60. package/dist/prod/client/sse.js +1 -4
  61. package/dist/prod/client/sse.js.map +1 -1
  62. package/dist/prod/client/util.js +1 -1
  63. package/dist/prod/client/util.js.map +1 -1
  64. package/dist/prod/cloudflare/link.js +1 -1
  65. package/dist/prod/cloudflare/link.js.map +1 -1
  66. package/dist/prod/memory.js +1 -1
  67. package/dist/prod/memory.js.map +1 -1
  68. package/dist/prod/protocol.js +1 -1
  69. package/dist/prod/protocol.js.map +1 -1
  70. package/dist/prod/redis/topics.js +1 -1
  71. package/dist/prod/redis/topics.js.map +1 -1
  72. package/dist/prod/server/http.js +1 -1
  73. package/dist/prod/server/http.js.map +1 -1
  74. package/dist/prod/server/hub.js +1 -1
  75. package/dist/prod/server/hub.js.map +1 -1
  76. package/dist/prod/server/index.js.map +1 -1
  77. package/dist/prod/server/limits.js +1 -1
  78. package/dist/prod/server/limits.js.map +1 -1
  79. package/dist/prod/server/rules.js +1 -1
  80. package/dist/prod/server/rules.js.map +1 -1
  81. package/package.json +6 -5
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.2
4
+
5
+ ### Fixed
6
+
7
+ - - Client: adding the same handler to a channel twice no longer leaves a subscription hold that nothing can release.
8
+ - - Client: a POST that never answers (server-sent events transport) is abandoned after the acknowledgment timeout, so the publishes queued behind it are sent.
9
+ - - Server: a stream declaration that names its own `t` or `n` is always read as a subscription, never as a publish or request.
10
+ - - Server: a WebSocket that closes while `authenticate` is still running no longer leaves a connection in the hub.
11
+ - - Server: presence changes posted over HTTP spend from the same rate limit as publishes, and a POST body is read only up to the size limit.
12
+ - - Server: a member whose presence entry expired is written back with its last state instead of `null`; a transport whose `send` throws is closed.
13
+ - - Memory adapter: idempotency keys are capped per channel (65,536), and expired ones are swept when the map doubles.
14
+ - - Redis adapter: a channel whose keys were lost and whose numbering restarted still delivers its messages (the hub tells subscribers `reset`).
15
+ - - Client: the event stream parser holds at most 1 MiB for an unfinished line and event; a longer one closes the stream and reconnects. A tab that becomes visible while the client waits out a reconnect backoff reconnects at once.
16
+ - - Server: `sub` and `unsub` frames spend from a per-connection bucket (the publish rate, a burst that covers all of `maxChannelsPerConnection`), so subscription churn is limited like publishing.
17
+ - - Server: a frame over `maxMessageBytes` is refused when it arrives instead of after the frames queued before it; it counts as an invalid frame.
18
+ - - Server: a presence state posted over HTTP also updates the connection's own copy (on this server directly, on another through the presence event), so a write-back after an expiry carries the newest version and state.
19
+ - - Cloudflare adapter: a listener whose queue overflows is reported (`onError`), and a presence listener is resynced to the real member set (leaves, joins, changes) instead of staying wrong.
20
+ - - Errors while dropping a connection and listener failures in the memory adapter go to `onError` (memory adapter: new `onError` option, default `console.error`) instead of being swallowed.
21
+ - The realtime client takes `reconnect.maxReconnectAttempts` (default unlimited). After that many failed reconnects in a row (the count restarts when a connection stays open `stableMs`) the client is `closed`, pending calls reject, and `onError` receives a `closed` error.
22
+
23
+ ## 1.0.1
24
+
25
+ ### Changed
26
+
27
+ - Released at 1.0.1 with the other packages, which share one version. No change to this package.
28
+
3
29
  ## 1.0.0
4
30
 
5
31
  ### Added
package/README.md CHANGED
@@ -104,6 +104,41 @@ Root, `@shadow-js/realtime`: the wire protocol and shared types.
104
104
  `@shadow-js/realtime/cloudflare`: `cloudflareAdapter`, `RealtimeChannelObject`.
105
105
  `@shadow-js/realtime/testing`: `connectTest`, `adapterConformance`.
106
106
 
107
+ ## Production error codes
108
+
109
+ A production build keeps a short code instead of the development explanation, so message text never reaches user bundles. Search this table for the code. In the explanation, `…` stands for a value the development message names. A code followed by a word names the option, feature or member at fault.
110
+
111
+ | Code | Development explanation |
112
+ | --- | --- |
113
+ | `REALTIME_CLOSED` | the adapter is closed. |
114
+ | `REALTIME_DATA` | the data is not JSON |
115
+ | `REALTIME_FRAME_1` | after must be a non-negative integer |
116
+ | `REALTIME_FRAME_10` | the frame is not an object |
117
+ | `REALTIME_FRAME_11` | the frame is larger than the limit |
118
+ | `REALTIME_FRAME_12` | unknown frame type |
119
+ | `REALTIME_FRAME_13` | v must be a version |
120
+ | `REALTIME_FRAME_14` | watch must be a boolean |
121
+ | `REALTIME_FRAME_2` | c is not a valid channel name |
122
+ | `REALTIME_FRAME_3` | echo must be a boolean |
123
+ | `REALTIME_FRAME_4` | e is not a valid event name |
124
+ | `REALTIME_FRAME_5` | the frame is not JSON |
125
+ | `REALTIME_FRAME_6` | k is not a valid key |
126
+ | `REALTIME_FRAME_7` | ms must be a positive number |
127
+ | `REALTIME_FRAME_8` | n must be a non-negative integer |
128
+ | `REALTIME_FRAME_9` | sub needs n / req needs n |
129
+ | `REALTIME_LIMIT_1` | limits.history must be at most …. |
130
+ | `REALTIME_LIMIT_2` | unknown limit "…". |
131
+ | `REALTIME_LIMIT_3` | limits.presenceTtlMs must be larger than limits.presenceHeartbeatMs. |
132
+ | `REALTIME_LIMIT_4` | limits.… must be … integer. |
133
+ | `REALTIME_PATTERN_1` | the channel pattern "…" must be 1..128 characters. |
134
+ | `REALTIME_PATTERN_2` | the channel pattern "…" repeats "…". |
135
+ | `REALTIME_PATTERN_3` | the channel pattern "…": {...name} must be the last segment. |
136
+ | `REALTIME_PATTERN_4` | the channel pattern "…" has an invalid segment "…". |
137
+ | `REALTIME_REVOKE` | revoke needs a user, a connection or a channel. |
138
+ | `REALTIME_RULE` | the rule for "…": history must be a non-negative integer. |
139
+
140
+ A `RealtimeError` raised without a message uses its protocol code (`forbidden`, `too_large`, ...) as the message; those codes are listed in the protocol guide at https://realtime.shadowjs.net/guides/realtime-protocol/. The `REALTIME_FRAME_*` codes are the `why` of a refused client frame, sent with the `bad_frame` error.
141
+
107
142
  ## Learn more
108
143
 
109
144
  See the ShadowJS documentation at https://shadowjs.net, and the realtime guide at https://realtime.shadowjs.net.
@@ -1,5 +1,4 @@
1
1
  import { Member, PresenceEvent, StoredEntry, StoredMessage } from "./protocol.js";
2
- //#region src/adapter.d.ts
3
2
  /** How a publish is retained and deduplicated. @since 1.0.0 */
4
3
  export interface PublishOptions {
5
4
  /** Messages kept per channel (0 keeps none). */
@@ -71,5 +70,4 @@ export interface Adapter {
71
70
  /** Releases connections and timers. */
72
71
  close(): Promise<void>;
73
72
  }
74
- //#endregion
75
73
  //# sourceMappingURL=adapter.d.ts.map
@@ -1,5 +1,4 @@
1
1
  import { ChannelSchema, Realtime, RealtimeOptions } from "./types.js";
2
- //#region src/client/core.d.ts
3
2
  /**
4
3
  * Creates the realtime client: one transport (WebSocket, falling back to server-sent ev), reconnects with backoff, a
5
4
  * bounded offline queue, resume from the last sequence number and typed channels. It connects when the first channel
@@ -20,5 +19,4 @@ import { ChannelSchema, Realtime, RealtimeOptions } from "./types.js";
20
19
  * @since 1.0.0
21
20
  */
22
21
  export declare function createRealtime<Schema extends ChannelSchema = ChannelSchema>(options: RealtimeOptions): Realtime<Schema>;
23
- //#endregion
24
22
  //# sourceMappingURL=core.d.ts.map
@@ -1,10 +1,10 @@
1
1
  import { RealtimeError } from "../errors.js";
2
- import { byteLength, decodeServerFrame, isChannelName, isEventName, isIdempotencyKey } from "../protocol.js";
2
+ import { byteLength, isChannelName, isEventName, isIdempotencyKey, serverFrameOf } from "../protocol.js";
3
3
  import { applyPresence } from "./presence.js";
4
4
  import { openStream } from "./sse.js";
5
5
  import { backoff, clientId, resolveUrl, sendUrl } from "./util.js";
6
6
  import { openSocket } from "./ws.js";
7
- //#region src/client/core.ts
7
+ // src/client/core.ts
8
8
  /** Close codes that mean the client did something the server will not accept again. */
9
9
  const FATAL = /* @__PURE__ */ new Set([
10
10
  1002,
@@ -57,7 +57,6 @@ function createRealtime(options) {
57
57
  const ackTtl = options.ackTimeoutMs ?? 1e4;
58
58
  const auto = (options.transport ?? "auto") === "auto";
59
59
  const id = clientId();
60
- const life = new AbortController();
61
60
  const chans = /* @__PURE__ */ new Map();
62
61
  const dirty = /* @__PURE__ */ new Set();
63
62
  const queue = [];
@@ -79,13 +78,15 @@ function createRealtime(options) {
79
78
  let counter = 0;
80
79
  let keys = 0;
81
80
  let orders = 0;
82
- let received = false;
81
+ /** Heartbeat intervals since the last thing was received. */
83
82
  let silent = 0;
84
83
  let connectTimer;
85
84
  let reconnectTimer;
86
85
  let stableTimer;
87
86
  let beatTimer;
88
87
  let posting = false;
88
+ /** The POST in flight, which closing the client abandons. */
89
+ let batch;
89
90
  let postTimer;
90
91
  let postFails = 0;
91
92
  let listening = false;
@@ -126,15 +127,13 @@ function createRealtime(options) {
126
127
  if (settle !== void 0) settle(error, value);
127
128
  else if (error !== void 0 && op.kind === "pub") report(error);
128
129
  };
129
- const fail = (op, error) => finish(op, error);
130
- const succeed = (op, value) => finish(op, void 0, value);
131
130
  const arm = (op, ms) => {
132
131
  clearTimeout(op.timer);
133
132
  op.timer = setTimeout(() => {
134
133
  const at = queue.indexOf(op);
135
134
  if (at >= 0) queue.splice(at, 1);
136
135
  if (inflight.get(op.n) === op) inflight.delete(op.n);
137
- fail(op, new RealtimeError("timeout"));
136
+ finish(op, new RealtimeError("timeout"));
138
137
  }, ms);
139
138
  };
140
139
  const take = (n) => {
@@ -159,22 +158,24 @@ function createRealtime(options) {
159
158
  chan,
160
159
  event,
161
160
  data,
162
- key: extra.key,
163
- ack: extra.ack ?? true,
164
- ms: extra.ms ?? 0,
161
+ ack: true,
162
+ ms: 0,
165
163
  order: ++orders,
166
164
  n: 0,
167
- timer: void 0,
168
- settle: extra.settle
165
+ ...extra
169
166
  });
167
+ /** Whether the server answers an operation (a request, or a publish that asks for an acknowledgment). */
168
+ const answered = (op) => op.kind === "req" || op.kind === "pub" && op.ack;
169
+ /** How long an operation that went out waits for its answer: a request's own time and a second, or the acknowledgment time. */
170
+ const waitOf = (op) => op.kind === "req" ? op.ms + 1e3 : ackTtl;
170
171
  /** The frame text of an operation, numbered for an answer. Undefined (and the operation failed) when it is too large. */
171
172
  const encode = (op) => {
172
173
  const c = JSON.stringify(op.chan.name);
173
- const wantsAnswer = op.kind === "req" || op.kind === "pub" && op.ack;
174
+ const wantsAnswer = answered(op);
174
175
  op.n = wantsAnswer ? ++counter : 0;
175
176
  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
177
  if (byteLength(text) > maxBytes) {
177
- fail(op, new RealtimeError("too_large"));
178
+ finish(op, new RealtimeError("too_large"));
178
179
  return;
179
180
  }
180
181
  return text;
@@ -182,16 +183,16 @@ function createRealtime(options) {
182
183
  /** Records that an operation went out: it now waits for an answer, or is done. */
183
184
  const sent = (op) => {
184
185
  if (op.kind === "pres") return;
185
- if (op.kind === "req" || op.ack) {
186
+ if (answered(op)) {
186
187
  inflight.set(op.n, op);
187
- arm(op, op.kind === "req" ? op.ms + 1e3 : ackTtl);
188
+ arm(op, waitOf(op));
188
189
  } else clearTimeout(op.timer);
189
190
  };
190
191
  /** Puts operations that went out on a connection that died back at the head of the queue, in their original order. */
191
192
  const requeue = (ops) => {
192
193
  ops.sort((a, b) => a.order - b.order);
193
194
  const again = [];
194
- for (const op of ops) if (op.kind === "req") fail(op, new RealtimeError("closed"));
195
+ for (const op of ops) if (op.kind === "req") finish(op, new RealtimeError("closed"));
195
196
  else {
196
197
  if (op.kind === "pub") arm(op, queueTtl);
197
198
  again.push(op);
@@ -220,13 +221,13 @@ function createRealtime(options) {
220
221
  ready: !join
221
222
  };
222
223
  setChanStatus(chan, chan.sy ? "resuming" : "subscribing");
223
- return { fields: {
224
+ return {
224
225
  c: chan.name,
225
226
  ...after === void 0 ? {} : { after },
226
227
  ...chan.options.echo === false ? { echo: false } : {},
227
228
  ...join ? { presence: chan.state } : {},
228
229
  ...watch ? { watch: true } : {}
229
- } };
230
+ };
230
231
  };
231
232
  const sendPresence = (chan) => {
232
233
  const w = chan.w;
@@ -252,12 +253,15 @@ function createRealtime(options) {
252
253
  setChanStatus(chan, chan.closed ? "closed" : "idle");
253
254
  };
254
255
  /** Brings the w in line with what a channel wants (WebSocket: subscribe, unsubscribe, presence). */
256
+ const unsub = (chan) => {
257
+ link?.send(`{"t":"unsub","c":${JSON.stringify(chan.name)}}`);
258
+ };
255
259
  const syncChan = (chan) => {
256
260
  const want = wants(chan);
257
261
  const w = chan.w;
258
262
  if (mode === "ws" && ready) {
259
263
  if (w !== void 0 && (!want || w.join !== joins(chan) || w.watch !== watches(chan))) {
260
- link?.send(`{"t":"unsub","c":${JSON.stringify(chan.name)}}`);
264
+ unsub(chan);
261
265
  chan.w = void 0;
262
266
  }
263
267
  if (want && chan.w === void 0 && chan.retry === void 0) {
@@ -265,7 +269,7 @@ function createRealtime(options) {
265
269
  link?.send(JSON.stringify({
266
270
  t: "sub",
267
271
  n,
268
- ...subscription(chan, n).fields
272
+ ...subscription(chan, n)
269
273
  }));
270
274
  }
271
275
  }
@@ -285,7 +289,7 @@ function createRealtime(options) {
285
289
  if (chan.closed) return;
286
290
  chan.closed = true;
287
291
  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)}}`);
292
+ if (chan.w !== void 0 && mode === "ws" && ready) unsub(chan);
289
293
  goIdle(chan);
290
294
  chan.ev.clear();
291
295
  chan.any.clear();
@@ -311,20 +315,6 @@ function createRealtime(options) {
311
315
  }, backoff(chan.fails, minDelay, maxDelay, random));
312
316
  chan.fails += 1;
313
317
  };
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
318
  /** What the open stream must be reopened for: the channels it carries and how they join. */
329
319
  const signature = () => {
330
320
  let sig = "";
@@ -338,11 +328,10 @@ function createRealtime(options) {
338
328
  gen += 1;
339
329
  const g = gen;
340
330
  ready = false;
341
- received = false;
342
331
  silent = 0;
343
332
  const ev = {
344
333
  rx: () => {
345
- if (g === gen) received = true;
334
+ if (g === gen) silent = 0;
346
335
  },
347
336
  frame: (frame) => {
348
337
  if (g === gen) guard(() => onFrame(frame, false));
@@ -355,10 +344,21 @@ function createRealtime(options) {
355
344
  if (!listening && typeof addEventListener === "function") {
356
345
  listening = true;
357
346
  addEventListener("online", onOnline);
347
+ if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible);
358
348
  }
359
349
  if (mode === "sse") {
360
350
  const url = resolveUrl(options.url, false);
361
- url.searchParams.set("s", JSON.stringify(declare()));
351
+ const entries = [];
352
+ for (const chan of [...chans.values()]) {
353
+ if (!wants(chan)) continue;
354
+ if (entries.length >= MAX_STREAM_CHANNELS) {
355
+ closeChan(chan, new RealtimeError("limit"));
356
+ continue;
357
+ }
358
+ entries.push(subscription(chan, entries.length + 1));
359
+ }
360
+ streamSig = signature();
361
+ url.searchParams.set("s", JSON.stringify(entries));
362
362
  link = openStream(url.toString(), doFetch, getHeaders, ev);
363
363
  } else link = openSocket(resolveUrl(options.url, true).toString(), options.WebSocket ?? globalThis.WebSocket, ev);
364
364
  } catch (error) {
@@ -373,6 +373,10 @@ function createRealtime(options) {
373
373
  const onOnline = () => {
374
374
  if (reconnectTimer !== void 0) connect();
375
375
  };
376
+ /** A tab that was hidden keeps its timers throttled or frozen: when it shows again, a connection waiting out a backoff reconnects now. */
377
+ const onVisible = () => {
378
+ if (document.visibilityState === "visible" && reconnectTimer !== void 0) connect();
379
+ };
376
380
  /** Releases the current transport and everything tied to it. */
377
381
  const drop = () => {
378
382
  gen += 1;
@@ -409,6 +413,10 @@ function createRealtime(options) {
409
413
  shutdown(new RealtimeError("closed", why));
410
414
  return;
411
415
  }
416
+ if (attempt >= (reconnect.maxReconnectAttempts ?? Infinity)) {
417
+ shutdown(new RealtimeError("closed", `gave up after ${reconnect.maxReconnectAttempts} reconnect attempts`));
418
+ return;
419
+ }
412
420
  let delay = backoff(attempt, minDelay, maxDelay, random);
413
421
  attempt += 1;
414
422
  if (wasReady) wsFails = 0;
@@ -424,49 +432,30 @@ function createRealtime(options) {
424
432
  };
425
433
  /** Heartbeat: every interval sends a ping; two intervals with nothing received declare the connection dead. */
426
434
  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
- }
435
+ silent += 1;
436
+ if (silent > 2) {
437
+ linkClosed({});
438
+ return;
436
439
  }
437
440
  if (mode === "ws") link?.send("{\"t\":\"ping\"}");
438
441
  };
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
442
  const onFrame = (frame, viaPost) => {
467
443
  switch (frame.t) {
468
444
  case "ready":
469
- onReady(frame);
445
+ if (ready) return;
446
+ ready = true;
447
+ connId = frame.id;
448
+ maxBytes = frame.max;
449
+ wsFails = 0;
450
+ silent = 0;
451
+ clearTimeout(connectTimer);
452
+ if (frame.hb > 0) beatTimer = setInterval(beat, frame.hb);
453
+ stableTimer = setTimeout(() => {
454
+ attempt = 0;
455
+ }, stableMs);
456
+ setStatus("open");
457
+ if (mode === "ws") for (const chan of chans.values()) dirty.add(chan);
458
+ flush();
470
459
  return;
471
460
  case "bye":
472
461
  byeCode = frame.code;
@@ -476,16 +465,13 @@ function createRealtime(options) {
476
465
  const w = chan?.w;
477
466
  if (chan === void 0 || w === void 0 || w.n !== frame.n) return;
478
467
  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);
468
+ const gap = frame.reset === true || frame.gap === true && w.after !== void 0 && !(w.after === 0 && !chan.sy);
484
469
  chan.sy = true;
485
470
  chan.fails = 0;
486
471
  setChanStatus(chan, "live");
487
472
  if (gap) emit(chan.gl, {
488
- ...info,
473
+ after: w.after ?? 0,
474
+ head: frame.head,
489
475
  reset: frame.reset === true
490
476
  });
491
477
  return;
@@ -519,7 +505,7 @@ function createRealtime(options) {
519
505
  }
520
506
  case "ack": {
521
507
  const op = take(frame.n);
522
- if (op !== void 0) succeed(op, {
508
+ if (op !== void 0) finish(op, void 0, {
523
509
  seq: frame.s,
524
510
  duplicate: frame.dup === true
525
511
  });
@@ -527,7 +513,7 @@ function createRealtime(options) {
527
513
  }
528
514
  case "rep": {
529
515
  const op = take(frame.n);
530
- if (op !== void 0) succeed(op, frame.d);
516
+ if (op !== void 0) finish(op, void 0, frame.d);
531
517
  return;
532
518
  }
533
519
  case "err": {
@@ -546,7 +532,7 @@ function createRealtime(options) {
546
532
  }
547
533
  const op = viaPost || mode === "ws" ? take(frame.n) : void 0;
548
534
  if (op !== void 0) {
549
- fail(op, error);
535
+ finish(op, error);
550
536
  return;
551
537
  }
552
538
  }
@@ -556,7 +542,10 @@ function createRealtime(options) {
556
542
  case "pres": {
557
543
  const chan = chans.get(frame.c);
558
544
  if (chan === void 0) return;
559
- if (applyPresence(chan.members, frame.op, frame.m)) notifyPresence(chan);
545
+ if (applyPresence(chan.members, frame.op, frame.m)) {
546
+ chan.list = [...chan.members.values()];
547
+ emit(chan.presence, chan.list);
548
+ }
560
549
  if (frame.op === "sync" && chan.w !== void 0) {
561
550
  chan.w.ready = true;
562
551
  if (chan.w.join && chan.w.ver !== chan.sv) sendPresence(chan);
@@ -603,6 +592,9 @@ function createRealtime(options) {
603
592
  const postBatch = async (ops, body) => {
604
593
  for (const op of ops) sent(op);
605
594
  let again = false;
595
+ const mine = new AbortController();
596
+ batch = mine;
597
+ const patience = setTimeout(() => mine.abort(), Math.max(...ops.map(waitOf)));
606
598
  try {
607
599
  const headers = new Headers(await getHeaders());
608
600
  headers.set("content-type", "application/json");
@@ -611,12 +603,12 @@ function createRealtime(options) {
611
603
  method: "POST",
612
604
  headers,
613
605
  body,
614
- signal: life.signal
606
+ signal: mine.signal
615
607
  });
616
608
  if (response.ok) {
617
609
  const parsed = await response.json();
618
610
  if (Array.isArray(parsed.frames)) for (const item of parsed.frames) {
619
- const frame = decodeServerFrame(JSON.stringify(item));
611
+ const frame = serverFrameOf(item);
620
612
  if (frame !== void 0) guard(() => onFrame(frame, true));
621
613
  }
622
614
  postFails = 0;
@@ -626,38 +618,42 @@ function createRealtime(options) {
626
618
  const code = refusal?.error?.code ?? "unavailable";
627
619
  for (const op of ops) {
628
620
  if (inflight.get(op.n) === op) inflight.delete(op.n);
629
- fail(op, new RealtimeError(code, refusal?.error?.message));
621
+ finish(op, new RealtimeError(code, refusal?.error?.message));
630
622
  }
631
623
  }
632
624
  } catch {
633
- if (life.signal.aborted) return;
625
+ if (finished) return;
634
626
  again = true;
627
+ } finally {
628
+ clearTimeout(patience);
635
629
  }
636
630
  posting = false;
637
- const unanswered = ops.filter((op) => (op.kind === "req" || op.kind === "pub" && op.ack) && inflight.get(op.n) === op);
631
+ const unanswered = ops.filter((op) => answered(op) && inflight.get(op.n) === op);
638
632
  for (const op of unanswered) inflight.delete(op.n);
639
633
  if (again) {
640
- for (const op of unanswered) if (op.kind === "req") fail(op, new RealtimeError("unavailable"));
634
+ for (const op of unanswered) if (op.kind === "req") finish(op, new RealtimeError("unavailable"));
641
635
  requeue(ops.filter((op) => op.kind === "pres" || op.kind === "pub" && (!op.ack || unanswered.includes(op))));
642
636
  postFails += 1;
643
637
  postTimer = setTimeout(() => {
644
638
  postTimer = void 0;
645
639
  flush();
646
640
  }, backoff(postFails - 1, minDelay, maxDelay, random));
647
- } else for (const op of unanswered) fail(op, new RealtimeError("unavailable"));
641
+ } else for (const op of unanswered) finish(op, new RealtimeError("unavailable"));
648
642
  flush();
649
643
  };
650
- const needed = () => queue.length > 0 || [...chans.values()].some(wants);
651
644
  const run = () => {
652
645
  scheduled = false;
653
646
  if (finished) return;
654
647
  syncAll();
655
648
  if (link === void 0) {
656
- if (reconnectTimer === void 0 && needed()) connect();
649
+ if (reconnectTimer === void 0 && (queue.length > 0 || [...chans.values()].some(wants))) connect();
657
650
  return;
658
651
  }
659
- if (mode === "sse" && (signature() !== streamSig || [...chans.values()].some((c) => wants(c) && c.w === void 0 && c.retry === void 0))) reopen();
660
- else flush();
652
+ if (mode === "sse" && (signature() !== streamSig || [...chans.values()].some((c) => wants(c) && c.w === void 0 && c.retry === void 0))) {
653
+ drop();
654
+ for (const chan of chans.values()) chan.w = void 0;
655
+ connect();
656
+ } else flush();
661
657
  };
662
658
  function schedule() {
663
659
  if (scheduled || finished) return;
@@ -672,14 +668,17 @@ function createRealtime(options) {
672
668
  reconnectTimer = void 0;
673
669
  postTimer = void 0;
674
670
  drop();
675
- life.abort();
676
- if (listening && typeof removeEventListener === "function") removeEventListener("online", onOnline);
677
- listening = false;
671
+ batch?.abort();
672
+ if (listening) {
673
+ if (typeof removeEventListener === "function") removeEventListener("online", onOnline);
674
+ if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible);
675
+ listening = false;
676
+ }
678
677
  const closed = error ?? new RealtimeError("closed");
679
678
  const pending = [...queue.splice(0), ...inflight.values()];
680
679
  inflight.clear();
681
680
  for (const op of pending) if (op.settle === void 0) clearTimeout(op.timer);
682
- else fail(op, closed);
681
+ else finish(op, closed);
683
682
  for (const chan of [...chans.values()]) closeChan(chan);
684
683
  chans.clear();
685
684
  dirty.clear();
@@ -697,7 +696,6 @@ function createRealtime(options) {
697
696
  sl: /* @__PURE__ */ new Set(),
698
697
  presence: /* @__PURE__ */ new Set(),
699
698
  members: /* @__PURE__ */ new Map(),
700
- list: void 0,
701
699
  status: "idle",
702
700
  refs: 0,
703
701
  hd: 0,
@@ -705,23 +703,23 @@ function createRealtime(options) {
705
703
  sy: false,
706
704
  state: channelOptions.presence,
707
705
  sv: 0,
708
- w: void 0,
709
- retry: void 0,
710
706
  fails: 0,
711
- closed: false,
712
- hn: void 0
707
+ closed: false
713
708
  };
714
709
  chans.set(name, chan);
715
710
  if (wants(chan)) touch(chan);
716
711
  /** Registers a listener; unless `passive`, it keeps the channel subscribed until removed. */
717
712
  const listen = (set, item, passive, after) => {
718
713
  if (chan.closed) return () => void 0;
714
+ let owns = !set.has(item);
719
715
  set.add(item);
720
- if (!passive) {
716
+ if (!passive && owns) {
721
717
  chan.refs += 1;
722
718
  touch(chan);
723
719
  }
724
720
  return () => {
721
+ if (!owns) return;
722
+ owns = false;
725
723
  if (!set.delete(item) || passive) return;
726
724
  chan.refs -= 1;
727
725
  after?.();
@@ -839,7 +837,6 @@ function createRealtime(options) {
839
837
  close: () => shutdown()
840
838
  };
841
839
  }
842
- //#endregion
843
840
  export { createRealtime };
844
841
 
845
842
  //# sourceMappingURL=core.js.map