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 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
- export function message(ws, { data, isBinary }) {
706
- const msg = JSON.parse(Buffer.from(data).toString());
707
- console.log('Got message:', msg);
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 msg;
3049
+ let parsed;
3042
3050
  try {
3043
- msg = JSON.parse(textDecoder.decode(message));
3051
+ parsed = JSON.parse(textDecoder.decode(message));
3044
3052
  } catch {
3045
- // Not valid JSON - fall through to user handler.
3046
- wsModule.message?.(ws, { data: message, isBinary, platform: ws.getUserData()[WS_PLATFORM] });
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
- wsModule.message?.(ws, { data: message, isBinary, platform: ws.getUserData()[WS_PLATFORM] });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "publishConfig": {
5
5
  "tag": "latest"
6
6
  },
@@ -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' && Array.isArray(event.data)) {
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
- for (const key of event.data) {
159
- if (timestamps.has(key)) {
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
- const msg = JSON.parse(Buffer.from(message).toString());
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
- handler.message?.(ws, { data: message, isBinary, platform: ws.getUserData()[WS_PLATFORM] });
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
- const msg = JSON.parse(buf.toString());
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 - fall through to user handler
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