svelte-adapter-uws 0.5.8 → 0.6.0-next.1

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
@@ -2815,6 +2815,16 @@ Positions live on the `update` / `bulk` channel; user metadata lives on the `cat
2815
2815
 
2816
2816
  The cluster-aware [extensions](https://github.com/lanteanio/svelte-adapter-uws-extensions) Redis-backed cursor speaks the same wire format, so the same client bundle works against either backend.
2817
2817
 
2818
+ #### Binary wire mode
2819
+
2820
+ Cursor frames ride a compact **binary wire** by default. The events above are the same; on the wire they are encoded as a binary `0x03` frame instead of a JSON envelope whenever the client supports it. A 221-cursor coalesced `bulk` is **~83% smaller** than the JSON equivalent and decodes ~4-5x faster (no `JSON.parse` on the cursor receive path). This is fully transparent: `cursor()` / `move()` are unchanged, the store still yields `Map<key, { user, data }>`, and the decode happens in the framework before your code sees the event.
2821
+
2822
+ - **Capability-gated, never breaking.** The client advertises `cursor.protocol:2` in its `hello` frame; a client that does not (an older build, or one that opted out) receives the JSON frames unchanged. Old client <-> new server and new client <-> old server both keep working.
2823
+ - **`binary: false` to disable.** `createCursor({ binary: false })` forces JSON for every client (e.g. to keep DevTools' WS inspector readable). The wire format is the server's decision - the library reads no URL parameter and a client cannot force its own connection back to JSON, so it can't be used to inflate egress.
2824
+ - **Positions are `float32`, keys are length-prefixed strings.** Fractional positions (e.g. `clientX - getBoundingClientRect().left`) are carried losslessly enough for cursors (sub-0.01 px at screen scale). Cursor `data` that is not exactly `{ x, y }` numeric - extra fields, non-numeric values - transparently falls back to JSON for that frame, so richer cursor payloads keep working.
2825
+
2826
+ Writing your own high-throughput plugin? The same mechanism is available via `platform.publishWire(topic, event, data, wire)` / `platform.sendWire(...)` on the server (where `wire = { capability, schemaVersion, encode(event, data) }` and `encode` returns a `Uint8Array` payload or `null` to fall back to JSON), plus `registerWireCodec(prefix, { capability, decode })` from `svelte-adapter-uws/client` on the client. JSON-only deployments pay nothing - `publishWire` takes the same single broadcast as `publish` when no connected client wants binary.
2827
+
2818
2828
  #### Server usage
2819
2829
 
2820
2830
  Use the `hooks` helper for zero-config cursor handling. The `message` hook handles `cursor` and `cursor-snapshot` messages automatically, and `close` calls `remove()`. The hooks verify that the sender is subscribed to the `__cursor:{topic}` channel before processing - clients that haven't passed the `subscribe` hook for that topic are silently rejected.
package/client.d.ts CHANGED
@@ -640,3 +640,25 @@ export interface WSConnection {
640
640
  * ```
641
641
  */
642
642
  export function connect(options?: ConnectOptions): WSConnection;
643
+
644
+ /**
645
+ * Register a client-side binary wire codec for a topic-name prefix. This is a
646
+ * plugin-author surface, not an app-author one - a plugin (e.g. the cursor
647
+ * client) calls it at import time. The connection then advertises
648
+ * `codec.capability` in its `hello` frame and routes inbound binary `0x03`
649
+ * frames whose resolved topic starts with `prefix` to `codec.decode`, which
650
+ * must return the same `{ event, data }` the JSON path would have dispatched
651
+ * (or `null` to drop a malformed frame). Idempotent per prefix. A client
652
+ * always advertises what it can decode; whether a topic is actually sent as
653
+ * binary is the server's decision (the plugin's codec, or `binary: false`).
654
+ *
655
+ * @param prefix - topic-name prefix the codec owns (e.g. `'__cursor:'`)
656
+ * @param codec - `{ capability, decode }`
657
+ */
658
+ export function registerWireCodec(
659
+ prefix: string,
660
+ codec: {
661
+ capability: string;
662
+ decode: (payload: Uint8Array) => { event: string; data: unknown } | null;
663
+ }
664
+ ): void;
package/client.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { writable, derived } from 'svelte/store';
2
+ import { parseBinaryFrame } from './files/wire.js';
2
3
 
3
4
  /** @type {ReturnType<typeof createConnection> | null} */
4
5
  let singleton = null;
@@ -6,6 +7,63 @@ let singleton = null;
6
7
  /** @type {'explicit' | 'implicit' | ''} */
7
8
  let singletonCreatedBy = '';
8
9
 
10
+ /**
11
+ * Client-side binary wire codecs, keyed by topic-name prefix. A plugin (e.g.
12
+ * the cursor client) registers its decoder + capability here at import time;
13
+ * the connection then advertises those capabilities in its `hello` frame and
14
+ * routes inbound `0x03` frames whose resolved topic matches a prefix to the
15
+ * matching decoder. The decoder returns the same `{ event, data }` the JSON
16
+ * path would have dispatched, so the reactive surface is identical.
17
+ * @type {Map<string, { capability: string, decode: (payload: Uint8Array) => ({ event: string, data: any } | null) }>}
18
+ */
19
+ const wireCodecs = new Map();
20
+
21
+ /**
22
+ * Register a binary wire codec for a topic-name prefix. Idempotent per prefix.
23
+ * Plugins call this at module load (before connect) so the first `hello`
24
+ * already advertises the capability; if a connection is already open, its
25
+ * `hello` is re-sent so a lazily-imported plugin still negotiates binary.
26
+ *
27
+ * @param {string} prefix - topic-name prefix the codec owns (e.g. '__cursor:')
28
+ * @param {{ capability: string, decode: (payload: Uint8Array) => ({ event: string, data: any } | null) }} codec
29
+ */
30
+ export function registerWireCodec(prefix, codec) {
31
+ wireCodecs.set(prefix, codec);
32
+ if (singleton && typeof singleton._resendHello === 'function') singleton._resendHello();
33
+ }
34
+
35
+ /**
36
+ * Build the `hello` caps array: `'batch'` plus every registered binary wire
37
+ * capability. A client always advertises what it can decode; the wire format
38
+ * is the server's decision (a plugin's codec, or `binary: false` to force
39
+ * JSON). We deliberately do NOT read any URL query parameter to opt out - the
40
+ * app owns its URL namespace, and a client-side force-JSON knob would let a
41
+ * connection inflate its own egress.
42
+ * @returns {string[]}
43
+ */
44
+ function buildHelloCaps() {
45
+ const caps = ['batch'];
46
+ for (const codec of wireCodecs.values()) caps.push(codec.capability);
47
+ return caps;
48
+ }
49
+
50
+ /**
51
+ * Resolve a topic name to its registered wire codec by longest matching prefix.
52
+ * @param {string} topic
53
+ * @returns {{ capability: string, decode: (payload: Uint8Array) => ({ event: string, data: any } | null) } | null}
54
+ */
55
+ function wireCodecForTopic(topic) {
56
+ let best = null;
57
+ let bestLen = -1;
58
+ for (const [prefix, codec] of wireCodecs) {
59
+ if (topic.startsWith(prefix) && prefix.length > bestLen) {
60
+ best = codec;
61
+ bestLen = prefix.length;
62
+ }
63
+ }
64
+ return best;
65
+ }
66
+
9
67
  /**
10
68
  * Ensure the singleton connection exists.
11
69
  * @param {import('./client.js').ConnectOptions} [options]
@@ -707,6 +765,14 @@ function createConnection(options) {
707
765
  /** @type {Map<string, number>} */
708
766
  const topicRefCounts = new Map();
709
767
 
768
+ // Inverse of the server's per-connection topic-id map: numeric wireId ->
769
+ // topic name. Populated from `{type:'wire-id'}` control frames; an inbound
770
+ // `0x03` binary frame carries only the numeric id, resolved here back to the
771
+ // topic name the store ladder is keyed on. Per-connection: cleared on each
772
+ // (re)connect since the server reassigns ids fresh on a new connection.
773
+ /** @type {Map<number, string>} */
774
+ const wireIdMap = new Map();
775
+
710
776
  // Highest seq seen per topic. Sent back to the server on reconnect via
711
777
  // the resume frame so the user's resume hook can replay anything we
712
778
  // missed during the disconnect window. Only topics that the server is
@@ -983,6 +1049,14 @@ function createConnection(options) {
983
1049
  scheduleReconnect();
984
1050
  return;
985
1051
  }
