svelte-adapter-uws 0.5.2 → 0.5.4
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/README.md +35 -17
- package/files/handler.js +21 -6
- package/index.d.ts +20 -0
- package/package.json +1 -1
- package/plugins/cursor/server.js +142 -28
- package/plugins/presence/client.d.ts +9 -0
- package/plugins/presence/client.js +68 -15
- package/plugins/presence/server.d.ts +18 -6
- package/plugins/presence/server.js +27 -11
- package/testing.js +27 -4
- package/vite.js +25 -4
package/README.md
CHANGED
|
@@ -699,12 +699,25 @@ export function open(ws, { platform }) {
|
|
|
699
699
|
ws.subscribe(`user:${userId}`);
|
|
700
700
|
}
|
|
701
701
|
|
|
702
|
-
// Called when a message is received
|
|
702
|
+
// Called when a message is received.
|
|
703
703
|
// Note: subscribe/unsubscribe messages from the client store are
|
|
704
|
-
// handled automatically BEFORE this function is called
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
704
|
+
// handled automatically BEFORE this function is called.
|
|
705
|
+
//
|
|
706
|
+
// `msg` is the JSON-parsed envelope when the adapter parsed the frame
|
|
707
|
+
// for control-message routing but no control type matched (i.e. it
|
|
708
|
+
// looks like `{"type":"<custom>",...}` from a plugin). The adapter
|
|
709
|
+
// already did `TextDecoder + JSON.parse` once during routing, so this
|
|
710
|
+
// avoids a second parse on the dispatch path. `msg` is `undefined`
|
|
711
|
+
// for binary frames, prefix-miss frames, parse failures, or frames
|
|
712
|
+
// that parse to a non-object.
|
|
713
|
+
export function message(ws, { data, isBinary, msg }) {
|
|
714
|
+
if (msg) {
|
|
715
|
+
// Already-parsed JSON object envelope - dispatch by msg.type
|
|
716
|
+
console.log('Got envelope:', msg);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
// Binary or non-envelope text frame - decode manually
|
|
720
|
+
console.log('Got raw frame, byteLength:', data.byteLength);
|
|
708
721
|
}
|
|
709
722
|
|
|
710
723
|
// Called when a client tries to subscribe to a topic (optional)
|
|
@@ -2364,8 +2377,8 @@ import { createPresence } from 'svelte-adapter-uws/plugins/presence';
|
|
|
2364
2377
|
|
|
2365
2378
|
export const presence = createPresence({
|
|
2366
2379
|
key: 'id',
|
|
2367
|
-
select: (userData) => ({ id: userData.id, name: userData.name })
|
|
2368
|
-
heartbeat:
|
|
2380
|
+
select: (userData) => ({ id: userData.id, name: userData.name })
|
|
2381
|
+
// heartbeat: 30_000 (default) - broadcast every 30s; clients refresh maxAge / re-add aged-out entries
|
|
2369
2382
|
// maxConnections: 1_000_000 (default) - hard cap on tracked connections
|
|
2370
2383
|
// maxTopics: 1_000_000 (default) - hard cap on active topic registry
|
|
2371
2384
|
});
|
|
@@ -2437,8 +2450,8 @@ import { createPresence } from 'svelte-adapter-uws/plugins/presence';
|
|
|
2437
2450
|
|
|
2438
2451
|
const presence = createPresence({
|
|
2439
2452
|
key: 'id', // field for multi-tab dedup (default: 'id')
|
|
2440
|
-
select: (userData) => userData, // extract public fields (default:
|
|
2441
|
-
heartbeat:
|
|
2453
|
+
select: (userData) => userData, // extract public fields (default: recursive denylist)
|
|
2454
|
+
heartbeat: 30_000 // broadcast every 30s (default: 30000; pass 0 to disable)
|
|
2442
2455
|
});
|
|
2443
2456
|
|
|
2444
2457
|
presence.hooks // ready-made { subscribe, unsubscribe, close } hooks
|
|
@@ -2453,14 +2466,15 @@ presence.clear() // reset everything (stops heartbeat timer)
|
|
|
2453
2466
|
|
|
2454
2467
|
#### Wire format
|
|
2455
2468
|
|
|
2456
|
-
The plugin emits
|
|
2469
|
+
The plugin emits three frame types on the `__presence:{topic}` channel:
|
|
2457
2470
|
|
|
2458
2471
|
- `{event: 'presence_state', data: {[key]: meta}}` - full snapshot, sent to a single connection on join or sync.
|
|
2459
2472
|
- `{event: 'presence_diff', data: {joins: {[key]: meta}, leaves: {[key]: meta}}}` - changes, broadcast to all subscribers of the topic.
|
|
2473
|
+
- `{event: 'heartbeat', data: {[key]: meta}}` - periodic full-roster refresh, broadcast every `heartbeat` ms (30 s default). Carries a `{userKey: data}` map so a client whose entry aged out of its local `maxAge` sweep can re-add it from the heartbeat alone, without waiting for the next `presence_diff`.
|
|
2460
2474
|
|
|
2461
2475
|
Diffs are buffered in a microtask queue: multiple joins / leaves in the same tick collapse into one diff frame. Within a diff, `leaves` are applied first then `joins`, so an update (same key in both) ends with the user present using the new data. If a key cycles join then leave in the same tick, the diff carries only the latest op (`leave` wins).
|
|
2462
2476
|
|
|
2463
|
-
|
|
2477
|
+
The Redis-backed variant in the [extensions](https://github.com/lanteanio/svelte-adapter-uws-extensions) package emits the same three frame shapes, so the same client bundle works against either backend.
|
|
2464
2478
|
|
|
2465
2479
|
#### Client API
|
|
2466
2480
|
|
|
@@ -2471,20 +2485,24 @@ const users = presence('room');
|
|
|
2471
2485
|
// $users = [{ id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }]
|
|
2472
2486
|
```
|
|
2473
2487
|
|
|
2474
|
-
The
|
|
2488
|
+
The client store defaults to a 90 s `maxAge` sweep: entries that haven't been refreshed by a heartbeat or `presence_diff` / `presence_state` inside the window are removed from the local map. With the server's 30 s default heartbeat, still-present users are refreshed three times per window and never flicker; ghost entries left over by silent server-side cleanup (cluster mass-disconnect, ungraceful client close) clear within one sweep window.
|
|
2489
|
+
|
|
2490
|
+
For admin / audit views that want unbounded retention ("show every user who ever touched this topic"), opt out with `maxAge: 0`:
|
|
2475
2491
|
|
|
2476
|
-
|
|
2492
|
+
```js
|
|
2493
|
+
const everyoneEver = presence('room', { maxAge: 0 });
|
|
2494
|
+
```
|
|
2495
|
+
|
|
2496
|
+
To customize the window, set `maxAge` and the matching server `heartbeat` together (rule of thumb: heartbeat is one-third of `maxAge` or less, so a still-present user gets at least two refreshes per sweep window):
|
|
2477
2497
|
|
|
2478
2498
|
```js
|
|
2479
2499
|
// Server: heartbeat every 60s
|
|
2480
2500
|
const presence = createPresence({ key: 'id', heartbeat: 60_000 });
|
|
2481
2501
|
|
|
2482
|
-
// Client: entries expire after
|
|
2483
|
-
const users = presence('room', { maxAge:
|
|
2502
|
+
// Client: entries expire after 180s without a heartbeat refresh
|
|
2503
|
+
const users = presence('room', { maxAge: 180_000 });
|
|
2484
2504
|
```
|
|
2485
2505
|
|
|
2486
|
-
Rule of thumb: set `heartbeat` to half (or less) of the client's `maxAge`.
|
|
2487
|
-
|
|
2488
2506
|
#### How multi-tab dedup works
|
|
2489
2507
|
|
|
2490
2508
|
If user "Alice" (key `id: '1'`) has three browser tabs open, `presence.join()` is called three times with the same key. The plugin ref-counts connections per key: Alice appears once in the list. When she closes two tabs, she stays present. Only when the last tab closes does the plugin broadcast a `leave` event.
|
package/files/handler.js
CHANGED
|
@@ -3035,17 +3035,30 @@ if (WS_ENABLED) {
|
|
|
3035
3035
|
// The 8192-byte ceiling is generous enough for subscribe-batch with
|
|
3036
3036
|
// many topics (N * 256-char names) while keeping the JSON.parse
|
|
3037
3037
|
// guard against truly large user messages.
|
|
3038
|
+
// `msg` is hoisted to outer scope so it can be forwarded to the user
|
|
3039
|
+
// handler in the fall-through delegation below. When the prefix
|
|
3040
|
+
// matched and JSON.parse produced an object that did NOT match any
|
|
3041
|
+
// known control type, the parsed value reaches plugin-layer
|
|
3042
|
+
// dispatchers (e.g. svelte-realtime's `onJsonMessage`) directly, so
|
|
3043
|
+
// they don't re-run TextDecoder + JSON.parse on every frame.
|
|
3044
|
+
/** @type {any} */
|
|
3045
|
+
let msg;
|
|
3038
3046
|
if (!isBinary && message.byteLength < 8192 &&
|
|
3039
3047
|
(new Uint8Array(message))[3] === 0x79 /* 'y' in {"type" */) {
|
|
3040
3048
|
/** @type {any} */
|
|
3041
|
-
let
|
|
3049
|
+
let parsed;
|
|
3042
3050
|
try {
|
|
3043
|
-
|
|
3051
|
+
parsed = JSON.parse(textDecoder.decode(message));
|
|
3044
3052
|
} catch {
|
|
3045
|
-
|
|
3046
|
-
|
|
3053
|
+
parsed = undefined;
|
|
3054
|
+
}
|
|
3055
|
+
if (parsed === null || typeof parsed !== 'object') {
|
|
3056
|
+
// Not a JSON object envelope (parse failed, or parsed to
|
|
3057
|
+
// null / primitive / array). Forward raw bytes only.
|
|
3058
|
+
wsModule.message?.(ws, { data: message, isBinary, msg, platform: ws.getUserData()[WS_PLATFORM] });
|
|
3047
3059
|
return;
|
|
3048
3060
|
}
|
|
3061
|
+
msg = parsed;
|
|
3049
3062
|
if (msg.type === 'subscribe' && typeof msg.topic === 'string') {
|
|
3050
3063
|
const ref = hasRef(msg.ref) ? msg.ref : null;
|
|
3051
3064
|
if (!isValidWireTopic(msg.topic, ALLOW_NON_ASCII_TOPICS)) {
|
|
@@ -3230,8 +3243,10 @@ if (WS_ENABLED) {
|
|
|
3230
3243
|
return;
|
|
3231
3244
|
}
|
|
3232
3245
|
}
|
|
3233
|
-
// Delegate everything else to the user's handler (if provided)
|
|
3234
|
-
|
|
3246
|
+
// Delegate everything else to the user's handler (if provided).
|
|
3247
|
+
// `msg` is the JSON-parsed envelope when the prefix matched + parsed
|
|
3248
|
+
// to an object + no control type matched; otherwise undefined.
|
|
3249
|
+
wsModule.message?.(ws, { data: message, isBinary, msg, platform: ws.getUserData()[WS_PLATFORM] });
|
|
3235
3250
|
},
|
|
3236
3251
|
|
|
3237
3252
|
drain: (ws) => {
|
package/index.d.ts
CHANGED
|
@@ -517,6 +517,26 @@ export interface MessageContext {
|
|
|
517
517
|
data: ArrayBuffer;
|
|
518
518
|
/** Whether the message is binary. */
|
|
519
519
|
isBinary: boolean;
|
|
520
|
+
/**
|
|
521
|
+
* The JSON-parsed envelope, when the adapter parsed the frame for
|
|
522
|
+
* control-message routing (subscribe / unsubscribe / hello / resume /
|
|
523
|
+
* reply / subscribe-batch) but no control type matched.
|
|
524
|
+
*
|
|
525
|
+
* Plugin-layer JSON envelope dispatchers (e.g. svelte-realtime's
|
|
526
|
+
* `createMessage({ onJsonMessage })`) consume this directly instead of
|
|
527
|
+
* re-running `TextDecoder + JSON.parse` on every frame.
|
|
528
|
+
*
|
|
529
|
+
* `undefined` when:
|
|
530
|
+
* - the frame is binary (`isBinary === true`), or
|
|
531
|
+
* - the frame did not start with `{"ty` (byte[3] !== 0x79), or
|
|
532
|
+
* - the frame was larger than 8 KiB, or
|
|
533
|
+
* - `JSON.parse` threw, or
|
|
534
|
+
* - the parsed value was not a plain object (null / array / primitive).
|
|
535
|
+
*
|
|
536
|
+
* The adapter's `websocket.maxPayloadLength` (default 1 MB) is the
|
|
537
|
+
* structural ceiling for frame size; this field adds no separate cap.
|
|
538
|
+
*/
|
|
539
|
+
msg?: any;
|
|
520
540
|
/** The platform API - publish, send, topic helpers, etc. */
|
|
521
541
|
platform: Platform;
|
|
522
542
|
}
|
package/package.json
CHANGED
package/plugins/cursor/server.js
CHANGED
|
@@ -99,6 +99,13 @@ const EVENTS = Object.freeze({
|
|
|
99
99
|
* subscribe so late joiners see existing cursors immediately.
|
|
100
100
|
* @property {() => void} clear -
|
|
101
101
|
* Clear all cursor tracking state and pending timers.
|
|
102
|
+
* @property {() => { flushes: number, driftMeanMs: number, driftMaxMs: number, dirtyTopicsCurrent: number, activeTopicsTotal: number }} stats -
|
|
103
|
+
* Snapshot of scheduler health. `flushes` is the total tick-driven
|
|
104
|
+
* flushes; `driftMeanMs` / `driftMaxMs` measure the gap between the
|
|
105
|
+
* target deadline and the actual fire time (`> topicThrottle` indicates
|
|
106
|
+
* sustained event-loop saturation); `dirtyTopicsCurrent` is topics with
|
|
107
|
+
* pending coalesced entries (should hover near zero); `activeTopicsTotal`
|
|
108
|
+
* is topics with at least one local cursor.
|
|
102
109
|
*/
|
|
103
110
|
|
|
104
111
|
/**
|
|
@@ -193,14 +200,48 @@ export function createCursor(options = {}) {
|
|
|
193
200
|
const topics = new Map();
|
|
194
201
|
|
|
195
202
|
/**
|
|
196
|
-
* Per-topic aggregate
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
203
|
+
* Per-topic aggregate flush state.
|
|
204
|
+
*
|
|
205
|
+
* - `dirty`: cursors awaiting coalesced flush. Keyed by connection key;
|
|
206
|
+
* latest-wins. When the coalesce window elapses, `dirty.size === 1`
|
|
207
|
+
* sends a single `update`; any other count sends one `bulk` array.
|
|
208
|
+
* - `lastFlush`: target-anchored timestamp of the most recent flush.
|
|
209
|
+
* Advanced by `topicThrottleMs` per cycle (not to actual fire time)
|
|
210
|
+
* so a single late tick does not compound drift on subsequent cycles.
|
|
211
|
+
*
|
|
212
|
+
* @type {Map<string, { dirty: Map<string, { data: any, platform: any }>, lastFlush: number }>}
|
|
201
213
|
*/
|
|
202
214
|
const topicFlush = new Map();
|
|
203
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Topics with at least one pending dirty entry. Bounded by mover count,
|
|
218
|
+
* not active-topic count, so the scheduler walks only dirty topics on
|
|
219
|
+
* each tick instead of every active one.
|
|
220
|
+
* @type {Set<string>}
|
|
221
|
+
*/
|
|
222
|
+
const dirtyTopics = new Set();
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Single tracker-wide timer. Always points at the next earliest topic
|
|
226
|
+
* deadline (or null when idle). Replaces the previous per-topic
|
|
227
|
+
* setTimeout pattern: N pending timers -> 1 pending timer regardless
|
|
228
|
+
* of topic count. Scheduling cost is O(dirty topics), not O(active
|
|
229
|
+
* topics).
|
|
230
|
+
* @type {ReturnType<typeof setTimeout> | null}
|
|
231
|
+
*/
|
|
232
|
+
let tickTimer = null;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Drift accounting for `stats()` observability. Mean (target - actual)
|
|
236
|
+
* and max over tick-driven flushes. Leading-edge synchronous flushes
|
|
237
|
+
* are NOT counted (they fire on the caller's thread, not via the
|
|
238
|
+
* scheduler; their drift is structurally zero).
|
|
239
|
+
*/
|
|
240
|
+
let driftSum = 0;
|
|
241
|
+
let driftCount = 0;
|
|
242
|
+
let driftMax = 0;
|
|
243
|
+
let flushCount = 0;
|
|
244
|
+
|
|
204
245
|
/**
|
|
205
246
|
* Get or create ws state and return the connection key + user data.
|
|
206
247
|
* @param {any} ws
|
|
@@ -224,14 +265,15 @@ export function createCursor(options = {}) {
|
|
|
224
265
|
}
|
|
225
266
|
|
|
226
267
|
/**
|
|
227
|
-
* Drop the topic's coalesce state
|
|
268
|
+
* Drop the topic's coalesce state. The single tracker-wide tickTimer is
|
|
269
|
+
* left alone (it self-cancels on the next tick when `dirtyTopics` is
|
|
270
|
+
* empty); we just remove this topic from both the flush map and the
|
|
271
|
+
* dirty set so the next tick skips it.
|
|
228
272
|
* @param {string} topic
|
|
229
273
|
*/
|
|
230
274
|
function clearTopicFlush(topic) {
|
|
231
|
-
const flushState = topicFlush.get(topic);
|
|
232
|
-
if (!flushState) return;
|
|
233
|
-
if (flushState.timer) clearTimeout(flushState.timer);
|
|
234
275
|
topicFlush.delete(topic);
|
|
276
|
+
dirtyTopics.delete(topic);
|
|
235
277
|
}
|
|
236
278
|
|
|
237
279
|
/**
|
|
@@ -262,6 +304,7 @@ export function createCursor(options = {}) {
|
|
|
262
304
|
*/
|
|
263
305
|
function flushDirty(topic, dirty) {
|
|
264
306
|
if (dirty.size === 0) return;
|
|
307
|
+
flushCount++;
|
|
265
308
|
if (dirty.size === 1) {
|
|
266
309
|
const [k, v] = dirty.entries().next().value;
|
|
267
310
|
doBroadcast(topic, k, v.data, v.platform);
|
|
@@ -278,9 +321,65 @@ export function createCursor(options = {}) {
|
|
|
278
321
|
}
|
|
279
322
|
}
|
|
280
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Scheduler tick. Walks `dirtyTopics`, flushes any topic whose deadline
|
|
326
|
+
* (`lastFlush + topicThrottleMs`) has passed, and re-arms `tickTimer`
|
|
327
|
+
* for the next earliest pending deadline. Topics whose deadline has
|
|
328
|
+
* not yet passed stay in `dirtyTopics` for the next tick.
|
|
329
|
+
*
|
|
330
|
+
* Target-anchored advance: on flush, `lastFlush` is set to the deadline
|
|
331
|
+
* (not the actual fire time) so a single late tick does not compound
|
|
332
|
+
* drift on subsequent cycles. If we fell behind by more than one cycle
|
|
333
|
+
* (event loop saturation > `topicThrottleMs`), `lastFlush` resets to
|
|
334
|
+
* `now` to avoid queueing phantom catch-up fires.
|
|
335
|
+
*/
|
|
336
|
+
function tick() {
|
|
337
|
+
tickTimer = null;
|
|
338
|
+
const now = Date.now();
|
|
339
|
+
let nextDeadline = Infinity;
|
|
340
|
+
|
|
341
|
+
for (const topic of dirtyTopics) {
|
|
342
|
+
const state = topicFlush.get(topic);
|
|
343
|
+
if (!state) { dirtyTopics.delete(topic); continue; }
|
|
344
|
+
if (state.dirty.size === 0) {
|
|
345
|
+
dirtyTopics.delete(topic);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const deadline = state.lastFlush + topicThrottleMs;
|
|
349
|
+
if (deadline <= now) {
|
|
350
|
+
const drift = now - deadline;
|
|
351
|
+
driftSum += drift;
|
|
352
|
+
driftCount++;
|
|
353
|
+
if (drift > driftMax) driftMax = drift;
|
|
354
|
+
|
|
355
|
+
flushDirty(topic, state.dirty); // increments flushCount internally
|
|
356
|
+
state.dirty.clear();
|
|
357
|
+
dirtyTopics.delete(topic);
|
|
358
|
+
|
|
359
|
+
state.lastFlush = drift < topicThrottleMs ? deadline : now;
|
|
360
|
+
} else if (deadline < nextDeadline) {
|
|
361
|
+
nextDeadline = deadline;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (nextDeadline !== Infinity) {
|
|
366
|
+
tickTimer = setTimeout(tick, Math.max(0, nextDeadline - Date.now()));
|
|
367
|
+
}
|
|
368
|
+
// else: scheduler idle until next `broadcast()` call.
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function armTick(delay) {
|
|
372
|
+
if (tickTimer !== null) return;
|
|
373
|
+
tickTimer = setTimeout(tick, delay);
|
|
374
|
+
}
|
|
375
|
+
|
|
281
376
|
/**
|
|
282
377
|
* Route a broadcast through the per-topic coalesce window when
|
|
283
378
|
* `topicThrottle` is enabled, or directly publish when disabled.
|
|
379
|
+
*
|
|
380
|
+
* Leading-edge synchronous flush preserves the contract that the first
|
|
381
|
+
* call on an idle topic publishes immediately (no setTimeout(0) detour).
|
|
382
|
+
* Trailing-edge fires via the single tracker-wide `tickTimer`.
|
|
284
383
|
*/
|
|
285
384
|
function broadcast(topic, key, data, platform) {
|
|
286
385
|
if (topicThrottleMs <= 0) {
|
|
@@ -290,34 +389,22 @@ export function createCursor(options = {}) {
|
|
|
290
389
|
|
|
291
390
|
let state = topicFlush.get(topic);
|
|
292
391
|
if (!state) {
|
|
293
|
-
state = {
|
|
392
|
+
state = { dirty: new Map(), lastFlush: 0 };
|
|
294
393
|
topicFlush.set(topic, state);
|
|
295
394
|
}
|
|
296
|
-
|
|
297
395
|
state.dirty.set(key, { data, platform });
|
|
298
396
|
|
|
299
397
|
const now = Date.now();
|
|
300
398
|
if (now - state.lastFlush >= topicThrottleMs) {
|
|
301
|
-
if (state.timer) {
|
|
302
|
-
clearTimeout(state.timer);
|
|
303
|
-
state.timer = null;
|
|
304
|
-
}
|
|
305
399
|
state.lastFlush = now;
|
|
306
400
|
flushDirty(topic, state.dirty);
|
|
307
401
|
state.dirty.clear();
|
|
402
|
+
dirtyTopics.delete(topic);
|
|
308
403
|
return;
|
|
309
404
|
}
|
|
310
405
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const s = topicFlush.get(topic);
|
|
314
|
-
if (!s) return;
|
|
315
|
-
s.timer = null;
|
|
316
|
-
s.lastFlush = Date.now();
|
|
317
|
-
flushDirty(topic, s.dirty);
|
|
318
|
-
s.dirty.clear();
|
|
319
|
-
}, topicThrottleMs - (now - state.lastFlush));
|
|
320
|
-
}
|
|
406
|
+
dirtyTopics.add(topic);
|
|
407
|
+
armTick(Math.max(0, topicThrottleMs - (now - state.lastFlush)));
|
|
321
408
|
}
|
|
322
409
|
|
|
323
410
|
/** @type {CursorTracker} */
|
|
@@ -460,15 +547,42 @@ export function createCursor(options = {}) {
|
|
|
460
547
|
if (entry.timer) clearTimeout(entry.timer);
|
|
461
548
|
}
|
|
462
549
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
}
|
|
550
|
+
if (tickTimer !== null) { clearTimeout(tickTimer); tickTimer = null; }
|
|
551
|
+
dirtyTopics.clear();
|
|
466
552
|
topics.clear();
|
|
467
553
|
topicFlush.clear();
|
|
468
554
|
wsState.clear();
|
|
469
555
|
connCounter = 0;
|
|
470
556
|
},
|
|
471
557
|
|
|
558
|
+
/**
|
|
559
|
+
* Snapshot of scheduler health. Always available, near-zero cost.
|
|
560
|
+
*
|
|
561
|
+
* - `flushes`: total tick-driven flushes since tracker creation.
|
|
562
|
+
* - `driftMeanMs`: mean (target_deadline - actual_fire_time) across
|
|
563
|
+
* all tick-driven flushes. 0 means perfect cadence; values >
|
|
564
|
+
* `topicThrottle` indicate sustained event-loop saturation or
|
|
565
|
+
* CPU contention.
|
|
566
|
+
* - `driftMaxMs`: largest single observed late fire. Useful for
|
|
567
|
+
* spotting one-off GC pauses vs. sustained drift.
|
|
568
|
+
* - `dirtyTopicsCurrent`: topics with pending coalesced entries
|
|
569
|
+
* right now. Should hover near zero in healthy operation.
|
|
570
|
+
* - `activeTopicsTotal`: topics with at least one local cursor.
|
|
571
|
+
*
|
|
572
|
+
* Leading-edge synchronous flushes (first call on an idle topic)
|
|
573
|
+
* are not counted in drift stats - they fire on the call thread,
|
|
574
|
+
* not via the scheduler.
|
|
575
|
+
*/
|
|
576
|
+
stats() {
|
|
577
|
+
return {
|
|
578
|
+
flushes: flushCount,
|
|
579
|
+
driftMeanMs: driftCount > 0 ? driftSum / driftCount : 0,
|
|
580
|
+
driftMaxMs: driftMax,
|
|
581
|
+
dirtyTopicsCurrent: dirtyTopics.size,
|
|
582
|
+
activeTopicsTotal: topics.size
|
|
583
|
+
};
|
|
584
|
+
},
|
|
585
|
+
|
|
472
586
|
hooks: {
|
|
473
587
|
message(ws, { data, platform }) {
|
|
474
588
|
let parsed;
|
|
@@ -6,10 +6,19 @@ import type { Readable } from 'svelte/store';
|
|
|
6
6
|
* Returns a readable Svelte store containing an array of user data objects.
|
|
7
7
|
* The array updates automatically when users join or leave.
|
|
8
8
|
*
|
|
9
|
+
* Defaults to a 90 s `maxAge` sweep: entries that haven't been refreshed
|
|
10
|
+
* by a heartbeat or presence_diff/state inside the window are removed
|
|
11
|
+
* from the local map. The server emits `{userKey: data}` heartbeats
|
|
12
|
+
* every 30 s by default, so still-present users re-appear on the next
|
|
13
|
+
* heartbeat (no flicker). Pass `maxAge: 0` to opt out of the sweep for
|
|
14
|
+
* admin / audit views that want unbounded retention.
|
|
15
|
+
*
|
|
9
16
|
* You must also subscribe to the topic itself (via `on()`, `crud()`, etc.)
|
|
10
17
|
* for the server's `subscribe` hook to fire and register your presence.
|
|
11
18
|
*
|
|
12
19
|
* @param topic - Topic to track presence on
|
|
20
|
+
* @param options - `maxAge` defaults to 90000 (ms). Pass `0` to disable
|
|
21
|
+
* the sweep.
|
|
13
22
|
*
|
|
14
23
|
* @example
|
|
15
24
|
* ```svelte
|
|
@@ -5,17 +5,24 @@
|
|
|
5
5
|
* a live list of who's connected. The server handles join/leave tracking;
|
|
6
6
|
* this module just keeps the client-side state in sync.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
* or
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* Defaults to a 90 s `maxAge` sweep: entries that haven't been refreshed
|
|
9
|
+
* by a heartbeat or presence_diff/state inside the window are removed
|
|
10
|
+
* from the local map. The in-memory server (and the Redis-backed variant
|
|
11
|
+
* in svelte-adapter-uws-extensions) emits `{userKey: data}` heartbeats
|
|
12
|
+
* every 30 s by default, so a still-present user re-appears on the very
|
|
13
|
+
* next heartbeat - no flicker for live users, and ghost entries from
|
|
14
|
+
* silent server-side TTL expiry (cluster mass-disconnect, ungraceful
|
|
15
|
+
* client close) clear within one sweep window.
|
|
16
|
+
*
|
|
17
|
+
* Apps that want unbounded retention ("show every user who ever touched
|
|
18
|
+
* this topic" - admin / audit views) opt out with `maxAge: 0`.
|
|
12
19
|
*
|
|
13
20
|
* @module svelte-adapter-uws/plugins/presence/client
|
|
14
21
|
*/
|
|
15
22
|
|
|
16
23
|
const TOPIC_PREFIX = '__presence:';
|
|
17
24
|
|
|
18
|
-
import { on } from '../../client.js';
|
|
25
|
+
import { on, connect, status } from '../../client.js';
|
|
19
26
|
import { writable } from 'svelte/store';
|
|
20
27
|
|
|
21
28
|
/** @type {Map<string, { subscribe: (fn: Function) => (() => void) }>} */
|
|
@@ -62,14 +69,19 @@ const presenceStores = new Map();
|
|
|
62
69
|
* @example
|
|
63
70
|
* ```svelte
|
|
64
71
|
* <script>
|
|
65
|
-
* //
|
|
66
|
-
* const users = presence('room', { maxAge:
|
|
72
|
+
* // Opt out of the default 90 s sweep for an admin / audit view.
|
|
73
|
+
* const users = presence('room', { maxAge: 0 });
|
|
67
74
|
* </script>
|
|
68
75
|
* ```
|
|
69
76
|
*/
|
|
70
77
|
export function presence(topic, options) {
|
|
71
|
-
|
|
72
|
-
|
|
78
|
+
// Default 90 s sweep matches the extensions Redis presence's default
|
|
79
|
+
// `ttl: 90` (server-side per-field TTL) and gives the in-memory
|
|
80
|
+
// server's 30 s default heartbeat a 3x safety margin. Apps that want
|
|
81
|
+
// "show every user who ever touched this topic" (admin/audit views)
|
|
82
|
+
// opt out with `maxAge: 0`.
|
|
83
|
+
const maxAge = options?.maxAge ?? 90000;
|
|
84
|
+
const cacheKey = topic + '\0' + maxAge;
|
|
73
85
|
|
|
74
86
|
const cached = presenceStores.get(cacheKey);
|
|
75
87
|
if (cached) return cached;
|
|
@@ -83,9 +95,11 @@ export function presence(topic, options) {
|
|
|
83
95
|
const output = writable(/** @type {any[]} */ ([]));
|
|
84
96
|
|
|
85
97
|
let sourceUnsub = /** @type {(() => void) | null} */ (null);
|
|
98
|
+
let statusUnsub = /** @type {(() => void) | null} */ (null);
|
|
86
99
|
/** @type {ReturnType<typeof setInterval> | null} */
|
|
87
100
|
let sweepTimer = null;
|
|
88
101
|
let refCount = 0;
|
|
102
|
+
let cancelled = false;
|
|
89
103
|
|
|
90
104
|
function flush() {
|
|
91
105
|
output.set([...userMap.values()]);
|
|
@@ -105,6 +119,7 @@ export function presence(topic, options) {
|
|
|
105
119
|
}
|
|
106
120
|
|
|
107
121
|
function startListening() {
|
|
122
|
+
cancelled = false;
|
|
108
123
|
// Fresh on() call each time - the underlying writable in client.js
|
|
109
124
|
// is cleaned up on full unsubscribe, so a stale reference would
|
|
110
125
|
// silently stop receiving events.
|
|
@@ -150,29 +165,67 @@ export function presence(topic, options) {
|
|
|
150
165
|
return;
|
|
151
166
|
}
|
|
152
167
|
|
|
153
|
-
if (event.event === 'heartbeat'
|
|
154
|
-
// Server confirms these keys are still active - refresh their
|
|
155
|
-
// timestamps so maxAge doesn't expire them. Keys not in the
|
|
156
|
-
// heartbeat are left alone (maxAge will handle them).
|
|
168
|
+
if (event.event === 'heartbeat') {
|
|
157
169
|
const now = Date.now();
|
|
158
|
-
|
|
159
|
-
|
|
170
|
+
let changed = false;
|
|
171
|
+
if (event.data && typeof event.data === 'object' && !Array.isArray(event.data)) {
|
|
172
|
+
// New shape: `{userKey: data}` map. Refresh existing AND
|
|
173
|
+
// re-add any entry that aged out between heartbeats. The
|
|
174
|
+
// older "refresh existing only" branch (below) could not
|
|
175
|
+
// recover entries the local sweep had already removed -
|
|
176
|
+
// once an entry aged out, the next heartbeat couldn't
|
|
177
|
+
// bring it back and the user stayed missing until a
|
|
178
|
+
// presence_diff or presence_state arrived.
|
|
179
|
+
for (const [key, data] of Object.entries(event.data)) {
|
|
160
180
|
timestamps.set(key, now);
|
|
181
|
+
const prev = userMap.get(key);
|
|
182
|
+
if (prev !== data) {
|
|
183
|
+
userMap.set(key, data);
|
|
184
|
+
changed = true;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} else if (Array.isArray(event.data)) {
|
|
188
|
+
// Back-compat: keys-only heartbeat (older server). Refresh
|
|
189
|
+
// existing entries; cannot recover aged-out ones from this
|
|
190
|
+
// shape. The presence_diff / presence_state reconciliation
|
|
191
|
+
// path still corrects missing entries on the next event.
|
|
192
|
+
for (const key of event.data) {
|
|
193
|
+
if (timestamps.has(key)) {
|
|
194
|
+
timestamps.set(key, now);
|
|
195
|
+
}
|
|
161
196
|
}
|
|
162
197
|
}
|
|
198
|
+
if (changed) flush();
|
|
199
|
+
return;
|
|
163
200
|
}
|
|
164
201
|
});
|
|
165
202
|
|
|
166
203
|
if (maxAge > 0) {
|
|
167
204
|
sweepTimer = setInterval(sweep, Math.max(maxAge / 2, 1000));
|
|
168
205
|
}
|
|
206
|
+
|
|
207
|
+
// Request a presence snapshot every time the socket opens (initial
|
|
208
|
+
// connect AND reconnects). Without this, a reconnecting client
|
|
209
|
+
// missed any presence_diff frames that fired during the disconnect
|
|
210
|
+
// window and its in-memory map stayed at whatever it last knew.
|
|
211
|
+
// Symmetric to the cursor plugin's `cursor-snapshot` send.
|
|
212
|
+
statusUnsub = status.subscribe((s) => {
|
|
213
|
+
if (s === 'open' && !cancelled) {
|
|
214
|
+
connect().send({ type: 'presence-snapshot', topic });
|
|
215
|
+
}
|
|
216
|
+
});
|
|
169
217
|
}
|
|
170
218
|
|
|
171
219
|
function stopListening() {
|
|
220
|
+
cancelled = true;
|
|
172
221
|
if (sourceUnsub) {
|
|
173
222
|
sourceUnsub();
|
|
174
223
|
sourceUnsub = null;
|
|
175
224
|
}
|
|
225
|
+
if (statusUnsub) {
|
|
226
|
+
statusUnsub();
|
|
227
|
+
statusUnsub = null;
|
|
228
|
+
}
|
|
176
229
|
if (sweepTimer) {
|
|
177
230
|
clearInterval(sweepTimer);
|
|
178
231
|
sweepTimer = null;
|
|
@@ -47,19 +47,31 @@ export interface PresenceOptions<UserData = unknown, Selected extends Record<str
|
|
|
47
47
|
/**
|
|
48
48
|
* Interval in milliseconds between heartbeat broadcasts.
|
|
49
49
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
50
|
+
* The server periodically publishes a `heartbeat` event to all presence
|
|
51
|
+
* topics carrying a `{userKey: data}` map of every active user. This
|
|
52
|
+
* refreshes each entry's `maxAge` timer on the client AND re-adds any
|
|
53
|
+
* entry the client swept while the user was still present, so live
|
|
54
|
+
* users do not flicker out when a `presence_diff` is missed (transient
|
|
55
|
+
* network blip, JS thread saturation).
|
|
53
56
|
*
|
|
54
|
-
* Set this to a value shorter than the client's `maxAge`.
|
|
57
|
+
* Set this to a value shorter than the client's `maxAge`. The 30 s
|
|
58
|
+
* default fits the 90 s default client `maxAge` with a 3x safety
|
|
59
|
+
* margin. Pass `0` to disable heartbeats entirely (apps that do not
|
|
60
|
+
* use the `maxAge` self-healing path).
|
|
55
61
|
*
|
|
56
|
-
* @default
|
|
62
|
+
* @default 30000
|
|
57
63
|
*
|
|
58
64
|
* @example
|
|
59
65
|
* ```js
|
|
60
|
-
* //
|
|
66
|
+
* // Slower heartbeat (less wire traffic, larger window for ghost entries)
|
|
61
67
|
* const presence = createPresence({ heartbeat: 60_000 });
|
|
62
68
|
* ```
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```js
|
|
72
|
+
* // Disable heartbeats; client must rely on presence_diff alone
|
|
73
|
+
* const presence = createPresence({ heartbeat: 0 });
|
|
74
|
+
* ```
|
|
63
75
|
*/
|
|
64
76
|
heartbeat?: number;
|
|
65
77
|
|
|
@@ -52,11 +52,14 @@ const TOPIC_PREFIX = '__presence:';
|
|
|
52
52
|
*
|
|
53
53
|
* Should return JSON-serializable data (plain objects, arrays, strings, numbers,
|
|
54
54
|
* booleans, null) since the result is sent over WebSocket.
|
|
55
|
-
* @property {number} [heartbeat=
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
55
|
+
* @property {number} [heartbeat=30000] - Interval in milliseconds between heartbeat broadcasts.
|
|
56
|
+
* The server periodically publishes a `heartbeat` event to all presence topics carrying a
|
|
57
|
+
* `{userKey: data}` map of every active user. This refreshes each entry's `maxAge` timer on
|
|
58
|
+
* the client AND re-adds any entry the client swept while the user was still present, so
|
|
59
|
+
* live users do not flicker out when a `presence_diff` is missed (e.g. transient network
|
|
60
|
+
* blip, JS thread saturation). Set this to a value shorter than the client's `maxAge`
|
|
61
|
+
* (default client `maxAge` is 90 s, so 30 s gives a 3x safety margin). Pass `0` to disable
|
|
62
|
+
* heartbeats entirely (apps that do not use the `maxAge` self-healing path).
|
|
60
63
|
*/
|
|
61
64
|
|
|
62
65
|
/**
|
|
@@ -252,7 +255,15 @@ function defaultPresenceSelect(obj, ancestors) {
|
|
|
252
255
|
export function createPresence(options = {}) {
|
|
253
256
|
const keyField = options.key || 'id';
|
|
254
257
|
const select = options.select || defaultPresenceSelect;
|
|
255
|
-
|
|
258
|
+
// Default 30 s heartbeat keeps the client's `maxAge` sweep self-healing:
|
|
259
|
+
// a still-present user re-appears on the next heartbeat after their
|
|
260
|
+
// entry ages out of the local map. Apps that want zero heartbeat
|
|
261
|
+
// traffic (no `maxAge` consumers, or out-of-band liveness) pass
|
|
262
|
+
// `heartbeat: 0` explicitly to opt out.
|
|
263
|
+
const heartbeatMs = options.heartbeat ?? 30000;
|
|
264
|
+
if (typeof heartbeatMs !== 'number' || !Number.isFinite(heartbeatMs) || heartbeatMs < 0) {
|
|
265
|
+
throw new Error('presence: heartbeat must be a non-negative number');
|
|
266
|
+
}
|
|
256
267
|
const maxConnections = options.maxConnections ?? 1_000_000;
|
|
257
268
|
const maxTopics = options.maxTopics ?? 1_000_000;
|
|
258
269
|
|
|
@@ -373,11 +384,16 @@ export function createPresence(options = {}) {
|
|
|
373
384
|
if (heartbeatMs > 0) {
|
|
374
385
|
heartbeatTimer = setInterval(() => {
|
|
375
386
|
for (const [topic, users] of topicPresence) {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
387
|
+
// Publish a `{userKey: data}` map (rather than a keys-only
|
|
388
|
+
// array) so a client whose entry aged out of its local
|
|
389
|
+
// `maxAge` sweep between heartbeats can re-add it from the
|
|
390
|
+
// heartbeat alone, without waiting for a presence_diff /
|
|
391
|
+
// presence_state to reconcile. Matches the Redis-backed
|
|
392
|
+
// variant in svelte-adapter-uws-extensions.
|
|
393
|
+
/** @type {Record<string, any>} */
|
|
394
|
+
const dataMap = {};
|
|
395
|
+
for (const [userKey, entry] of users) dataMap[userKey] = entry.data;
|
|
396
|
+
_platform.publish(TOPIC_PREFIX + topic, 'heartbeat', dataMap);
|
|
381
397
|
}
|
|
382
398
|
}, heartbeatMs);
|
|
383
399
|
}
|
package/testing.js
CHANGED
|
@@ -568,12 +568,27 @@ export async function createTestServer(options = {}) {
|
|
|
568
568
|
|
|
569
569
|
async message(ws, message, isBinary) {
|
|
570
570
|
bumpInT(ws, message);
|
|
571
|
-
// Handle subscribe/unsubscribe from client store
|
|
571
|
+
// Handle subscribe/unsubscribe from client store.
|
|
572
|
+
//
|
|
573
|
+
// `msg` is hoisted to outer scope so it can be forwarded to the
|
|
574
|
+
// user handler in the fall-through delegation below. When the
|
|
575
|
+
// prefix matched and JSON.parse produced an object that did NOT
|
|
576
|
+
// match any known control type, the parsed value reaches plugin-
|
|
577
|
+
// layer dispatchers (e.g. svelte-realtime's `onJsonMessage`)
|
|
578
|
+
// directly, so they don't re-run TextDecoder + JSON.parse on
|
|
579
|
+
// every frame. Mirrors handler.js + vite.js.
|
|
580
|
+
/** @type {any} */
|
|
581
|
+
let msg;
|
|
572
582
|
if (!isBinary && message.byteLength < 8192) {
|
|
573
583
|
const bytes = new Uint8Array(message);
|
|
574
584
|
if (bytes[3] === 0x79) {
|
|
575
585
|
try {
|
|
576
|
-
|
|
586
|
+
msg = JSON.parse(Buffer.from(message).toString());
|
|
587
|
+
// Reject null / primitives / arrays so `msg` only reaches
|
|
588
|
+
// the user handler as a {type,...} object envelope. Throw
|
|
589
|
+
// to the catch (which clears `msg`) for a unified fall-
|
|
590
|
+
// through path with parse failures.
|
|
591
|
+
if (msg === null || typeof msg !== 'object' || Array.isArray(msg)) throw 0;
|
|
577
592
|
if (msg.type === 'subscribe' && typeof msg.topic === 'string') {
|
|
578
593
|
const ref = hasRefT(msg.ref) ? msg.ref : null;
|
|
579
594
|
if (!isValidWireTopic(msg.topic, ALLOW_NON_ASCII_TOPICS_T)) {
|
|
@@ -702,7 +717,13 @@ export async function createTestServer(options = {}) {
|
|
|
702
717
|
sendOutboundT(ws, '{"type":"resumed"}');
|
|
703
718
|
return;
|
|
704
719
|
}
|
|
705
|
-
} catch {
|
|
720
|
+
} catch {
|
|
721
|
+
// Not JSON, not an object envelope, or a known control
|
|
722
|
+
// type that threw inside its handler. Clear `msg` so the
|
|
723
|
+
// fall-through delegation sees `msg: undefined` (raw
|
|
724
|
+
// bytes only).
|
|
725
|
+
msg = undefined;
|
|
726
|
+
}
|
|
706
727
|
}
|
|
707
728
|
}
|
|
708
729
|
|
|
@@ -712,7 +733,9 @@ export async function createTestServer(options = {}) {
|
|
|
712
733
|
}
|
|
713
734
|
messageWaiters = [];
|
|
714
735
|
|
|
715
|
-
|
|
736
|
+
// `msg` is the JSON-parsed envelope when the prefix matched + parsed
|
|
737
|
+
// to an object + no control type matched; otherwise undefined.
|
|
738
|
+
handler.message?.(ws, { data: message, isBinary, msg, platform: ws.getUserData()[WS_PLATFORM] });
|
|
716
739
|
},
|
|
717
740
|
|
|
718
741
|
close(ws, code, message) {
|
package/vite.js
CHANGED
|
@@ -1003,9 +1003,24 @@ export default function uws(options = {}) {
|
|
|
1003
1003
|
// {"topic" have byte[3]='o' - skip JSON.parse for non-control messages.
|
|
1004
1004
|
// 8192 bytes matches the production handler ceiling and is large
|
|
1005
1005
|
// enough for a subscribe-batch with many topics.
|
|
1006
|
+
//
|
|
1007
|
+
// `msg` is hoisted to outer scope so it can be forwarded to the
|
|
1008
|
+
// user handler in the fall-through delegation below. When the
|
|
1009
|
+
// prefix matched and JSON.parse produced an object that did NOT
|
|
1010
|
+
// match any known control type, the parsed value reaches plugin-
|
|
1011
|
+
// layer dispatchers (e.g. svelte-realtime's `onJsonMessage`)
|
|
1012
|
+
// directly, so they don't re-run TextDecoder + JSON.parse on
|
|
1013
|
+
// every frame.
|
|
1014
|
+
/** @type {any} */
|
|
1015
|
+
let msg;
|
|
1006
1016
|
if (!isBinary && buf.byteLength < 8192 && buf[3] === 0x79) {
|
|
1007
1017
|
try {
|
|
1008
|
-
|
|
1018
|
+
msg = JSON.parse(buf.toString());
|
|
1019
|
+
// Reject null / primitives / arrays so `msg` only reaches
|
|
1020
|
+
// the user handler as a {type,...} object envelope. Throw
|
|
1021
|
+
// to the catch (which clears `msg`) for a unified fall-
|
|
1022
|
+
// through path with parse failures.
|
|
1023
|
+
if (msg === null || typeof msg !== 'object' || Array.isArray(msg)) throw 0;
|
|
1009
1024
|
if (msg.type === 'subscribe' && typeof msg.topic === 'string') {
|
|
1010
1025
|
const ref = hasRefValue(msg.ref) ? msg.ref : null;
|
|
1011
1026
|
if (!isValidWireTopic(msg.topic, ALLOW_NON_ASCII_TOPICS_V)) {
|
|
@@ -1149,14 +1164,20 @@ export default function uws(options = {}) {
|
|
|
1149
1164
|
return;
|
|
1150
1165
|
}
|
|
1151
1166
|
} catch {
|
|
1152
|
-
// Not JSON
|
|
1167
|
+
// Not JSON, not an object envelope, or a known control
|
|
1168
|
+
// type that threw inside its handler. Clear `msg` so the
|
|
1169
|
+
// fall-through delegation sees `msg: undefined` (raw
|
|
1170
|
+
// bytes only).
|
|
1171
|
+
msg = undefined;
|
|
1153
1172
|
}
|
|
1154
1173
|
}
|
|
1155
1174
|
|
|
1156
|
-
// Delegate to user handler
|
|
1175
|
+
// Delegate to user handler. `msg` is the JSON-parsed envelope
|
|
1176
|
+
// when the prefix matched + parsed to an object + no control
|
|
1177
|
+
// type matched; otherwise undefined.
|
|
1157
1178
|
await handlerReady;
|
|
1158
1179
|
if (userHandlers.message) {
|
|
1159
|
-
userHandlers.message(wrapped, { data: arrayBuffer, isBinary: !!isBinary, platform: wrapped.getUserData()[WS_PLATFORM] });
|
|
1180
|
+
userHandlers.message(wrapped, { data: arrayBuffer, isBinary: !!isBinary, msg, platform: wrapped.getUserData()[WS_PLATFORM] });
|
|
1160
1181
|
}
|
|
1161
1182
|
});
|
|
1162
1183
|
|