svelte-adapter-uws 0.5.2 → 0.5.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/README.md +18 -5
- package/files/handler.js +21 -6
- package/index.d.ts +20 -0
- package/package.json +1 -1
- package/plugins/presence/client.js +48 -7
- 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)
|
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
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
const TOPIC_PREFIX = '__presence:';
|
|
17
17
|
|
|
18
|
-
import { on } from '../../client.js';
|
|
18
|
+
import { on, connect, status } from '../../client.js';
|
|
19
19
|
import { writable } from 'svelte/store';
|
|
20
20
|
|
|
21
21
|
/** @type {Map<string, { subscribe: (fn: Function) => (() => void) }>} */
|
|
@@ -83,9 +83,11 @@ export function presence(topic, options) {
|
|
|
83
83
|
const output = writable(/** @type {any[]} */ ([]));
|
|
84
84
|
|
|
85
85
|
let sourceUnsub = /** @type {(() => void) | null} */ (null);
|
|
86
|
+
let statusUnsub = /** @type {(() => void) | null} */ (null);
|
|
86
87
|
/** @type {ReturnType<typeof setInterval> | null} */
|
|
87
88
|
let sweepTimer = null;
|
|
88
89
|
let refCount = 0;
|
|
90
|
+
let cancelled = false;
|
|
89
91
|
|
|
90
92
|
function flush() {
|
|
91
93
|
output.set([...userMap.values()]);
|
|
@@ -105,6 +107,7 @@ export function presence(topic, options) {
|
|
|
105
107
|
}
|
|
106
108
|
|
|
107
109
|
function startListening() {
|
|
110
|
+
cancelled = false;
|
|
108
111
|
// Fresh on() call each time - the underlying writable in client.js
|
|
109
112
|
// is cleaned up on full unsubscribe, so a stale reference would
|
|
110
113
|
// silently stop receiving events.
|
|
@@ -150,29 +153,67 @@ export function presence(topic, options) {
|
|
|
150
153
|
return;
|
|
151
154
|
}
|
|
152
155
|
|
|
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).
|
|
156
|
+
if (event.event === 'heartbeat') {
|
|
157
157
|
const now = Date.now();
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
let changed = false;
|
|
159
|
+
if (event.data && typeof event.data === 'object' && !Array.isArray(event.data)) {
|
|
160
|
+
// New shape: `{userKey: data}` map. Refresh existing AND
|
|
161
|
+
// re-add any entry that aged out between heartbeats. The
|
|
162
|
+
// older "refresh existing only" branch (below) could not
|
|
163
|
+
// recover entries the local sweep had already removed -
|
|
164
|
+
// once an entry aged out, the next heartbeat couldn't
|
|
165
|
+
// bring it back and the user stayed missing until a
|
|
166
|
+
// presence_diff or presence_state arrived.
|
|
167
|
+
for (const [key, data] of Object.entries(event.data)) {
|
|
160
168
|
timestamps.set(key, now);
|
|
169
|
+
const prev = userMap.get(key);
|
|
170
|
+
if (prev !== data) {
|
|
171
|
+
userMap.set(key, data);
|
|
172
|
+
changed = true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} else if (Array.isArray(event.data)) {
|
|
176
|
+
// Back-compat: keys-only heartbeat (older server). Refresh
|
|
177
|
+
// existing entries; cannot recover aged-out ones from this
|
|
178
|
+
// shape. The presence_diff / presence_state reconciliation
|
|
179
|
+
// path still corrects missing entries on the next event.
|
|
180
|
+
for (const key of event.data) {
|
|
181
|
+
if (timestamps.has(key)) {
|
|
182
|
+
timestamps.set(key, now);
|
|
183
|
+
}
|
|
161
184
|
}
|
|
162
185
|
}
|
|
186
|
+
if (changed) flush();
|
|
187
|
+
return;
|
|
163
188
|
}
|
|
164
189
|
});
|
|
165
190
|
|
|
166
191
|
if (maxAge > 0) {
|
|
167
192
|
sweepTimer = setInterval(sweep, Math.max(maxAge / 2, 1000));
|
|
168
193
|
}
|
|
194
|
+
|
|
195
|
+
// Request a presence snapshot every time the socket opens (initial
|
|
196
|
+
// connect AND reconnects). Without this, a reconnecting client
|
|
197
|
+
// missed any presence_diff frames that fired during the disconnect
|
|
198
|
+
// window and its in-memory map stayed at whatever it last knew.
|
|
199
|
+
// Symmetric to the cursor plugin's `cursor-snapshot` send.
|
|
200
|
+
statusUnsub = status.subscribe((s) => {
|
|
201
|
+
if (s === 'open' && !cancelled) {
|
|
202
|
+
connect().send({ type: 'presence-snapshot', topic });
|
|
203
|
+
}
|
|
204
|
+
});
|
|
169
205
|
}
|
|
170
206
|
|
|
171
207
|
function stopListening() {
|
|
208
|
+
cancelled = true;
|
|
172
209
|
if (sourceUnsub) {
|
|
173
210
|
sourceUnsub();
|
|
174
211
|
sourceUnsub = null;
|
|
175
212
|
}
|
|
213
|
+
if (statusUnsub) {
|
|
214
|
+
statusUnsub();
|
|
215
|
+
statusUnsub = null;
|
|
216
|
+
}
|
|
176
217
|
if (sweepTimer) {
|
|
177
218
|
clearInterval(sweepTimer);
|
|
178
219
|
sweepTimer = null;
|
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
|
|