@shadow-js/realtime 1.0.1 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +35 -0
- package/dist/dev/client/core.js +107 -107
- package/dist/dev/client/core.js.map +1 -1
- package/dist/dev/client/sse.js +33 -24
- package/dist/dev/client/sse.js.map +1 -1
- package/dist/dev/client/types.d.ts +6 -0
- package/dist/dev/client/util.js +2 -2
- package/dist/dev/client/util.js.map +1 -1
- package/dist/dev/cloudflare/link.js +69 -8
- package/dist/dev/cloudflare/link.js.map +1 -1
- package/dist/dev/memory.d.ts +11 -0
- package/dist/dev/memory.js +38 -10
- package/dist/dev/memory.js.map +1 -1
- package/dist/dev/protocol.js +13 -12
- package/dist/dev/protocol.js.map +1 -1
- package/dist/dev/redis/topics.d.ts +6 -1
- package/dist/dev/redis/topics.js +4 -1
- package/dist/dev/redis/topics.js.map +1 -1
- package/dist/dev/server/http.js +37 -24
- package/dist/dev/server/http.js.map +1 -1
- package/dist/dev/server/hub.d.ts +1 -1
- package/dist/dev/server/hub.js +117 -69
- package/dist/dev/server/hub.js.map +1 -1
- package/dist/dev/server/index.d.ts +2 -2
- package/dist/dev/server/index.js.map +1 -1
- package/dist/dev/server/limits.d.ts +4 -1
- package/dist/dev/server/limits.js.map +1 -1
- package/dist/dev/server/rules.js.map +1 -1
- package/dist/prod/client/core.js +1 -1
- package/dist/prod/client/core.js.map +1 -1
- package/dist/prod/client/sse.js +1 -4
- package/dist/prod/client/sse.js.map +1 -1
- package/dist/prod/client/util.js +1 -1
- package/dist/prod/client/util.js.map +1 -1
- package/dist/prod/cloudflare/link.js +1 -1
- package/dist/prod/cloudflare/link.js.map +1 -1
- package/dist/prod/memory.js +1 -1
- package/dist/prod/memory.js.map +1 -1
- package/dist/prod/protocol.js +1 -1
- package/dist/prod/protocol.js.map +1 -1
- package/dist/prod/redis/topics.js +1 -1
- package/dist/prod/redis/topics.js.map +1 -1
- package/dist/prod/server/http.js +1 -1
- package/dist/prod/server/http.js.map +1 -1
- package/dist/prod/server/hub.js +1 -1
- package/dist/prod/server/hub.js.map +1 -1
- package/dist/prod/server/index.js.map +1 -1
- package/dist/prod/server/limits.js +1 -1
- package/dist/prod/server/limits.js.map +1 -1
- package/dist/prod/server/rules.js +1 -1
- package/dist/prod/server/rules.js.map +1 -1
- package/package.json +6 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.3
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Idempotency keys belong to their publisher: the hub stores `key/u:<user>` (a signed-in user), `key/a` (anonymous connections) or `key/s` (`hub.publish`), so another user or the server reusing a key is never a duplicate.
|
|
8
|
+
- `maxPresenceMembers` holds under concurrent joins: joins to one channel are applied one at a time per hub.
|
|
9
|
+
- A `revoke` issued while a subscribe is awaiting its async rule refuses that subscribe (and counts it).
|
|
10
|
+
- After `subbed gap:true` the client resumes from the gap's head, so a gap with nothing left to replay is reported once, not on every reconnect.
|
|
11
|
+
|
|
12
|
+
## 1.0.2
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Client: adding the same handler to a channel twice no longer leaves a subscription hold that nothing can release.
|
|
17
|
+
- Client: a POST that never answers (server-sent events transport) is abandoned after the acknowledgment timeout, so the publishes queued behind it are sent.
|
|
18
|
+
- Server: a stream declaration that names its own `t` or `n` is always read as a subscription, never as a publish or request.
|
|
19
|
+
- Server: a WebSocket that closes while `authenticate` is still running no longer leaves a connection in the hub.
|
|
20
|
+
- 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.
|
|
21
|
+
- Server: a member whose presence entry expired is written back with its last state instead of `null`; a transport whose `send` throws is closed.
|
|
22
|
+
- Memory adapter: idempotency keys are capped per channel (65,536), and expired ones are swept when the map doubles.
|
|
23
|
+
- Redis adapter: a channel whose keys were lost and whose numbering restarted still delivers its messages (the hub tells subscribers `reset`).
|
|
24
|
+
- 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.
|
|
25
|
+
- 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.
|
|
26
|
+
- Server: a frame over `maxMessageBytes` is refused when it arrives instead of after the frames queued before it; it counts as an invalid frame.
|
|
27
|
+
- 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.
|
|
28
|
+
- 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.
|
|
29
|
+
- 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.
|
|
30
|
+
- 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.
|
|
31
|
+
|
|
3
32
|
## 1.0.1
|
|
4
33
|
|
|
5
34
|
### Changed
|
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.
|
package/dist/dev/client/core.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { RealtimeError } from "../errors.js";
|
|
2
|
-
import { byteLength,
|
|
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";
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
ms: extra.ms ?? 0,
|
|
161
|
+
ack: true,
|
|
162
|
+
ms: 0,
|
|
165
163
|
order: ++orders,
|
|
166
164
|
n: 0,
|
|
167
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
186
|
+
if (answered(op)) {
|
|
186
187
|
inflight.set(op.n, op);
|
|
187
|
-
arm(op, op
|
|
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")
|
|
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);
|
|
@@ -210,7 +211,7 @@ function createRealtime(options) {
|
|
|
210
211
|
const subscription = (chan, n) => {
|
|
211
212
|
const join = joins(chan);
|
|
212
213
|
const watch = watches(chan);
|
|
213
|
-
const after = chan.sy ? chan.last : chan.options.replay === true ? 0 : void 0;
|
|
214
|
+
const after = chan.sy ? Math.max(chan.last, chan.gh) : chan.options.replay === true ? 0 : void 0;
|
|
214
215
|
chan.w = {
|
|
215
216
|
n,
|
|
216
217
|
join,
|
|
@@ -220,13 +221,13 @@ function createRealtime(options) {
|
|
|
220
221
|
ready: !join
|
|
221
222
|
};
|
|
222
223
|
setChanStatus(chan, chan.sy ? "resuming" : "subscribing");
|
|
223
|
-
return {
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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,14 @@ 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
|
|
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");
|
|
472
|
+
chan.gh = gap ? frame.head : 0;
|
|
487
473
|
if (gap) emit(chan.gl, {
|
|
488
|
-
|
|
474
|
+
after: w.after ?? 0,
|
|
475
|
+
head: frame.head,
|
|
489
476
|
reset: frame.reset === true
|
|
490
477
|
});
|
|
491
478
|
return;
|
|
@@ -519,7 +506,7 @@ function createRealtime(options) {
|
|
|
519
506
|
}
|
|
520
507
|
case "ack": {
|
|
521
508
|
const op = take(frame.n);
|
|
522
|
-
if (op !== void 0)
|
|
509
|
+
if (op !== void 0) finish(op, void 0, {
|
|
523
510
|
seq: frame.s,
|
|
524
511
|
duplicate: frame.dup === true
|
|
525
512
|
});
|
|
@@ -527,7 +514,7 @@ function createRealtime(options) {
|
|
|
527
514
|
}
|
|
528
515
|
case "rep": {
|
|
529
516
|
const op = take(frame.n);
|
|
530
|
-
if (op !== void 0)
|
|
517
|
+
if (op !== void 0) finish(op, void 0, frame.d);
|
|
531
518
|
return;
|
|
532
519
|
}
|
|
533
520
|
case "err": {
|
|
@@ -546,7 +533,7 @@ function createRealtime(options) {
|
|
|
546
533
|
}
|
|
547
534
|
const op = viaPost || mode === "ws" ? take(frame.n) : void 0;
|
|
548
535
|
if (op !== void 0) {
|
|
549
|
-
|
|
536
|
+
finish(op, error);
|
|
550
537
|
return;
|
|
551
538
|
}
|
|
552
539
|
}
|
|
@@ -556,7 +543,10 @@ function createRealtime(options) {
|
|
|
556
543
|
case "pres": {
|
|
557
544
|
const chan = chans.get(frame.c);
|
|
558
545
|
if (chan === void 0) return;
|
|
559
|
-
if (applyPresence(chan.members, frame.op, frame.m))
|
|
546
|
+
if (applyPresence(chan.members, frame.op, frame.m)) {
|
|
547
|
+
chan.list = [...chan.members.values()];
|
|
548
|
+
emit(chan.presence, chan.list);
|
|
549
|
+
}
|
|
560
550
|
if (frame.op === "sync" && chan.w !== void 0) {
|
|
561
551
|
chan.w.ready = true;
|
|
562
552
|
if (chan.w.join && chan.w.ver !== chan.sv) sendPresence(chan);
|
|
@@ -603,6 +593,9 @@ function createRealtime(options) {
|
|
|
603
593
|
const postBatch = async (ops, body) => {
|
|
604
594
|
for (const op of ops) sent(op);
|
|
605
595
|
let again = false;
|
|
596
|
+
const mine = new AbortController();
|
|
597
|
+
batch = mine;
|
|
598
|
+
const patience = setTimeout(() => mine.abort(), Math.max(...ops.map(waitOf)));
|
|
606
599
|
try {
|
|
607
600
|
const headers = new Headers(await getHeaders());
|
|
608
601
|
headers.set("content-type", "application/json");
|
|
@@ -611,12 +604,12 @@ function createRealtime(options) {
|
|
|
611
604
|
method: "POST",
|
|
612
605
|
headers,
|
|
613
606
|
body,
|
|
614
|
-
signal:
|
|
607
|
+
signal: mine.signal
|
|
615
608
|
});
|
|
616
609
|
if (response.ok) {
|
|
617
610
|
const parsed = await response.json();
|
|
618
611
|
if (Array.isArray(parsed.frames)) for (const item of parsed.frames) {
|
|
619
|
-
const frame =
|
|
612
|
+
const frame = serverFrameOf(item);
|
|
620
613
|
if (frame !== void 0) guard(() => onFrame(frame, true));
|
|
621
614
|
}
|
|
622
615
|
postFails = 0;
|
|
@@ -626,38 +619,42 @@ function createRealtime(options) {
|
|
|
626
619
|
const code = refusal?.error?.code ?? "unavailable";
|
|
627
620
|
for (const op of ops) {
|
|
628
621
|
if (inflight.get(op.n) === op) inflight.delete(op.n);
|
|
629
|
-
|
|
622
|
+
finish(op, new RealtimeError(code, refusal?.error?.message));
|
|
630
623
|
}
|
|
631
624
|
}
|
|
632
625
|
} catch {
|
|
633
|
-
if (
|
|
626
|
+
if (finished) return;
|
|
634
627
|
again = true;
|
|
628
|
+
} finally {
|
|
629
|
+
clearTimeout(patience);
|
|
635
630
|
}
|
|
636
631
|
posting = false;
|
|
637
|
-
const unanswered = ops.filter((op) => (op
|
|
632
|
+
const unanswered = ops.filter((op) => answered(op) && inflight.get(op.n) === op);
|
|
638
633
|
for (const op of unanswered) inflight.delete(op.n);
|
|
639
634
|
if (again) {
|
|
640
|
-
for (const op of unanswered) if (op.kind === "req")
|
|
635
|
+
for (const op of unanswered) if (op.kind === "req") finish(op, new RealtimeError("unavailable"));
|
|
641
636
|
requeue(ops.filter((op) => op.kind === "pres" || op.kind === "pub" && (!op.ack || unanswered.includes(op))));
|
|
642
637
|
postFails += 1;
|
|
643
638
|
postTimer = setTimeout(() => {
|
|
644
639
|
postTimer = void 0;
|
|
645
640
|
flush();
|
|
646
641
|
}, backoff(postFails - 1, minDelay, maxDelay, random));
|
|
647
|
-
} else for (const op of unanswered)
|
|
642
|
+
} else for (const op of unanswered) finish(op, new RealtimeError("unavailable"));
|
|
648
643
|
flush();
|
|
649
644
|
};
|
|
650
|
-
const needed = () => queue.length > 0 || [...chans.values()].some(wants);
|
|
651
645
|
const run = () => {
|
|
652
646
|
scheduled = false;
|
|
653
647
|
if (finished) return;
|
|
654
648
|
syncAll();
|
|
655
649
|
if (link === void 0) {
|
|
656
|
-
if (reconnectTimer === void 0 &&
|
|
650
|
+
if (reconnectTimer === void 0 && (queue.length > 0 || [...chans.values()].some(wants))) connect();
|
|
657
651
|
return;
|
|
658
652
|
}
|
|
659
|
-
if (mode === "sse" && (signature() !== streamSig || [...chans.values()].some((c) => wants(c) && c.w === void 0 && c.retry === void 0)))
|
|
660
|
-
|
|
653
|
+
if (mode === "sse" && (signature() !== streamSig || [...chans.values()].some((c) => wants(c) && c.w === void 0 && c.retry === void 0))) {
|
|
654
|
+
drop();
|
|
655
|
+
for (const chan of chans.values()) chan.w = void 0;
|
|
656
|
+
connect();
|
|
657
|
+
} else flush();
|
|
661
658
|
};
|
|
662
659
|
function schedule() {
|
|
663
660
|
if (scheduled || finished) return;
|
|
@@ -672,14 +669,17 @@ function createRealtime(options) {
|
|
|
672
669
|
reconnectTimer = void 0;
|
|
673
670
|
postTimer = void 0;
|
|
674
671
|
drop();
|
|
675
|
-
|
|
676
|
-
if (listening
|
|
677
|
-
|
|
672
|
+
batch?.abort();
|
|
673
|
+
if (listening) {
|
|
674
|
+
if (typeof removeEventListener === "function") removeEventListener("online", onOnline);
|
|
675
|
+
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible);
|
|
676
|
+
listening = false;
|
|
677
|
+
}
|
|
678
678
|
const closed = error ?? new RealtimeError("closed");
|
|
679
679
|
const pending = [...queue.splice(0), ...inflight.values()];
|
|
680
680
|
inflight.clear();
|
|
681
681
|
for (const op of pending) if (op.settle === void 0) clearTimeout(op.timer);
|
|
682
|
-
else
|
|
682
|
+
else finish(op, closed);
|
|
683
683
|
for (const chan of [...chans.values()]) closeChan(chan);
|
|
684
684
|
chans.clear();
|
|
685
685
|
dirty.clear();
|
|
@@ -697,31 +697,31 @@ function createRealtime(options) {
|
|
|
697
697
|
sl: /* @__PURE__ */ new Set(),
|
|
698
698
|
presence: /* @__PURE__ */ new Set(),
|
|
699
699
|
members: /* @__PURE__ */ new Map(),
|
|
700
|
-
list: void 0,
|
|
701
700
|
status: "idle",
|
|
702
701
|
refs: 0,
|
|
703
702
|
hd: 0,
|
|
704
703
|
last: 0,
|
|
704
|
+
gh: 0,
|
|
705
705
|
sy: false,
|
|
706
706
|
state: channelOptions.presence,
|
|
707
707
|
sv: 0,
|
|
708
|
-
w: void 0,
|
|
709
|
-
retry: void 0,
|
|
710
708
|
fails: 0,
|
|
711
|
-
closed: false
|
|
712
|
-
hn: void 0
|
|
709
|
+
closed: false
|
|
713
710
|
};
|
|
714
711
|
chans.set(name, chan);
|
|
715
712
|
if (wants(chan)) touch(chan);
|
|
716
713
|
/** Registers a listener; unless `passive`, it keeps the channel subscribed until removed. */
|
|
717
714
|
const listen = (set, item, passive, after) => {
|
|
718
715
|
if (chan.closed) return () => void 0;
|
|
716
|
+
let owns = !set.has(item);
|
|
719
717
|
set.add(item);
|
|
720
|
-
if (!passive) {
|
|
718
|
+
if (!passive && owns) {
|
|
721
719
|
chan.refs += 1;
|
|
722
720
|
touch(chan);
|
|
723
721
|
}
|
|
724
722
|
return () => {
|
|
723
|
+
if (!owns) return;
|
|
724
|
+
owns = false;
|
|
725
725
|
if (!set.delete(item) || passive) return;
|
|
726
726
|
chan.refs -= 1;
|
|
727
727
|
after?.();
|