1052
+ // Read inbound binary frames as ArrayBuffer (default is Blob, which is
1053
+ // async to read). Cannot regress the realtime upload layer: that layer
1054
+ // only EMITS binary (0x01/0x02) and receives upload results as JSON on
1055
+ // the '__upload' topic - it never reads an inbound binary frame.
1056
+ ws.binaryType = 'arraybuffer';
1057
+ // Topic-ids are per-connection; the server reassigns them on a fresh
1058
+ // connection, so drop any stale id -> name mappings from a prior socket.
1059
+ wireIdMap.clear();
986
1060
 
987
1061
  ws.onopen = () => {
988
1062
  attempt = 0;
@@ -992,10 +1066,12 @@ function createConnection(options) {
992
1066
  if (debug) console.log('[ws] connected');
993
1067
 
994
1068
  // Advertise client capabilities. Server stores these on the
995
- // connection's userData and uses them to gate opt-in wire
996
- // features (currently: 'batch' for platform.publishBatched
997
- // frames). Old servers ignore the unknown frame type.
998
- ws?.send('{"type":"hello","caps":["batch"]}');
1069
+ // connection's userData and uses them to gate opt-in wire features:
1070
+ // 'batch' for publishBatched frames, plus any registered binary wire
1071
+ // codec capabilities (e.g. 'cursor.protocol:2'). A client always
1072
+ // advertises what it can decode; the wire format is the server's
1073
+ // call. Old servers ignore the unknown frame type.
1074
+ ws?.send(JSON.stringify({ type: 'hello', caps: buildHelloCaps() }));
999
1075
 
1000
1076
  // If we have a previous session id and any tracked seqs, ask the
1001
1077
  // server to fill the gap before we resubscribe. The server's
@@ -1057,6 +1133,36 @@ function createConnection(options) {
1057
1133
  ws.onmessage = (rawEvent) => {
1058
1134
  lastServerMessage = Date.now();
1059
1135
  try {
1136
+ // Inbound binary demux, ahead of the JSON path. A 0x03 frame is
1137
+ // a binary topic PAYLOAD: resolve its numeric topic-id to a name,
1138
+ // decode via the registered codec, and feed the SAME
1139
+ // dispatchEvent the JSON path uses so the reactive surface is
1140
+ // byte-for-byte identical (zero JSON.parse on this hot path).
1141
+ // Any other binary frame (the realtime layer's outbound-only
1142
+ // 0x01/0x02, or a malformed frame) is dropped. Binary frames
1143
+ // never fall through to JSON.parse.
1144
+ if (rawEvent.data instanceof ArrayBuffer) {
1145
+ if (rawEvent.data.byteLength > 1048576) {
1146
+ if (debug) console.warn('[ws] binary frame too large, dropped:', rawEvent.data.byteLength, 'bytes');
1147
+ return;
1148
+ }
1149
+ const parsed = parseBinaryFrame(new Uint8Array(rawEvent.data));
1150
+ if (parsed) {
1151
+ const topic = wireIdMap.get(parsed.topicId);
1152
+ if (topic !== undefined) {
1153
+ const codec = wireCodecForTopic(topic);
1154
+ const decoded = codec ? codec.decode(parsed.payload) : null;
1155
+ if (decoded) {
1156
+ const out = { topic, event: decoded.event, data: decoded.data };
1157
+ if (parsed.seq > 0) out.seq = parsed.seq;
1158
+ dispatchEvent(out);
1159
+ }
1160
+ } else if (debug) {
1161
+ console.warn('[ws] 0x03 frame for unknown topicId', parsed.topicId);
1162
+ }
1163
+ }
1164
+ return;
1165
+ }
1060
1166
  // Reject oversized messages to prevent main-thread blocking
1061
1167
  if (typeof rawEvent.data === 'string' && rawEvent.data.length > 1048576) {
1062
1168
  if (debug) console.warn('[ws] message too large, dropped:', rawEvent.data.length, 'bytes');
@@ -1093,6 +1199,15 @@ function createConnection(options) {
1093
1199
  if (debug) console.log('[ws] subscribed topic=%s ref=%s', msg.topic, msg.ref);
1094
1200
  return;
1095
1201
  }
1202
+ if (msg.type === 'wire-id' && typeof msg.topic === 'string' && typeof msg.id === 'number') {
1203
+ // Server announced a binary topic-id assignment. Record the
1204
+ // inverse mapping so a subsequent 0x03 frame's numeric id
1205
+ // resolves to this topic name. Arrives before the first
1206
+ // binary frame for the topic (same socket, ordered).
1207
+ wireIdMap.set(msg.id, msg.topic);
1208
+ if (debug) console.log('[ws] wire-id topic=%s id=%d', msg.topic, msg.id);
1209
+ return;
1210
+ }
1096
1211
  if (msg.type === 'subscribe-denied' && typeof msg.topic === 'string' && typeof msg.reason === 'string') {
1097
1212
  console.warn('[ws] subscribe denied topic=%s reason=%s\n See: https://svti.me/subscribe-denied', msg.topic, msg.reason);
1098
1213
  denialsStore.set({ topic: msg.topic, reason: msg.reason, ref: msg.ref });
@@ -1562,6 +1677,16 @@ function createConnection(options) {
1562
1677
  return () => { if (requestHandler === handler) requestHandler = null; };
1563
1678
  }
1564
1679
 
1680
+ // Re-advertise capabilities on an already-open socket. Called by
1681
+ // registerWireCodec when a binary plugin is imported after connect so its
1682
+ // capability still reaches the server (the common case - import before
1683
+ // connect - is covered by buildHelloCaps() reading the registry at open).
1684
+ function resendHello() {
1685
+ if (ws && ws.readyState === WebSocket.OPEN) {
1686
+ ws.send(JSON.stringify({ type: 'hello', caps: buildHelloCaps() }));
1687
+ }
1688
+ }
1689
+
1565
1690
  return {
1566
1691
  events: { subscribe: eventsStore.subscribe },
1567
1692
  status: { subscribe: statusStore.subscribe },
@@ -1584,6 +1709,7 @@ function createConnection(options) {
1584
1709
  // mark and back off until it drops below a low-water mark.
1585
1710
  get bufferedAmount() { return ws?.bufferedAmount ?? 0; },
1586
1711
  onRequest,
1712
+ _resendHello: resendHello,
1587
1713
  close
1588
1714
  };
1589
1715
  }
package/files/handler.js CHANGED
@@ -22,7 +22,8 @@ import { env } from 'ENV';
22
22
  import { server } from './_init.js';
23
23
  import * as wsModule from 'WS_HANDLER';
24
24
  import { parseCookies, createCookies } from './cookies.js';
25
- import { mimeLookup, parse_as_bytes, parse_origin, writeChunkWithBackpressure, drainCoalesced, computePressureReason, computeTopPublishers, nextTopicSeq, completeEnvelope, wrapBatchEnvelope, collapseByCoalesceKey, esc, isValidWireTopic, createScopedTopic, isOriginAllowed, isAuthOriginAccepted, describeUnsafeSameOriginConfig, createUpgradeAdmission, resolveRequestId, assert, readAssertionCounts, WS_SUBSCRIPTIONS, WS_COALESCED, WS_SESSION_ID, WS_PENDING_REQUESTS, WS_STATS, WS_PLATFORM, WS_REQUEST_ID_KEY, WS_CAPS, MAX_SUBSCRIPTIONS_PER_CONNECTION, MAX_PENDING_REQUESTS_PER_CONNECTION, MAX_COALESCED_KEYS_PER_CONNECTION, TOPIC_SEQS_WARN_THRESHOLD, PUBLISH_WARN_DEDUP_MAX } from './utils.js';
25
+ import { mimeLookup, parse_as_bytes, parse_origin, writeChunkWithBackpressure, drainCoalesced, computePressureReason, computeTopPublishers, nextTopicSeq, completeEnvelope, wrapBatchEnvelope, collapseByCoalesceKey, esc, isValidWireTopic, createScopedTopic, isOriginAllowed, isAuthOriginAccepted, describeUnsafeSameOriginConfig, createUpgradeAdmission, resolveRequestId, assert, readAssertionCounts, WS_SUBSCRIPTIONS, WS_COALESCED, WS_SESSION_ID, WS_PENDING_REQUESTS, WS_STATS, WS_PLATFORM, WS_REQUEST_ID_KEY, WS_CAPS, WS_TOPIC_IDS, MAX_SUBSCRIPTIONS_PER_CONNECTION, MAX_PENDING_REQUESTS_PER_CONNECTION, MAX_COALESCED_KEYS_PER_CONNECTION, TOPIC_SEQS_WARN_THRESHOLD, PUBLISH_WARN_DEDUP_MAX } from './utils.js';
26
+ import { buildBinaryFrame, allocWireId, wireIdAnnounce, createCapCounts } from './wire.js';
26
27
 
27
28
  /* global ENV_PREFIX */
28
29
  /* global PRECOMPRESS */
@@ -912,6 +913,40 @@ function flushCoalescedFor(ws) {
912
913
  if (aborted) pending.clear();
913
914
  }
914
915
 
916
+ // - Binary wire (0x03) capability accounting + topic-id assignment ----------
917
+ // A connection opts into a binary plugin codec by advertising the codec's
918
+ // capability token in its `hello` frame (stored in WS_CAPS). capCounts tracks
919
+ // how many live connections advertised each token, so platform.publishWire
920
+ // takes a zero-cost JSON fast path when no connected client wants binary for a
921
+ // codec - a JSON-only deployment never enters the per-subscriber walk. The
922
+ // id-allocation, announce-frame, and cap-counting primitives are shared with
923
+ // the test / dev platforms via ./wire.js so the id space is identical.
924
+ const capCounts = createCapCounts();
925
+
926
+ /**
927
+ * Resolve (allocating on first use) the per-connection binary topic-id for a
928
+ * topic. On a fresh assignment, announce the `name -> id` mapping to the
929
+ * client in a `{type:'wire-id'}` control frame so an inbound `0x03` frame's
930
+ * numeric id resolves back to the topic name. The announce rides the same
931
+ * socket immediately before the first binary frame for the topic, so ordering
932
+ * guarantees the client records the mapping first - no ack/frame race.
933
+ * Per-connection and reset on reconnect (a reconnect is a new connection with
934
+ * fresh userData).
935
+ * @param {import('uWebSockets.js').WebSocket<any>} ws
936
+ * @param {any} ud - ws.getUserData()
937
+ * @param {string} topic
938
+ * @returns {number}
939
+ */
940
+ function ensureWireId(ws, ud, topic) {
941
+ const { id, isNew } = allocWireId(ud, WS_TOPIC_IDS, topic);
942
+ if (isNew) {
943
+ const announce = wireIdAnnounce(topic, id);
944
+ try { ws.send(announce, false, false); } catch { closedWsAborts++; return id; }
945
+ bumpOut(ws, announce);
946
+ }
947
+ return id;
948
+ }
949
+
915
950
  /** @type {import('./index.js').Platform} */
916
951
  const platform = {
917
952
  /**
@@ -979,6 +1014,126 @@ const platform = {
979
1014
  return result;
980
1015
  },
981
1016
 
1017
+ /**
1018
+ * Publish via a plugin-declared binary wire codec. Binary-capable
1019
+ * subscribers (those that advertised `wire.capability`) receive a `0x03`
1020
+ * frame; everyone else receives the identical JSON envelope `publish()`
1021
+ * would have sent. When no connected client advertises the capability - or
1022
+ * the codec declines this frame (`encode` returns null) - this takes the
1023
+ * exact single `app.publish` JSON fan-out with no per-subscriber walk, so a
1024
+ * JSON-only deployment pays nothing for the binary machinery.
1025
+ *
1026
+ * The framework owns the `0x03 | schemaVersion | topicId | seq | payload`
1027
+ * envelope; the plugin's `encode` produces only the payload. seq is stamped
1028
+ * once and carried in both the JSON and binary forms so resume keeps working.
1029
+ *
1030
+ * @param {string} topic
1031
+ * @param {string} event
1032
+ * @param {any} data
1033
+ * @param {{ capability: string, schemaVersion: number, encode: (event: string, data: any) => (Uint8Array | null) }} wire
1034
+ * @param {{ seq?: boolean, relay?: boolean }} [options]
1035
+ * @returns {boolean}
1036
+ */
1037
+ publishWire(topic, event, data, wire, options) {
1038
+ publishCountWindow++;
1039
+ const seq = (options && options.seq === false)
1040
+ ? null
1041
+ : nextTopicSeq(topicSeqs, topic);
1042
+ const envelope = completeEnvelope(envelopePrefix(topic, event), data, seq);
1043
+ assert(envelope.length > 0, 'envelope.empty', { topic, event });
1044
+ let s = topicPublishStats.get(topic);
1045
+ if (!s) {
1046
+ s = { m: 0, b: 0 };
1047
+ topicPublishStats.set(topic, s);
1048
+ maybeWarnTopicRegistry();
1049
+ } else {
1050
+ assert(typeof s.m === 'number' && typeof s.b === 'number', 'topic.stats-shape', { topic });
1051
+ }
1052
+ s.m++;
1053
+ s.b += envelope.length;
1054
+
1055
+ // Encode once - but only when at least one live connection wants binary
1056
+ // for this codec. A null payload (no capable client, or the codec
1057
+ // declined this frame) falls through to the single C++ app.publish
1058
+ // fan-out, byte- and instruction-identical to platform.publish.
1059
+ const payload = capCounts.has(wire.capability) ? wire.encode(event, data) : null;
1060
+ const relayed = !!(parentPort && (!options || options.relay !== false));
1061
+ if (payload == null) {
1062
+ const result = app.publish(topic, envelope, false, false);
1063
+ if (relayed) batchRelay(topic, envelope);
1064
+ return result || relayed;
1065
+ }
1066
+
1067
+ // Mixed-capability fan-out: app.publish cannot vary payload per
1068
+ // recipient, so walk the topic's subscribers and send each the form it
1069
+ // negotiated. The codec payload is shared across recipients; only the
1070
+ // tiny per-connection frame header (topic-id + seq) differs, memoized
1071
+ // per distinct id so the common all-same-id case builds one frame and
1072
+ // reuses it for every binary send.
1073
+ const seqOnWire = seq == null ? 0 : seq;
1074
+ /** @type {Map<number, Uint8Array>} */
1075
+ const frameById = new Map();
1076
+ for (const ws of wsConnections) {
1077
+ let ud;
1078
+ try { ud = ws.getUserData(); } catch { continue; }
1079
+ const subs = ud[WS_SUBSCRIPTIONS];
1080
+ if (!subs || !subs.has(topic)) continue;
1081
+ const caps = ud[WS_CAPS];
1082
+ if (caps && caps.has(wire.capability)) {
1083
+ const id = ensureWireId(ws, ud, topic);
1084
+ let frame = frameById.get(id);
1085
+ if (!frame) {
1086
+ frame = buildBinaryFrame(wire.schemaVersion, id, seqOnWire, payload);
1087
+ frameById.set(id, frame);
1088
+ }
1089
+ try { ws.send(frame, true, false); } catch { closedWsAborts++; }
1090
+ } else {
1091
+ try { ws.send(envelope, false, false); } catch { closedWsAborts++; }
1092
+ }
1093
+ }
1094
+ // Cross-worker subscribers receive the JSON envelope (binary is
1095
+ // same-worker only); their worker re-publishes it to them as JSON.
1096
+ if (relayed) batchRelay(topic, envelope);
1097
+ if (wsDebug) {
1098
+ console.log('[ws] publishWire topic=%s event=%s payloadBytes=%d', topic, event, payload.length);
1099
+ }
1100
+ return true;
1101
+ },
1102
+
1103
+ /**
1104
+ * Single-target send via a plugin-declared binary wire codec. The target
1105
+ * receives a `0x03` frame when it advertised `wire.capability` and the
1106
+ * codec can encode this frame; otherwise it receives the JSON envelope
1107
+ * `send()` would have sent. No seq is stamped (matches `send()`); the
1108
+ * binary frame carries seq 0 ("no seq"). Used for snapshot/catalog frames.
1109
+ *
1110
+ * @param {import('uWebSockets.js').WebSocket<any>} ws
1111
+ * @param {string} topic
1112
+ * @param {string} event
1113
+ * @param {any} data
1114
+ * @param {{ capability: string, schemaVersion: number, encode: (event: string, data: any) => (Uint8Array | null) }} wire
1115
+ * @returns {number} uWS send status (0/1/2), or 2 on a freed handle
1116
+ */
1117
+ sendWire(ws, topic, event, data, wire) {
1118
+ let ud;
1119
+ try { ud = ws.getUserData(); } catch { closedWsAborts++; return 2; }
1120
+ const caps = ud[WS_CAPS];
1121
+ const payload = (caps && caps.has(wire.capability)) ? wire.encode(event, data) : null;
1122
+ if (payload == null) {
1123
+ const json = envelopePrefix(topic, event) + JSON.stringify(data ?? null) + '}';
1124
+ let result;
1125
+ try { result = ws.send(json, false, false); } catch { closedWsAborts++; return 2; }
1126
+ bumpOut(ws, json);
1127
+ return result;
1128
+ }
1129
+ const id = ensureWireId(ws, ud, topic);
1130
+ const frame = buildBinaryFrame(wire.schemaVersion, id, 0, payload);
1131
+ let result;
1132
+ try { result = ws.send(frame, true, false); } catch { closedWsAborts++; return 2; }
1133
+ bumpOut(ws, frame);
1134
+ return result;
1135
+ },
1136
+
982
1137
  /**
983
1138
  * Send a message to a single connection with coalesce-by-key semantics.
984
1139
  *
@@ -3321,7 +3476,12 @@ if (WS_ENABLED) {
3321
3476
  for (let i = 0; i < msg.caps.length; i++) {
3322
3477
  if (typeof msg.caps[i] === 'string') caps.add(msg.caps[i]);
3323
3478
  }
3324
- ws.getUserData()[WS_CAPS] = caps;
3479
+ const ud = ws.getUserData();
3480
+ // Maintain the live per-capability connection counts so the
3481
+ // binary publish fast path knows whether any client wants
3482
+ // binary. A re-sent hello replaces the prior set; diff it.
3483
+ capCounts.adjust(ud[WS_CAPS], caps);
3484
+ ud[WS_CAPS] = caps;
3325
3485
  if (wsDebug) console.log('[ws] hello caps=%o', [...caps]);
3326
3486
  return;
3327
3487
  }
@@ -3412,6 +3572,9 @@ if (WS_ENABLED) {
3412
3572
  } finally {
3413
3573
  totalSubscriptions -= subscriptions.size;
3414
3574
  assert(totalSubscriptions >= 0, 'subs.total-negative', { totalSubscriptions });
3575
+ // Release this connection's advertised capabilities from the
3576
+ // live counts so the binary publish fast path stays accurate.
3577
+ capCounts.adjust(userData[WS_CAPS], null);
3415
3578
  wsConnections.delete(ws);
3416
3579
  if (wsDebug) console.log('[ws] close code=%d connections=%d', code, wsConnections.size);
3417
3580
  }
package/files/utils.js CHANGED
@@ -404,6 +404,18 @@ export const WS_PLATFORM = Symbol.for('adapter-uws.ws.platform');
404
404
  */
405
405
  export const WS_CAPS = Symbol.for('adapter-uws.ws.caps');
406
406
 
407
+ /**
408
+ * Per-connection binary wire-id allocation for `0x03` topic frames:
409
+ * `{ byName: Map<topicName, number>, next: number }`. Allocated lazily on the
410
+ * first binary publish to a connection (never for JSON-only connections, so
411
+ * the common case pays nothing). The id replaces the topic string on the wire;
412
+ * the server announces each `name -> id` assignment to the client in a
413
+ * `{type:'wire-id'}` control frame the first time it emits a binary frame for
414
+ * that topic. Per-connection and reset on reconnect - no cross-reconnect id
415
+ * stability and no server-side schema registry.
416
+ */
417
+ export const WS_TOPIC_IDS = Symbol.for('adapter-uws.ws.topic-ids');
418
+
407
419
  // - Bounded-by-default capacity caps ---------------------------------------
408
420
  // Single source of truth for the per-connection and module-level Map / Set
409
421
  // caps that handler.js, vite.js, and testing.js all enforce. The numbers
package/files/wire.js ADDED
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Binary wire primitives for the `0x03` topic-PAYLOAD frame.
3
+ *
4
+ * The leading-byte demux on a WebSocket connection is:
5
+ * - `0x01` upload chunk (svelte-realtime layer, client -> server, never inbound here)
6
+ * - `0x02` upload cancel (svelte-realtime layer, client -> server)
7
+ * - `0x03` binary topic frame (this module, server -> client)
8
+ *
9
+ * The `0x03` frame envelope, owned by the framework (not the plugin codec):
10
+ *
11
+ * [0x03][schemaVersion:u8][topicId:varint][seq:varint][codec payload...]
12
+ *
13
+ * `topicId` is the per-connection short id assigned by the server and
14
+ * advertised to the client out-of-band (see the `wire-id` control frame).
15
+ * `seq` is the per-topic monotonic sequence (0 means "no seq", matching the
16
+ * seq-less single-target send path). The codec payload is opaque to the
17
+ * framework - a plugin's `wire.encode` produced it and that plugin's
18
+ * `wire.decode` consumes it.
19
+ *
20
+ * Integers use unsigned LEB128 varints. Division/multiplication (not bit
21
+ * shifts) carry values past 2^31 so a long-lived per-topic `seq` never wraps
22
+ * or corrupts. Floats use big-endian IEEE-754 single precision, matching the
23
+ * big-endian convention of the upload frame builders.
24
+ *
25
+ * @module svelte-adapter-uws/files/wire
26
+ */
27
+
28
+ /** Leading byte of a binary topic-PAYLOAD frame. */
29
+ export const WIRE_BINARY_TAG = 0x03;
30
+
31
+ const ENC = new TextEncoder();
32
+ const DEC = new TextDecoder();
33
+
34
+ /**
35
+ * Growable byte buffer with varint / float32 / length-prefixed-string writers.
36
+ * Backed by an ArrayBuffer that doubles on demand; `take()` returns an
37
+ * exact-length copy safe to retain, share, or hand to `ws.send`.
38
+ */
39
+ export class ByteWriter {
40
+ constructor(initial = 64) {
41
+ this._ab = new ArrayBuffer(initial);
42
+ this._buf = new Uint8Array(this._ab);
43
+ this._view = new DataView(this._ab);
44
+ this.len = 0;
45
+ }
46
+
47
+ /** @param {number} need - additional bytes required */
48
+ _ensure(need) {
49
+ const want = this.len + need;
50
+ if (want <= this._buf.length) return;
51
+ let cap = this._buf.length * 2;
52
+ while (cap < want) cap *= 2;
53
+ const ab = new ArrayBuffer(cap);
54
+ const buf = new Uint8Array(ab);
55
+ buf.set(this._buf.subarray(0, this.len));
56
+ this._ab = ab;
57
+ this._buf = buf;
58
+ this._view = new DataView(ab);
59
+ }
60
+
61
+ /** Write one byte. @param {number} n */
62
+ u8(n) {
63
+ this._ensure(1);
64
+ this._buf[this.len++] = n & 0xff;
65
+ }
66
+
67
+ /** Write an unsigned LEB128 varint. @param {number} value - non-negative, < 2^53 */
68
+ varint(value) {
69
+ // Math (not >>>) so values above 2^31 stay correct - `seq` can exceed
70
+ // 32 bits on a long-lived high-throughput topic.
71
+ while (value > 0x7f) {
72
+ this.u8((value & 0x7f) | 0x80);
73
+ value = Math.floor(value / 128);
74
+ }
75
+ this.u8(value & 0x7f);
76
+ }
77
+
78
+ /** Write a big-endian float32. @param {number} n */
79
+ f32(n) {
80
+ this._ensure(4);
81
+ this._view.setFloat32(this.len, n, false);
82
+ this.len += 4;
83
+ }
84
+
85
+ /** Write a length-prefixed (varint byte length) UTF-8 string. @param {string} s */
86
+ str(s) {
87
+ const bytes = ENC.encode(s);
88
+ this.varint(bytes.length);
89
+ this._ensure(bytes.length);
90
+ this._buf.set(bytes, this.len);
91
+ this.len += bytes.length;
92
+ }
93
+
94
+ /** Append raw bytes. @param {Uint8Array} bytes */
95
+ bytes(bytes) {
96
+ this._ensure(bytes.length);
97
+ this._buf.set(bytes, this.len);
98
+ this.len += bytes.length;
99
+ }
100
+
101
+ /** @returns {Uint8Array} exact-length copy of the written bytes */
102
+ take() {
103
+ return this._buf.slice(0, this.len);
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Sequential reader over a byte payload (typically a zero-copy subarray of an
109
+ * inbound frame). Symmetric to {@link ByteWriter}. A read past the end throws
110
+ * a RangeError, which callers turn into a dropped frame.
111
+ */
112
+ export class ByteReader {
113
+ /** @param {Uint8Array} buf */
114
+ constructor(buf) {
115
+ this._buf = buf;
116
+ this._view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
117
+ this.pos = 0;
118
+ }
119
+
120
+ get done() {
121
+ return this.pos >= this._buf.length;
122
+ }
123
+
124
+ /** @returns {number} */
125
+ u8() {
126
+ if (this.pos >= this._buf.length) throw new RangeError('wire: read past end');
127
+ return this._buf[this.pos++];
128
+ }
129
+
130
+ /** @returns {number} */
131
+ varint() {
132
+ // Fast path: single-byte varint (the common case for lengths/counts).
133
+ const first = this._buf[this.pos];
134
+ if (first === undefined) throw new RangeError('wire: read past end');
135
+ if (first < 0x80) { this.pos++; return first; }
136
+ let result = 0;
137
+ let mul = 1;
138
+ let b;
139
+ do {
140
+ b = this._buf[this.pos++];
141
+ if (b === undefined) throw new RangeError('wire: read past end');
142
+ result += (b & 0x7f) * mul;
143
+ mul *= 128;
144
+ } while (b & 0x80);
145
+ return result;
146
+ }
147
+
148
+ /** @returns {number} big-endian float32 */
149
+ f32() {
150
+ if (this.pos + 4 > this._buf.length) throw new RangeError('wire: read past end');
151
+ const v = this._view.getFloat32(this.pos, false);
152
+ this.pos += 4;
153
+ return v;
154
+ }
155
+
156
+ /** @returns {string} length-prefixed UTF-8 string */
157
+ str() {
158
+ const len = this.varint();
159
+ if (this.pos + len > this._buf.length) throw new RangeError('wire: read past end');
160
+ const s = DEC.decode(this._buf.subarray(this.pos, this.pos + len));
161
+ this.pos += len;
162
+ return s;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Build a complete `0x03` topic-PAYLOAD frame from a codec payload.
168
+ *
169
+ * @param {number} schemaVersion - 1-byte plugin codec schema version
170
+ * @param {number} topicId - per-connection short topic id
171
+ * @param {number} seq - per-topic monotonic seq, or 0 for "no seq"
172
+ * @param {Uint8Array} payload - bytes returned by the plugin's `wire.encode`
173
+ * @returns {Uint8Array}
174
+ */
175
+ export function buildBinaryFrame(schemaVersion, topicId, seq, payload) {
176
+ const w = new ByteWriter(8 + payload.length);
177
+ w.u8(WIRE_BINARY_TAG);
178
+ w.u8(schemaVersion & 0xff);
179
+ w.varint(topicId);
180
+ w.varint(seq);
181
+ w.bytes(payload);
182
+ return w.take();
183
+ }
184
+
185
+ /**
186
+ * Parse the framework header of a `0x03` frame and return the header fields
187
+ * plus a zero-copy view of the codec payload.
188
+ *
189
+ * @param {Uint8Array} bytes - the full inbound binary frame
190
+ * @returns {{ schemaVersion: number, topicId: number, seq: number, payload: Uint8Array } | null}
191
+ * null when the frame is not a `0x03` frame or is truncated.
192
+ */
193
+ export function parseBinaryFrame(bytes) {
194
+ if (bytes.length < 2 || bytes[0] !== WIRE_BINARY_TAG) return null;
195
+ try {
196
+ const r = new ByteReader(bytes);
197
+ r.u8(); // tag, already checked
198
+ const schemaVersion = r.u8();
199
+ const topicId = r.varint();
200
+ const seq = r.varint();
201
+ return { schemaVersion, topicId, seq, payload: bytes.subarray(r.pos) };
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Allocate (or return the existing) per-connection binary topic-id for a
209
+ * topic, managing the `WS_TOPIC_IDS` slot `{ byName, next }` on the userData
210
+ * object. Shared across the prod / test / dev platform implementations so the
211
+ * id space is identical in all three.
212
+ *
213
+ * @param {any} ud - ws.getUserData()
214
+ * @param {symbol} slotKey - the WS_TOPIC_IDS symbol
215
+ * @param {string} topic
216
+ * @returns {{ id: number, isNew: boolean }} isNew is true on first allocation
217
+ */
218
+ export function allocWireId(ud, slotKey, topic) {
219
+ let slot = ud[slotKey];
220
+ if (!slot) {
221
+ slot = { byName: new Map(), next: 1 };
222
+ ud[slotKey] = slot;
223
+ }
224
+ const existing = slot.byName.get(topic);
225
+ if (existing !== undefined) return { id: existing, isNew: false };
226
+ const id = slot.next++;
227
+ slot.byName.set(topic, id);
228
+ return { id, isNew: true };
229
+ }
230
+
231
+ /**
232
+ * Build the `{type:'wire-id'}` control frame announcing which numeric topic-id
233
+ * maps to which topic name, so an inbound `0x03` frame resolves to a topic.
234
+ * @param {string} topic
235
+ * @param {number} id
236
+ * @returns {string}
237
+ */
238
+ export function wireIdAnnounce(topic, id) {
239
+ return '{"type":"wire-id","topic":' + JSON.stringify(topic) + ',"id":' + id + '}';
240
+ }
241
+
242
+ /**
243
+ * Per-entry-point live capability accounting: how many connections have
244
+ * advertised each capability token. Lets the binary publish path skip the
245
+ * per-subscriber walk entirely when no connected client wants binary for a
246
+ * codec (a JSON-only deployment pays nothing).
247
+ *
248
+ * @returns {{ has(cap: string): boolean, adjust(prev: Set<string>|null|undefined, next: Set<string>|null|undefined): void }}
249
+ */
250
+ export function createCapCounts() {
251
+ /** @type {Map<string, number>} */
252
+ const counts = new Map();
253
+ return {
254
+ has(cap) {
255
+ return (counts.get(cap) || 0) > 0;
256
+ },
257
+ adjust(prev, next) {
258
+ if (prev) {
259
+ for (const c of prev) {
260
+ const n = (counts.get(c) || 0) - 1;
261
+ if (n > 0) counts.set(c, n);
262
+ else counts.delete(c);
263
+ }
264
+ }
265
+ if (next) {
266
+ for (const c of next) counts.set(c, (counts.get(c) || 0) + 1);
267
+ }
268
+ }
269
+ };
270
+ }
package/index.d.ts CHANGED
@@ -1062,6 +1062,58 @@ export interface Platform {
1062
1062
  */
1063
1063
  publish(topic: string, event: string, data?: unknown, options?: { relay?: boolean; seq?: boolean }): boolean;
1064
1064
 
1065
+ /**
1066
+ * Publish via a plugin-declared binary wire codec. Subscribers that
1067
+ * advertised `wire.capability` in their `hello` frame receive a compact
1068
+ * binary `0x03` frame; everyone else receives the identical JSON envelope
1069
+ * `publish()` would have sent. App authors never call this - it is the
1070
+ * plugin-author surface for a high-throughput topic family (the cursor
1071
+ * plugin is the first beneficiary, gated by `cursor.protocol:2`).
1072
+ *
1073
+ * Zero-cost when no connected client wants binary: this takes the same
1074
+ * single `app.publish` fan-out as `publish()`. The codec's `encode` may
1075
+ * return `null` for a frame it cannot represent, which transparently falls
1076
+ * back to JSON for that one frame.
1077
+ *
1078
+ * @param topic - Topic string
1079
+ * @param event - Event name (the codec maps it to an opcode)
1080
+ * @param data - Payload (passed to `wire.encode`, or JSON-serialized on fallback)
1081
+ * @param wire - The plugin's wire codec: a negotiated `capability` token, a
1082
+ * 1-byte `schemaVersion`, and an `encode(event, data)` returning the
1083
+ * payload bytes or `null` to fall back to JSON for this frame.
1084
+ * @param options - Same `relay` / `seq` semantics as `publish()`.
1085
+ */
1086
+ publishWire(
1087
+ topic: string,
1088
+ event: string,
1089
+ data: unknown,
1090
+ wire: {
1091
+ capability: string;
1092
+ schemaVersion: number;
1093
+ encode: (event: string, data: unknown) => Uint8Array | null;
1094
+ },
1095
+ options?: { relay?: boolean; seq?: boolean }
1096
+ ): boolean;
1097
+
1098
+ /**
1099
+ * Single-target counterpart to `publishWire()`. The connection receives a
1100
+ * binary `0x03` frame when it advertised `wire.capability` and the codec
1101
+ * can encode the frame; otherwise the JSON envelope `send()` would have
1102
+ * sent. No per-topic seq is stamped (matches `send()`). Used for
1103
+ * snapshot/catalog frames to a single late-joining subscriber.
1104
+ */
1105
+ sendWire(
1106
+ ws: WebSocket<any>,
1107
+ topic: string,
1108
+ event: string,
1109
+ data: unknown,
1110
+ wire: {
1111
+ capability: string;
1112
+ schemaVersion: number;
1113
+ encode: (event: string, data: unknown) => Uint8Array | null;
1114
+ }
1115
+ ): number;
1116
+
1065
1117
  /**
1066
1118
  * Publish multiple messages, returning per-message delivery results.
1067
1119
  *
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws",
3
- "version": "0.5.8",
3
+ "version": "0.6.0-next.1",
4
4
  "publishConfig": {
5
- "tag": "latest"
5
+ "tag": "next"
6
6
  },
7
7
  "description": "SvelteKit adapter for uWebSockets.js - high-performance C++ HTTP server with built-in WebSocket support",
8
8
  "author": "Kevin Radziszewski",
@@ -28,8 +28,17 @@
28
28
 
29
29
  const TOPIC_PREFIX = '__cursor:';
30
30
 
31
- import { on, connect, status } from '../../client.js';
31
+ import { on, connect, status, registerWireCodec } from '../../client.js';
32
32
  import { writable } from 'svelte/store';
33
+ import { decodeCursor, CURSOR_CAPABILITY } from './codec.js';
34
+
35
+ // Opt this connection into binary cursor frames: advertise the capability in
36
+ // the `hello` frame and route inbound `0x03` frames on `__cursor:` topics
37
+ // through the cursor decoder, which yields the identical { event, data } the
38
+ // JSON path produced - so the store merge logic below is untouched. Registered
39
+ // at module load so the first `hello` already carries the capability. Fully
40
+ // transparent: nothing in the cursor() store knows whether a frame was binary.
41
+ registerWireCodec(TOPIC_PREFIX, { capability: CURSOR_CAPABILITY, decode: decodeCursor });
33
42
 
34
43
  /** @type {Map<string, ReturnType<typeof cursor>>} */
35
44
  const cursorStores = new Map();
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Binary wire codec for the cursor plugin.
3
+ *
4
+ * Produces / consumes the `codec payload` that rides inside the framework's
5
+ * `0x03` topic frame (see files/wire.js for the frame envelope). The payload
6
+ * is `[op:u8][op-specific...]`, one op per cursor wire event:
7
+ *
8
+ * UPDATE [op][key][x:f32][y:f32]
9
+ * BULK [op][count:varint]({key}{x:f32}{y:f32})*
10
+ * REMOVE [op][key]
11
+ * JOIN [op][key][userJson]
12
+ * CATALOG [op][count:varint]({key}{userJson})*
13
+ *
14
+ * where `key` and `userJson` are length-prefixed UTF-8 strings.
15
+ *
16
+ * Design notes (why the encoding is shaped this way, recorded so the choices
17
+ * are legible):
18
+ *
19
+ * - The key is a length-prefixed UTF-8 string, NOT 16 raw UUID bytes. Cursor
20
+ * keys are server-assigned connection ids - `"42"` in-process,
21
+ * `"<instanceId>:42"` in the Redis-backed variant - never UUIDs, and the
22
+ * same client decoder serves both backends. A length-prefixed string is
23
+ * correct for any key shape.
24
+ * - Positions are big-endian float32, NOT i16. Real cursor positions are
25
+ * fractional doubles (e.g. `clientX - getBoundingClientRect().left`), so an
26
+ * integer-only schema would fall back to JSON for essentially every frame.
27
+ * float32 precision (sub-0.01 px at screen scale) is imperceptible for an
28
+ * ephemeral cursor.
29
+ *
30
+ * The encoder returns `null` for any frame it cannot represent (data that is
31
+ * not exactly `{x, y}`-numeric, a non-string key, or a `user` that will not
32
+ * JSON-serialize). A `null` return tells the framework to send JSON for that
33
+ * one frame, so apps that put richer data on the cursor channel keep working.
34
+ *
35
+ * @module svelte-adapter-uws/plugins/cursor/codec
36
+ */
37
+
38
+ import { ByteWriter, ByteReader } from '../../files/wire.js';
39
+
40
+ /** Negotiated capability token. Bumped to `:3` only for an incompatible schema. */
41
+ export const CURSOR_CAPABILITY = 'cursor.protocol:2';
42
+
43
+ /** 1-byte in-frame schema version (the fine gate within the capability). */
44
+ export const CURSOR_SCHEMA_VERSION = 1;
45
+
46
+ const OP_UPDATE = 1;
47
+ const OP_BULK = 2;
48
+ const OP_REMOVE = 3;
49
+ const OP_JOIN = 4;
50
+ const OP_CATALOG = 5;
51
+
52
+ /**
53
+ * True when `d` is exactly a `{ x, y }` pair of finite numbers and nothing
54
+ * else - the only shape the binary position encoding is lossless for. Extra
55
+ * fields or non-numeric coords fall back to JSON so no data is silently lost.
56
+ * @param {any} d
57
+ */
58
+ function isXY(d) {
59
+ if (d === null || typeof d !== 'object') return false;
60
+ if (typeof d.x !== 'number' || !Number.isFinite(d.x)) return false;
61
+ if (typeof d.y !== 'number' || !Number.isFinite(d.y)) return false;
62
+ // Reject anything carrying fields beyond x/y so they are not dropped.
63
+ for (const k in d) {
64
+ if (k !== 'x' && k !== 'y') return false;
65
+ }
66
+ return true;
67
+ }
68
+
69
+ /**
70
+ * Encode a cursor wire event into a codec payload.
71
+ *
72
+ * @param {string} event - one of 'update' | 'bulk' | 'remove' | 'join' | 'catalog'
73
+ * @param {any} data - the same value `platform.publish`/`send` would carry
74
+ * @returns {Uint8Array | null} payload bytes, or null to fall back to JSON
75
+ */
76
+ export function encodeCursor(event, data) {
77
+ try {
78
+ switch (event) {
79
+ case 'update': {
80
+ if (!data || typeof data.key !== 'string' || !isXY(data.data)) return null;
81
+ const w = new ByteWriter(24);
82
+ w.u8(OP_UPDATE);
83
+ w.str(data.key);
84
+ w.f32(data.data.x);
85
+ w.f32(data.data.y);
86
+ return w.take();
87
+ }
88
+ case 'bulk': {
89
+ if (!Array.isArray(data)) return null;
90
+ for (let i = 0; i < data.length; i++) {
91
+ const e = data[i];
92
+ if (!e || typeof e.key !== 'string' || !isXY(e.data)) return null;
93
+ }
94
+ const w = new ByteWriter(16 + data.length * 16);
95
+ w.u8(OP_BULK);
96
+ w.varint(data.length);
97
+ for (let i = 0; i < data.length; i++) {
98
+ const e = data[i];
99
+ w.str(e.key);
100
+ w.f32(e.data.x);
101
+ w.f32(e.data.y);
102
+ }
103
+ return w.take();
104
+ }
105
+ case 'remove': {
106
+ if (!data || typeof data.key !== 'string') return null;
107
+ const w = new ByteWriter(16);
108
+ w.u8(OP_REMOVE);
109
+ w.str(data.key);
110
+ return w.take();
111
+ }
112
+ case 'join': {
113
+ if (!data || typeof data.key !== 'string') return null;
114
+ const w = new ByteWriter(32);
115
+ w.u8(OP_JOIN);
116
+ w.str(data.key);
117
+ w.str(JSON.stringify(data.user ?? null));
118
+ return w.take();
119
+ }
120
+ case 'catalog': {
121
+ if (!Array.isArray(data)) return null;
122
+ const w = new ByteWriter(16 + data.length * 24);
123
+ w.u8(OP_CATALOG);
124
+ w.varint(data.length);
125
+ for (let i = 0; i < data.length; i++) {
126
+ const e = data[i];
127
+ if (!e || typeof e.key !== 'string') return null;
128
+ w.str(e.key);
129
+ w.str(JSON.stringify(e.user ?? null));
130
+ }
131
+ return w.take();
132
+ }
133
+ default:
134
+ return null;
135
+ }
136
+ } catch {
137
+ // Any encode failure (e.g. a `user` value that will not JSON-serialize)
138
+ // falls back to JSON for this frame rather than throwing into publish.
139
+ return null;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Decode a cursor codec payload back into the `{ event, data }` shape the
145
+ * JSON path would have dispatched. Returns null on an unknown opcode or a
146
+ * truncated / malformed frame (the frame is then dropped; cursor is
147
+ * best-effort).
148
+ *
149
+ * @param {Uint8Array} payload - codec bytes (frame header already stripped)
150
+ * @returns {{ event: string, data: any } | null}
151
+ */
152
+ export function decodeCursor(payload) {
153
+ try {
154
+ const r = new ByteReader(payload);
155
+ const op = r.u8();
156
+ switch (op) {
157
+ case OP_UPDATE: {
158
+ const key = r.str();
159
+ const x = r.f32();
160
+ const y = r.f32();
161
+ return { event: 'update', data: { key, data: { x, y } } };
162
+ }
163
+ case OP_BULK: {
164
+ const count = r.varint();
165
+ const arr = new Array(count);
166
+ for (let i = 0; i < count; i++) {
167
+ const key = r.str();
168
+ const x = r.f32();
169
+ const y = r.f32();
170
+ arr[i] = { key, data: { x, y } };
171
+ }
172
+ return { event: 'bulk', data: arr };
173
+ }
174
+ case OP_REMOVE: {
175
+ const key = r.str();
176
+ return { event: 'remove', data: { key } };
177
+ }
178
+ case OP_JOIN: {
179
+ const key = r.str();
180
+ const user = JSON.parse(r.str());
181
+ return { event: 'join', data: { key, user } };
182
+ }
183
+ case OP_CATALOG: {
184
+ const count = r.varint();
185
+ const arr = new Array(count);
186
+ for (let i = 0; i < count; i++) {
187
+ const key = r.str();
188
+ const user = JSON.parse(r.str());
189
+ arr[i] = { key, user };
190
+ }
191
+ return { event: 'catalog', data: arr };
192
+ }
193
+ default:
194
+ return null;
195
+ }
196
+ } catch {
197
+ return null;
198
+ }
199
+ }
@@ -89,6 +89,23 @@ export interface CursorOptions<UserData = unknown, UserInfo = unknown> {
89
89
  * @default 8192 (8 KB)
90
90
  */
91
91
  maxDataBytes?: number;
92
+
93
+ /**
94
+ * Binary wire transport. When `true` (the default), cursor frames are sent
95
+ * as compact binary `0x03` frames to clients that negotiated the
96
+ * `cursor.protocol:2` capability, and as JSON to everyone else - fully
97
+ * transparent, no app-code change, and a ~65-85% wire-size reduction on the
98
+ * position hot path. Set `false` to force JSON for every client (e.g. to
99
+ * keep DevTools' WS inspector readable). The wire format is the server's
100
+ * decision - clients never opt out via a URL parameter.
101
+ *
102
+ * Non-`{x, y}`-numeric cursor data (extra fields, non-numeric positions)
103
+ * transparently falls back to JSON per frame, so richer cursor payloads
104
+ * keep working regardless of this flag.
105
+ *
106
+ * @default true
107
+ */
108
+ binary?: boolean;
92
109
  }
93
110
 
94
111
  export interface CursorEntry<UserInfo = unknown, Data = unknown> {
@@ -36,6 +36,8 @@
36
36
  * @module svelte-adapter-uws/plugins/cursor
37
37
  */
38
38
 
39
+ import { encodeCursor, CURSOR_CAPABILITY, CURSOR_SCHEMA_VERSION } from './codec.js';
40
+
39
41
  const TOPIC_PREFIX = '__cursor:';
40
42
 
41
43
  /** Wire-protocol event names. */
@@ -154,6 +156,14 @@ export function createCursor(options = {}) {
154
156
  const maxTopicLength = options.maxTopicLength ?? 256;
155
157
  const maxDataBytes = options.maxDataBytes ?? 8192;
156
158
 
159
+ // Binary wire is on by default and fully transparent: binary-capable
160
+ // clients receive compact `0x03` cursor frames, everyone else (and any
161
+ // platform without the publishWire/sendWire methods, e.g. the unit-test
162
+ // mock) receives the identical JSON frames. `binary: false` forces JSON.
163
+ const wireCodec = options.binary === false
164
+ ? null
165
+ : { capability: CURSOR_CAPABILITY, schemaVersion: CURSOR_SCHEMA_VERSION, encode: encodeCursor };
166
+
157
167
  if (typeof throttleMs !== 'number' || !Number.isFinite(throttleMs) || throttleMs < 0) {
158
168
  throw new Error('cursor: throttle must be a non-negative number');
159
169
  }
@@ -284,13 +294,47 @@ export function createCursor(options = {}) {
284
294
  dirtyTopics.delete(topic);
285
295
  }
286
296
 
297
+ /**
298
+ * Broadcast a cursor wire event. Routes through the binary `publishWire`
299
+ * path when a codec is configured AND the platform supports it (production /
300
+ * dev / test-server); otherwise falls back to the JSON `publish` - so the
301
+ * unit-test mock platform and `binary: false` both keep the exact JSON shape.
302
+ * @param {string} fullTopic - the channel name, already TOPIC_PREFIX-scoped
303
+ * @param {string} event
304
+ * @param {any} data
305
+ * @param {import('../../index.js').Platform} platform
306
+ */
307
+ function emit(fullTopic, event, data, platform) {
308
+ if (wireCodec && typeof platform.publishWire === 'function') {
309
+ platform.publishWire(fullTopic, event, data, wireCodec);
310
+ } else {
311
+ platform.publish(fullTopic, event, data);
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Single-target variant of {@link emit} (snapshot catalog + positions).
317
+ * @param {any} ws
318
+ * @param {string} fullTopic
319
+ * @param {string} event
320
+ * @param {any} data
321
+ * @param {import('../../index.js').Platform} platform
322
+ */
323
+ function emitTo(ws, fullTopic, event, data, platform) {
324
+ if (wireCodec && typeof platform.sendWire === 'function') {
325
+ platform.sendWire(ws, fullTopic, event, data, wireCodec);
326
+ } else {
327
+ platform.send(ws, fullTopic, event, data);
328
+ }
329
+ }
330
+
287
331
  /**
288
332
  * Emit `join` for a (ws, topic) pair the first time the ws moves on
289
333
  * the topic. Broadcast (not single-target) so existing subscribers
290
334
  * pick up the new user before any position frames arrive.
291
335
  */
292
336
  function emitJoin(topic, key, user, platform) {
293
- platform.publish(TOPIC_PREFIX + topic, EVENTS.JOIN, { key, user });
337
+ emit(TOPIC_PREFIX + topic, EVENTS.JOIN, { key, user }, platform);
294
338
  }
295
339
 
296
340
  /**
@@ -301,7 +345,7 @@ export function createCursor(options = {}) {
301
345
  * @param {import('../../index.js').Platform} platform
302
346
  */
303
347
  function doBroadcast(topic, key, data, platform) {
304
- platform.publish(TOPIC_PREFIX + topic, EVENTS.UPDATE, { key, data });
348
+ emit(TOPIC_PREFIX + topic, EVENTS.UPDATE, { key, data }, platform);
305
349
  }
306
350
 
307
351
  /**
@@ -325,7 +369,7 @@ export function createCursor(options = {}) {
325
369
  flushPlatform = v.platform;
326
370
  }
327
371
  if (flushPlatform) {
328
- flushPlatform.publish(TOPIC_PREFIX + topic, EVENTS.BULK, entries);
372
+ emit(TOPIC_PREFIX + topic, EVENTS.BULK, entries, flushPlatform);
329
373
  }
330
374
  }
331
375
 
@@ -533,7 +577,7 @@ export function createCursor(options = {}) {
533
577
  const flushState = topicFlush.get(topic);
534
578
  if (flushState) flushState.dirty.delete(state.key);
535
579
  }
536
- platform.publish(TOPIC_PREFIX + topic, EVENTS.REMOVE, { key: state.key });
580
+ emit(TOPIC_PREFIX + topic, EVENTS.REMOVE, { key: state.key }, platform);
537
581
  }
538
582
  }
539
583
 
@@ -561,8 +605,8 @@ export function createCursor(options = {}) {
561
605
  positions.push({ key, data: entry.data });
562
606
  }
563
607
  }
564
- platform.send(ws, TOPIC_PREFIX + topic, EVENTS.CATALOG, catalog);
565
- platform.send(ws, TOPIC_PREFIX + topic, EVENTS.BULK, positions);
608
+ emitTo(ws, TOPIC_PREFIX + topic, EVENTS.CATALOG, catalog, platform);
609
+ emitTo(ws, TOPIC_PREFIX + topic, EVENTS.BULK, positions, platform);
566
610
  },
567
611
 
568
612
  clear() {
package/testing.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { parseCookies } from './files/cookies.js';
3
- import { nextTopicSeq, completeEnvelope, wrapBatchEnvelope, collapseByCoalesceKey, esc, isValidWireTopic, createScopedTopic, resolveRequestId, createChaosState, createUpgradeAdmission, readAssertionCounts, assert, WS_SUBSCRIPTIONS, WS_COALESCED, WS_SESSION_ID, WS_PENDING_REQUESTS, WS_STATS, WS_PLATFORM, WS_REQUEST_ID_KEY, WS_CAPS, MAX_SUBSCRIPTIONS_PER_CONNECTION, MAX_PENDING_REQUESTS_PER_CONNECTION } from './files/utils.js';
3
+ import { nextTopicSeq, completeEnvelope, wrapBatchEnvelope, collapseByCoalesceKey, esc, isValidWireTopic, createScopedTopic, resolveRequestId, createChaosState, createUpgradeAdmission, readAssertionCounts, assert, WS_SUBSCRIPTIONS, WS_COALESCED, WS_SESSION_ID, WS_PENDING_REQUESTS, WS_STATS, WS_PLATFORM, WS_REQUEST_ID_KEY, WS_CAPS, WS_TOPIC_IDS, MAX_SUBSCRIPTIONS_PER_CONNECTION, MAX_PENDING_REQUESTS_PER_CONNECTION } from './files/utils.js';
4
+ import { buildBinaryFrame, allocWireId, wireIdAnnounce, createCapCounts } from './files/wire.js';
4
5
 
5
6
  // Curated re-exports for downstream test code (extensions, app-side
6
7
  // integration tests, custom transport bridges that need to assert on
@@ -208,6 +209,50 @@ export async function createTestServer(options = {}) {
208
209
  return result;
209
210
  }
210
211
 
212
+ /**
213
+ * Binary-frame variant of sendOutboundT (isBinary=true). Routes through the
214
+ * same chaos chokepoint so drop/slow-drain scenarios apply to `0x03` frames.
215
+ * @param {import('uWebSockets.js').WebSocket<any>} ws
216
+ * @param {Uint8Array} frame
217
+ */
218
+ function sendOutboundBinaryT(ws, frame) {
219
+ if (chaos.shouldDropOutbound()) return 0;
220
+ const delay = chaos.getDelayMs();
221
+ if (delay > 0) {
222
+ setTimeout(() => {
223
+ try { ws.send(frame, true, false); }
224
+ catch { closedWsAbortsT++; return; }
225
+ bumpOutT(ws, frame);
226
+ }, delay);
227
+ return 1;
228
+ }
229
+ let result;
230
+ try { result = ws.send(frame, true, false); }
231
+ catch { closedWsAbortsT++; return 2; }
232
+ bumpOutT(ws, frame);
233
+ return result;
234
+ }
235
+
236
+ // Binary wire (0x03) capability accounting + topic-id assignment, mirroring
237
+ // production handler.js so the cap-gated binary publish path is exercised
238
+ // by createTestServer-based suites. Shared primitives live in ./files/wire.js.
239
+ const capCountsT = createCapCounts();
240
+
241
+ /**
242
+ * Per-connection topic-id resolution + lazy `wire-id` announce. Binary
243
+ * frames and the announce flow through sendOutboundT so chaos scenarios
244
+ * apply to them too.
245
+ * @param {import('uWebSockets.js').WebSocket<any>} ws
246
+ * @param {any} ud
247
+ * @param {string} topic
248
+ * @returns {number}
249
+ */
250
+ function ensureWireIdT(ws, ud, topic) {
251
+ const { id, isNew } = allocWireId(ud, WS_TOPIC_IDS, topic);
252
+ if (isNew) sendOutboundT(ws, wireIdAnnounce(topic, id));
253
+ return id;
254
+ }
255
+
211
256
  const platform = {
212
257
  publish(topic, event, data, options) {
213
258
  const seq = (options && options.seq === false)
@@ -233,6 +278,61 @@ export async function createTestServer(options = {}) {
233
278
  const payload = envelope(topic, event, data);
234
279
  return sendOutboundT(ws, payload);
235
280
  },
281
+ publishWire(topic, event, data, wire, options) {
282
+ const seq = (options && options.seq === false)
283
+ ? null
284
+ : nextTopicSeq(topicSeqs, topic);
285
+ const env = envelope(topic, event, data, seq);
286
+ const payload = capCountsT.has(wire.capability) ? wire.encode(event, data) : null;
287
+ if (payload == null) {
288
+ // No capable client (or codec declined): single C++ fan-out,
289
+ // identical to platform.publish's fast path.
290
+ if (chaos.scenario === null) return app.publish(topic, env, false, false);
291
+ let delivered = false;
292
+ for (const ws of wsConnections) {
293
+ if (!ws.isSubscribed(topic)) continue;
294
+ sendOutboundT(ws, env);
295
+ delivered = true;
296
+ }
297
+ return delivered;
298
+ }
299
+ const seqOnWire = seq == null ? 0 : seq;
300
+ /** @type {Map<number, Uint8Array>} */
301
+ const frameById = new Map();
302
+ let delivered = false;
303
+ for (const ws of wsConnections) {
304
+ let ud;
305
+ try { ud = ws.getUserData(); } catch { continue; }
306
+ const subs = ud[WS_SUBSCRIPTIONS];
307
+ if (!subs || !subs.has(topic)) continue;
308
+ const caps = ud[WS_CAPS];
309
+ if (caps && caps.has(wire.capability)) {
310
+ const id = ensureWireIdT(ws, ud, topic);
311
+ let frame = frameById.get(id);
312
+ if (!frame) {
313
+ frame = buildBinaryFrame(wire.schemaVersion, id, seqOnWire, payload);
314
+ frameById.set(id, frame);
315
+ }
316
+ sendOutboundBinaryT(ws, frame);
317
+ } else {
318
+ sendOutboundT(ws, env);
319
+ }
320
+ delivered = true;
321
+ }
322
+ return delivered;
323
+ },
324
+ sendWire(ws, topic, event, data, wire) {
325
+ let ud;
326
+ try { ud = ws.getUserData(); } catch { closedWsAbortsT++; return 2; }
327
+ const caps = ud[WS_CAPS];
328
+ const payload = (caps && caps.has(wire.capability)) ? wire.encode(event, data) : null;
329
+ if (payload == null) {
330
+ return sendOutboundT(ws, envelope(topic, event, data));
331
+ }
332
+ const id = ensureWireIdT(ws, ud, topic);
333
+ const frame = buildBinaryFrame(wire.schemaVersion, id, 0, payload);
334
+ return sendOutboundBinaryT(ws, frame);
335
+ },
236
336
  sendTo(filter, topic, event, data) {
237
337
  const msg = envelope(topic, event, data);
238
338
  let count = 0;
@@ -681,7 +781,9 @@ export async function createTestServer(options = {}) {
681
781
  for (let i = 0; i < msg.caps.length; i++) {
682
782
  if (typeof msg.caps[i] === 'string') caps.add(msg.caps[i]);
683
783
  }
684
- ws.getUserData()[WS_CAPS] = caps;
784
+ const helloUd = ws.getUserData();
785
+ capCountsT.adjust(helloUd[WS_CAPS], caps);
786
+ helloUd[WS_CAPS] = caps;
685
787
  return;
686
788
  }
687
789
  if (msg.type === 'subscribe-batch' && Array.isArray(msg.topics)) {
@@ -809,6 +911,7 @@ export async function createTestServer(options = {}) {
809
911
  }
810
912
  : { code, message, platform: closePlatform, subscriptions: subs };
811
913
  handler.close?.(ws, ctx);
914
+ capCountsT.adjust(ud[WS_CAPS], null);
812
915
  wsConnections.delete(ws);
813
916
  }
814
917
  });
package/vite.js CHANGED
@@ -319,6 +319,20 @@ export default function uws(options = {}) {
319
319
  const platform = {
320
320
  publish,
321
321
  publishBatched,
322
+ // Binary wire (publishWire/sendWire) is a production transport
323
+ // optimization. Dev mode delegates to the JSON publish/send: a
324
+ // binary-capable client receives JSON text frames, which its cursor
325
+ // store consumes identically (the binary path is transparent and
326
+ // optional). This mirrors dev's existing simpler-than-prod posture
327
+ // (dev also skips per-topic seq stamping). The full binary `0x03` path
328
+ // ships and is tested in production (files/handler.js) and the test
329
+ // server (testing.js).
330
+ publishWire(topic, event, data, _wire, options) {
331
+ return publish(topic, event, data, options);
332
+ },
333
+ sendWire(ws, topic, event, data, _wire) {
334
+ return send(ws, topic, event, data);
335
+ },
322
336
  batch(messages) {
323
337
  const results = [];
324
338
  for (let i = 0; i < messages.length; i++) {