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

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,21 @@ 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. 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
+ There are two binary wire forms, negotiated per connection by capability:
2823
+
2824
+ - **Full-string keys (`cursor.protocol:2`, schema 1).** Every frame carries each cursor's key string. A 221-cursor coalesced `bulk` is **~83% smaller** than JSON and decodes ~4-5x faster.
2825
+ - **Short-id dictionary (`cursor.protocol:3`, schema 2), the default for capable clients.** Each cursor key is announced once, then referenced by a 1-2 byte per-connection id, so the key bytes leave the wire after the first frame and the decoder resolves the id from a cached map - **no per-entry string decode**. For realistic clustered keys this lands a warm `bulk` at **~88-89% smaller than JSON** and decodes **~16-18x faster than `JSON.parse` (~4.4x faster than the full-string wire)**. No `JSON.parse` on the cursor receive path at all.
2826
+
2827
+ - **Capability-gated, never breaking.** The client advertises both `cursor.protocol:2` and `cursor.protocol:3` in its `hello` frame; the server sends the dictionary form to clients that advertised it and the full-string form to older binary clients, and a client with neither receives JSON unchanged. The frame's 1-byte schema version tells the decoder which form it is. Old client <-> new server and new client <-> old server both keep working.
2828
+ - **`binary: false` to disable, `dictionary: false` to keep the full-string wire.** `createCursor({ binary: false })` forces JSON for every client (e.g. to keep DevTools' WS inspector readable). `createCursor({ dictionary: false })` keeps binary but uses the full-string form for everyone, encoded once and fanned out to all subscribers. The dictionary is per-connection stateful, so each capable subscriber's frame is encoded independently; a warm dictionary encode is much cheaper than a full-string encode, so the default is a net win (cheaper CPU and smaller frames) for typical per-process fan-out - reach for `dictionary: false` only on a single process serving very high per-topic subscriber counts (hundreds-plus on one worker), where the per-subscriber encode would cost more CPU than the bandwidth saving is worth. 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.
2829
+ - **Positions are `float32`, keys are 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.
2830
+
2831
+ 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, state?), state? }` and `encode` returns a `Uint8Array` payload or `null` to fall back to JSON), plus `registerWireCodec(prefix, { capability, capabilities?, state?, decode })` from `svelte-adapter-uws/client` on the client. The optional `wire.state` slot gives the codec one object per connection (`onAttach(ws)` / `onDetach(ws, state)`) for a stateful wire like the cursor dictionary; the per-connection `state` is reset on reconnect. JSON-only deployments pay nothing - `publishWire` takes the same single broadcast as `publish` when no connected client wants binary.
2832
+
2818
2833
  #### Server usage
2819
2834
 
2820
2835
  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,44 @@ 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
+ * A codec may advertise more than one capability via `capabilities` (e.g. a
656
+ * cursor client that decodes both the full-string and the short-id dictionary
657
+ * wire advertises both tokens, negotiating the best the server offers while an
658
+ * older server still sends the form it knows). A codec may also declare a
659
+ * per-connection `state` factory (`state.onAttach` / `state.onDetach`) for a
660
+ * stateful wire (the cursor short-id dictionary); `decode` then receives that
661
+ * per-connection state and the frame's `schemaVersion` so it can resolve
662
+ * references and dispatch between schema revisions. The state is reset on every
663
+ * (re)connect, in lock-step with the server's matching encoder state.
664
+ *
665
+ * @param prefix - topic-name prefix the codec owns (e.g. `'__cursor:'`)
666
+ * @param codec - `{ capability, capabilities?, state?, decode }`
667
+ */
668
+ export function registerWireCodec(
669
+ prefix: string,
670
+ codec: {
671
+ capability: string;
672
+ capabilities?: string[];
673
+ state?: {
674
+ onAttach?: () => unknown;
675
+ onDetach?: (state: unknown) => void;
676
+ };
677
+ decode: (
678
+ payload: Uint8Array,
679
+ state?: unknown,
680
+ schemaVersion?: number
681
+ ) => { event: string; data: unknown } | null;
682
+ }
683
+ ): 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,76 @@ 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
+ *
18
+ * A codec may advertise more than one capability (`capabilities`) - e.g. a
19
+ * cursor client that can decode both the full-string and the short-id wire
20
+ * advertises both tokens so it negotiates the best the server offers while an
21
+ * older server still sends it the form it knows. A codec may also declare a
22
+ * per-connection `state` factory (`state.onAttach` / `state.onDetach`) for a
23
+ * stateful wire (the cursor short-id dictionary, or a future apply-in-place
24
+ * CRDT codec); the decoder then receives that state plus the frame's
25
+ * `schemaVersion` so it can dispatch between schema revisions.
26
+ * @type {Map<string, { capability: string, capabilities?: string[], state?: { onAttach?: () => any, onDetach?: (state: any) => void }, decode: (payload: Uint8Array, state?: any, schemaVersion?: number) => ({ event: string, data: any } | null) }>}
27
+ */
28
+ const wireCodecs = new Map();
29
+
30
+ /**
31
+ * Register a binary wire codec for a topic-name prefix. Idempotent per prefix.
32
+ * Plugins call this at module load (before connect) so the first `hello`
33
+ * already advertises the capability; if a connection is already open, its
34
+ * `hello` is re-sent so a lazily-imported plugin still negotiates binary.
35
+ *
36
+ * @param {string} prefix - topic-name prefix the codec owns (e.g. '__cursor:')
37
+ * @param {{ capability: string, capabilities?: string[], state?: { onAttach?: () => any, onDetach?: (state: any) => void }, decode: (payload: Uint8Array, state?: any, schemaVersion?: number) => ({ event: string, data: any } | null) }} codec
38
+ */
39
+ export function registerWireCodec(prefix, codec) {
40
+ wireCodecs.set(prefix, codec);
41
+ if (singleton && typeof singleton._resendHello === 'function') singleton._resendHello();
42
+ }
43
+
44
+ /**
45
+ * Build the `hello` caps array: `'batch'` plus every capability every
46
+ * registered codec can decode. A client always advertises what it can decode;
47
+ * the wire format is the server's decision (a plugin's codec, or `binary: false`
48
+ * to force JSON). We deliberately do NOT read any URL query parameter to opt
49
+ * out - the app owns its URL namespace, and a client-side force-JSON knob would
50
+ * let a connection inflate its own egress.
51
+ * @returns {string[]}
52
+ */
53
+ function buildHelloCaps() {
54
+ const caps = ['batch'];
55
+ for (const codec of wireCodecs.values()) {
56
+ const tokens = codec.capabilities || [codec.capability];
57
+ for (let i = 0; i < tokens.length; i++) caps.push(tokens[i]);
58
+ }
59
+ return caps;
60
+ }
61
+
62
+ /**
63
+ * Resolve a topic name to its registered wire codec (and its prefix, for
64
+ * per-connection state keying) by longest matching prefix.
65
+ * @param {string} topic
66
+ * @returns {{ prefix: string, codec: { capability: string, capabilities?: string[], state?: { onAttach?: () => any, onDetach?: (state: any) => void }, decode: (payload: Uint8Array, state?: any, schemaVersion?: number) => ({ event: string, data: any } | null) } } | null}
67
+ */
68
+ function wireCodecForTopic(topic) {
69
+ let best = null;
70
+ let bestLen = -1;
71
+ for (const [prefix, codec] of wireCodecs) {
72
+ if (topic.startsWith(prefix) && prefix.length > bestLen) {
73
+ best = { prefix, codec };
74
+ bestLen = prefix.length;
75
+ }
76
+ }
77
+ return best;
78
+ }
79
+
9
80
  /**
10
81
  * Ensure the singleton connection exists.
11
82
  * @param {import('./client.js').ConnectOptions} [options]
@@ -707,6 +778,47 @@ function createConnection(options) {
707
778
  /** @type {Map<string, number>} */
708
779
  const topicRefCounts = new Map();
709
780
 
781
+ // Inverse of the server's per-connection topic-id map: numeric wireId ->
782
+ // topic name. Populated from `{type:'wire-id'}` control frames; an inbound
783
+ // `0x03` binary frame carries only the numeric id, resolved here back to the
784
+ // topic name the store ladder is keyed on. Per-connection: cleared on each
785
+ // (re)connect since the server reassigns ids fresh on a new connection.
786
+ /** @type {Map<number, string>} */
787
+ const wireIdMap = new Map();
788
+
789
+ // Per-connection decoder state for stateful wire codecs (e.g. the cursor
790
+ // short-id dictionary), keyed by codec prefix. Created lazily on the first
791
+ // `0x03` frame for a prefix via the codec's `state.onAttach()`, and cleared
792
+ // (with `state.onDetach()`) on each (re)connect alongside `wireIdMap` since
793
+ // the server resets its matching encoder state on a fresh connection.
794
+ /** @type {Map<string, any>} */
795
+ const wireDecoderStates = new Map();
796
+
797
+ // Resolve (lazily creating) the per-connection decoder state for a codec.
798
+ /** @param {string} prefix @param {{ state?: { onAttach?: () => any } }} codec @returns {any} */
799
+ function ensureDecoderState(prefix, codec) {
800
+ if (!codec.state || typeof codec.state.onAttach !== 'function') return null;
801
+ let st = wireDecoderStates.get(prefix);
802
+ if (st === undefined) {
803
+ try { st = codec.state.onAttach(); } catch { st = null; }
804
+ wireDecoderStates.set(prefix, st);
805
+ }
806
+ return st;
807
+ }
808
+
809
+ // Dispose every per-connection decoder state, then clear. Called on each
810
+ // (re)connect so a reconnect starts from an empty dictionary in lock-step
811
+ // with the server's reset encoder state.
812
+ function resetWireDecoderStates() {
813
+ for (const [prefix, st] of wireDecoderStates) {
814
+ const codec = wireCodecs.get(prefix);
815
+ if (codec && codec.state && typeof codec.state.onDetach === 'function') {
816
+ try { codec.state.onDetach(st); } catch {}
817
+ }
818
+ }
819
+ wireDecoderStates.clear();
820
+ }
821
+
710
822
  // Highest seq seen per topic. Sent back to the server on reconnect via
711
823
  // the resume frame so the user's resume hook can replay anything we
712
824
  // missed during the disconnect window. Only topics that the server is
@@ -983,6 +1095,18 @@ function createConnection(options) {
983
1095
  scheduleReconnect();
984
1096
  return;
985
1097
  }
1098
+ // Read inbound binary frames as ArrayBuffer (default is Blob, which is
1099
+ // async to read). Cannot regress the realtime upload layer: that layer
1100
+ // only EMITS binary (0x01/0x02) and receives upload results as JSON on
1101
+ // the '__upload' topic - it never reads an inbound binary frame.
1102
+ ws.binaryType = 'arraybuffer';
1103
+ // Topic-ids are per-connection; the server reassigns them on a fresh
1104
+ // connection, so drop any stale id -> name mappings from a prior socket.
1105
+ // The stateful codec dictionaries reset in lock-step: the server starts a
1106
+ // fresh encoder dictionary on the new connection, so a stale client
1107
+ // dictionary would resolve ids to the wrong keys.
1108
+ wireIdMap.clear();
1109
+ resetWireDecoderStates();
986
1110
 
987
1111
  ws.onopen = () => {
988
1112
  attempt = 0;
@@ -992,10 +1116,12 @@ function createConnection(options) {
992
1116
  if (debug) console.log('[ws] connected');
993
1117
 
994
1118
  // 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"]}');
1119
+ // connection's userData and uses them to gate opt-in wire features:
1120
+ // 'batch' for publishBatched frames, plus any registered binary wire
1121
+ // codec capabilities (e.g. 'cursor.protocol:2'). A client always
1122
+ // advertises what it can decode; the wire format is the server's
1123
+ // call. Old servers ignore the unknown frame type.
1124
+ ws?.send(JSON.stringify({ type: 'hello', caps: buildHelloCaps() }));
999
1125
 
1000
1126
  // If we have a previous session id and any tracked seqs, ask the
1001
1127
  // server to fill the gap before we resubscribe. The server's
@@ -1057,6 +1183,38 @@ function createConnection(options) {
1057
1183
  ws.onmessage = (rawEvent) => {
1058
1184
  lastServerMessage = Date.now();
1059
1185
  try {
1186
+ // Inbound binary demux, ahead of the JSON path. A 0x03 frame is
1187
+ // a binary topic PAYLOAD: resolve its numeric topic-id to a name,
1188
+ // decode via the registered codec, and feed the SAME
1189
+ // dispatchEvent the JSON path uses so the reactive surface is
1190
+ // byte-for-byte identical (zero JSON.parse on this hot path).
1191
+ // Any other binary frame (the realtime layer's outbound-only
1192
+ // 0x01/0x02, or a malformed frame) is dropped. Binary frames
1193
+ // never fall through to JSON.parse.
1194
+ if (rawEvent.data instanceof ArrayBuffer) {
1195
+ if (rawEvent.data.byteLength > 1048576) {
1196
+ if (debug) console.warn('[ws] binary frame too large, dropped:', rawEvent.data.byteLength, 'bytes');
1197
+ return;
1198
+ }
1199
+ const parsed = parseBinaryFrame(new Uint8Array(rawEvent.data));
1200
+ if (parsed) {
1201
+ const topic = wireIdMap.get(parsed.topicId);
1202
+ if (topic !== undefined) {
1203
+ const match = wireCodecForTopic(topic);
1204
+ const decoded = match
1205
+ ? match.codec.decode(parsed.payload, ensureDecoderState(match.prefix, match.codec), parsed.schemaVersion)
1206
+ : null;
1207
+ if (decoded) {
1208
+ const out = { topic, event: decoded.event, data: decoded.data };
1209
+ if (parsed.seq > 0) out.seq = parsed.seq;
1210
+ dispatchEvent(out);
1211
+ }
1212
+ } else if (debug) {
1213
+ console.warn('[ws] 0x03 frame for unknown topicId', parsed.topicId);
1214
+ }
1215
+ }
1216
+ return;
1217
+ }
1060
1218
  // Reject oversized messages to prevent main-thread blocking
1061
1219
  if (typeof rawEvent.data === 'string' && rawEvent.data.length > 1048576) {
1062
1220
  if (debug) console.warn('[ws] message too large, dropped:', rawEvent.data.length, 'bytes');
@@ -1093,6 +1251,15 @@ function createConnection(options) {
1093
1251
  if (debug) console.log('[ws] subscribed topic=%s ref=%s', msg.topic, msg.ref);
1094
1252
  return;
1095
1253
  }
1254
+ if (msg.type === 'wire-id' && typeof msg.topic === 'string' && typeof msg.id === 'number') {
1255
+ // Server announced a binary topic-id assignment. Record the
1256
+ // inverse mapping so a subsequent 0x03 frame's numeric id
1257
+ // resolves to this topic name. Arrives before the first
1258
+ // binary frame for the topic (same socket, ordered).
1259
+ wireIdMap.set(msg.id, msg.topic);
1260
+ if (debug) console.log('[ws] wire-id topic=%s id=%d', msg.topic, msg.id);
1261
+ return;
1262
+ }
1096
1263
  if (msg.type === 'subscribe-denied' && typeof msg.topic === 'string' && typeof msg.reason === 'string') {
1097
1264
  console.warn('[ws] subscribe denied topic=%s reason=%s\n See: https://svti.me/subscribe-denied', msg.topic, msg.reason);
1098
1265
  denialsStore.set({ topic: msg.topic, reason: msg.reason, ref: msg.ref });
@@ -1562,6 +1729,16 @@ function createConnection(options) {
1562
1729
  return () => { if (requestHandler === handler) requestHandler = null; };
1563
1730
  }
1564
1731
 
1732
+ // Re-advertise capabilities on an already-open socket. Called by
1733
+ // registerWireCodec when a binary plugin is imported after connect so its
1734
+ // capability still reaches the server (the common case - import before
1735
+ // connect - is covered by buildHelloCaps() reading the registry at open).
1736
+ function resendHello() {
1737
+ if (ws && ws.readyState === WebSocket.OPEN) {
1738
+ ws.send(JSON.stringify({ type: 'hello', caps: buildHelloCaps() }));
1739
+ }
1740
+ }
1741
+
1565
1742
  return {
1566
1743
  events: { subscribe: eventsStore.subscribe },
1567
1744
  status: { subscribe: statusStore.subscribe },
@@ -1584,6 +1761,7 @@ function createConnection(options) {
1584
1761
  // mark and back off until it drops below a low-water mark.
1585
1762
  get bufferedAmount() { return ws?.bufferedAmount ?? 0; },
1586
1763
  onRequest,
1764
+ _resendHello: resendHello,
1587
1765
  close
1588
1766
  };
1589
1767
  }
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, WS_WIRE_STATE, 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,97 @@ 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
+
950
+ /**
951
+ * Resolve (allocating on first use) the per-connection state object for a
952
+ * stateful wire codec, stored under the codec's capability in the
953
+ * `WS_WIRE_STATE` slot. The codec's `wire.state.onAttach(ws)` factory runs once
954
+ * per (connection, capability) - the decision it makes (e.g. which schema
955
+ * version this connection negotiated, read from its `WS_CAPS`) is then fixed for
956
+ * the life of the connection. A factory that throws or returns null degrades
957
+ * that connection to JSON for the frame rather than crashing the publish.
958
+ * Returns null for a stateless codec (no `wire.state`) or on attach failure.
959
+ * @param {import('uWebSockets.js').WebSocket<any>} ws
960
+ * @param {any} ud - ws.getUserData()
961
+ * @param {{ capability: string, state?: { onAttach: (ws: any) => any, onDetach?: (ws: any, state: any) => void } }} wire
962
+ * @returns {any}
963
+ */
964
+ function ensureWireState(ws, ud, wire) {
965
+ if (!wire.state) return null;
966
+ let m = ud[WS_WIRE_STATE];
967
+ if (!m) {
968
+ m = new Map();
969
+ ud[WS_WIRE_STATE] = m;
970
+ }
971
+ let entry = m.get(wire.capability);
972
+ if (entry === undefined) {
973
+ let state = null;
974
+ try {
975
+ state = wire.state.onAttach(ws);
976
+ } catch (err) {
977
+ if (wsDebug) console.error('[ws] wire.state.onAttach threw for', wire.capability, err);
978
+ state = null;
979
+ }
980
+ entry = { state, detach: wire.state.onDetach };
981
+ m.set(wire.capability, entry);
982
+ }
983
+ return entry.state;
984
+ }
985
+
986
+ /**
987
+ * Dispose every per-connection wire-codec state on close. Mirrors the
988
+ * `capCounts.adjust(..., null)` release: a codec that holds resources (or just
989
+ * wants its dictionary freed promptly) gets its `onDetach(ws, state)` called
990
+ * exactly once. Safe to call when no stateful codec ever ran.
991
+ * @param {import('uWebSockets.js').WebSocket<any>} ws
992
+ * @param {any} ud - ws.getUserData()
993
+ */
994
+ function detachWireStates(ws, ud) {
995
+ const m = ud[WS_WIRE_STATE];
996
+ if (!m) return;
997
+ for (const entry of m.values()) {
998
+ if (entry && typeof entry.detach === 'function') {
999
+ try { entry.detach(ws, entry.state); } catch (err) {
1000
+ if (wsDebug) console.error('[ws] wire.state.onDetach threw', err);
1001
+ }
1002
+ }
1003
+ }
1004
+ m.clear();
1005
+ }
1006
+
915
1007
  /** @type {import('./index.js').Platform} */
916
1008
  const platform = {
917
1009
  /**
@@ -979,6 +1071,190 @@ const platform = {
979
1071
  return result;
980
1072
  },
981
1073
 
1074
+ /**
1075
+ * Publish via a plugin-declared binary wire codec. Binary-capable
1076
+ * subscribers (those that advertised `wire.capability`) receive a `0x03`
1077
+ * frame; everyone else receives the identical JSON envelope `publish()`
1078
+ * would have sent. When no connected client advertises the capability - or
1079
+ * the codec declines this frame (`encode` returns null) - this takes the
1080
+ * exact single `app.publish` JSON fan-out with no per-subscriber walk, so a
1081
+ * JSON-only deployment pays nothing for the binary machinery.
1082
+ *
1083
+ * The framework owns the `0x03 | schemaVersion | topicId | seq | payload`
1084
+ * envelope; the plugin's `encode` produces only the payload. seq is stamped
1085
+ * once and carried in both the JSON and binary forms so resume keeps working.
1086
+ *
1087
+ * @param {string} topic
1088
+ * @param {string} event
1089
+ * @param {any} data
1090
+ * @param {{ capability: string, schemaVersion: number, encode: (event: string, data: any) => (Uint8Array | null) }} wire
1091
+ * @param {{ seq?: boolean, relay?: boolean }} [options]
1092
+ * @returns {boolean}
1093
+ */
1094
+ publishWire(topic, event, data, wire, options) {
1095
+ publishCountWindow++;
1096
+ const seq = (options && options.seq === false)
1097
+ ? null
1098
+ : nextTopicSeq(topicSeqs, topic);
1099
+ const envelope = completeEnvelope(envelopePrefix(topic, event), data, seq);
1100
+ assert(envelope.length > 0, 'envelope.empty', { topic, event });
1101
+ let s = topicPublishStats.get(topic);
1102
+ if (!s) {
1103
+ s = { m: 0, b: 0 };
1104
+ topicPublishStats.set(topic, s);
1105
+ maybeWarnTopicRegistry();
1106
+ } else {
1107
+ assert(typeof s.m === 'number' && typeof s.b === 'number', 'topic.stats-shape', { topic });
1108
+ }
1109
+ s.m++;
1110
+ s.b += envelope.length;
1111
+
1112
+ const relayed = !!(parentPort && (!options || options.relay !== false));
1113
+
1114
+ // JSON fast path: no live connection wants binary for this codec. Byte-
1115
+ // and instruction-identical to platform.publish - a JSON-only deployment
1116
+ // never enters the per-subscriber walk or touches the codec at all.
1117
+ if (!capCounts.has(wire.capability)) {
1118
+ const result = app.publish(topic, envelope, false, false);
1119
+ if (relayed) batchRelay(topic, envelope);
1120
+ return result || relayed;
1121
+ }
1122
+
1123
+ const seqOnWire = seq == null ? 0 : seq;
1124
+
1125
+ // Stateful codec (per-connection dictionary / apply-state): the encoded
1126
+ // payload depends on the recipient's state, so encode-once-send-many no
1127
+ // longer holds for the binary recipients - each capable connection is
1128
+ // encoded against its own state. Connections whose onAttach returned null
1129
+ // (e.g. an older client that negotiated the stateless schema) share one
1130
+ // encode at `wire.schemaVersion`, memoized by topic-id, so a mixed room
1131
+ // keeps the single-encode fan-out for those clients.
1132
+ if (wire.state) {
1133
+ let sharedPayload;
1134
+ let sharedEncoded = false;
1135
+ /** @type {Map<number, Uint8Array>} */
1136
+ const sharedFrameById = new Map();
1137
+ for (const ws of wsConnections) {
1138
+ let ud;
1139
+ try { ud = ws.getUserData(); } catch { continue; }
1140
+ const subs = ud[WS_SUBSCRIPTIONS];
1141
+ if (!subs || !subs.has(topic)) continue;
1142
+ const caps = ud[WS_CAPS];
1143
+ if (!caps || !caps.has(wire.capability)) {
1144
+ try { ws.send(envelope, false, false); } catch { closedWsAborts++; }
1145
+ continue;
1146
+ }
1147
+ const state = ensureWireState(ws, ud, wire);
1148
+ if (state == null) {
1149
+ // Shared encode-once at the codec's baseline schema version.
1150
+ if (!sharedEncoded) { sharedPayload = wire.encode(event, data, null); sharedEncoded = true; }
1151
+ if (sharedPayload == null) { try { ws.send(envelope, false, false); } catch { closedWsAborts++; } continue; }
1152
+ const id = ensureWireId(ws, ud, topic);
1153
+ let frame = sharedFrameById.get(id);
1154
+ if (!frame) { frame = buildBinaryFrame(wire.schemaVersion, id, seqOnWire, sharedPayload); sharedFrameById.set(id, frame); }
1155
+ try { ws.send(frame, true, false); } catch { closedWsAborts++; }
1156
+ } else {
1157
+ // Per-connection encode against this connection's state, stamped
1158
+ // with the schema version that state negotiated.
1159
+ const payload = wire.encode(event, data, state);
1160
+ if (payload == null) { try { ws.send(envelope, false, false); } catch { closedWsAborts++; } continue; }
1161
+ const sv = typeof state.schemaVersion === 'number' ? state.schemaVersion : wire.schemaVersion;
1162
+ const frame = buildBinaryFrame(sv, ensureWireId(ws, ud, topic), seqOnWire, payload);
1163
+ try { ws.send(frame, true, false); } catch { closedWsAborts++; }
1164
+ }
1165
+ }
1166
+ if (relayed) batchRelay(topic, envelope);
1167
+ return true;
1168
+ }
1169
+
1170
+ // Stateless codec: encode once, send many. A null payload (the codec
1171
+ // declined this frame) falls through to the single C++ app.publish
1172
+ // fan-out, instruction-identical to platform.publish. The codec payload
1173
+ // is shared across recipients; only the tiny per-connection frame header
1174
+ // (topic-id + seq) differs, memoized per distinct id so the common
1175
+ // all-same-id case builds one frame and reuses it for every binary send.
1176
+ const payload = wire.encode(event, data);
1177
+ if (payload == null) {
1178
+ const result = app.publish(topic, envelope, false, false);
1179
+ if (relayed) batchRelay(topic, envelope);
1180
+ return result || relayed;
1181
+ }
1182
+ /** @type {Map<number, Uint8Array>} */
1183
+ const frameById = new Map();
1184
+ for (const ws of wsConnections) {
1185
+ let ud;
1186
+ try { ud = ws.getUserData(); } catch { continue; }
1187
+ const subs = ud[WS_SUBSCRIPTIONS];
1188
+ if (!subs || !subs.has(topic)) continue;
1189
+ const caps = ud[WS_CAPS];
1190
+ if (caps && caps.has(wire.capability)) {
1191
+ const id = ensureWireId(ws, ud, topic);
1192
+ let frame = frameById.get(id);
1193
+ if (!frame) {
1194
+ frame = buildBinaryFrame(wire.schemaVersion, id, seqOnWire, payload);
1195
+ frameById.set(id, frame);
1196
+ }
1197
+ try { ws.send(frame, true, false); } catch { closedWsAborts++; }
1198
+ } else {
1199
+ try { ws.send(envelope, false, false); } catch { closedWsAborts++; }
1200
+ }
1201
+ }
1202
+ // Cross-worker subscribers receive the JSON envelope (binary is
1203
+ // same-worker only); their worker re-publishes it to them as JSON.
1204
+ if (relayed) batchRelay(topic, envelope);
1205
+ if (wsDebug) {
1206
+ console.log('[ws] publishWire topic=%s event=%s payloadBytes=%d', topic, event, payload.length);
1207
+ }
1208
+ return true;
1209
+ },
1210
+
1211
+ /**
1212
+ * Single-target send via a plugin-declared binary wire codec. The target
1213
+ * receives a `0x03` frame when it advertised `wire.capability` and the
1214
+ * codec can encode this frame; otherwise it receives the JSON envelope
1215
+ * `send()` would have sent. No seq is stamped (matches `send()`); the
1216
+ * binary frame carries seq 0 ("no seq"). Used for snapshot/catalog frames.
1217
+ *
1218
+ * @param {import('uWebSockets.js').WebSocket<any>} ws
1219
+ * @param {string} topic
1220
+ * @param {string} event
1221
+ * @param {any} data
1222
+ * @param {{ capability: string, schemaVersion: number, encode: (event: string, data: any) => (Uint8Array | null) }} wire
1223
+ * @returns {number} uWS send status (0/1/2), or 2 on a freed handle
1224
+ */
1225
+ sendWire(ws, topic, event, data, wire) {
1226
+ let ud;
1227
+ try { ud = ws.getUserData(); } catch { closedWsAborts++; return 2; }
1228
+ const caps = ud[WS_CAPS];
1229
+ let payload = null;
1230
+ let schemaVersion = wire.schemaVersion;
1231
+ if (caps && caps.has(wire.capability)) {
1232
+ if (wire.state) {
1233
+ // Share the connection's codec state with publishWire so a
1234
+ // snapshot CATALOG interns ids the following BULK (and every
1235
+ // later broadcast) references against the same dictionary.
1236
+ const state = ensureWireState(ws, ud, wire);
1237
+ payload = wire.encode(event, data, state);
1238
+ if (state != null && typeof state.schemaVersion === 'number') schemaVersion = state.schemaVersion;
1239
+ } else {
1240
+ payload = wire.encode(event, data);
1241
+ }
1242
+ }
1243
+ if (payload == null) {
1244
+ const json = envelopePrefix(topic, event) + JSON.stringify(data ?? null) + '}';
1245
+ let result;
1246
+ try { result = ws.send(json, false, false); } catch { closedWsAborts++; return 2; }
1247
+ bumpOut(ws, json);
1248
+ return result;
1249
+ }
1250
+ const id = ensureWireId(ws, ud, topic);
1251
+ const frame = buildBinaryFrame(schemaVersion, id, 0, payload);
1252
+ let result;
1253
+ try { result = ws.send(frame, true, false); } catch { closedWsAborts++; return 2; }
1254
+ bumpOut(ws, frame);
1255
+ return result;
1256
+ },
1257
+
982
1258
  /**
983
1259
  * Send a message to a single connection with coalesce-by-key semantics.
984
1260
  *
@@ -3321,7 +3597,12 @@ if (WS_ENABLED) {
3321
3597
  for (let i = 0; i < msg.caps.length; i++) {
3322
3598
  if (typeof msg.caps[i] === 'string') caps.add(msg.caps[i]);
3323
3599
  }
3324
- ws.getUserData()[WS_CAPS] = caps;
3600
+ const ud = ws.getUserData();
3601
+ // Maintain the live per-capability connection counts so the
3602
+ // binary publish fast path knows whether any client wants
3603
+ // binary. A re-sent hello replaces the prior set; diff it.
3604
+ capCounts.adjust(ud[WS_CAPS], caps);
3605
+ ud[WS_CAPS] = caps;
3325
3606
  if (wsDebug) console.log('[ws] hello caps=%o', [...caps]);
3326
3607
  return;
3327
3608
  }
@@ -3412,6 +3693,12 @@ if (WS_ENABLED) {
3412
3693
  } finally {
3413
3694
  totalSubscriptions -= subscriptions.size;
3414
3695
  assert(totalSubscriptions >= 0, 'subs.total-negative', { totalSubscriptions });
3696
+ // Release this connection's advertised capabilities from the
3697
+ // live counts so the binary publish fast path stays accurate.
3698
+ capCounts.adjust(userData[WS_CAPS], null);
3699
+ // Dispose any per-connection wire-codec state (e.g. the cursor
3700
+ // short-id dictionary) so a long-lived server frees it promptly.
3701
+ detachWireStates(ws, userData);
3415
3702
  wsConnections.delete(ws);
3416
3703
  if (wsDebug) console.log('[ws] close code=%d connections=%d', code, wsConnections.size);
3417
3704
  }
package/files/utils.js CHANGED
@@ -404,6 +404,31 @@ 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
+
419
+ /**
420
+ * Per-connection per-codec wire state for stateful binary codecs:
421
+ * `Map<capability, { state, detach }>`. A codec that declares a `wire.state`
422
+ * factory (e.g. the cursor short-id dictionary, or a future apply-in-place
423
+ * CRDT codec) gets one `state` object per connection, created lazily by
424
+ * `wire.state.onAttach(ws)` on the first binary frame to that connection and
425
+ * disposed by `wire.state.onDetach(ws, state)` on close. JSON-only and
426
+ * stateless-codec connections never allocate this slot. The decision a codec
427
+ * makes in `onAttach` (e.g. which schema version this connection negotiated)
428
+ * is fixed for the life of the connection - reset on reconnect, not re-hello.
429
+ */
430
+ export const WS_WIRE_STATE = Symbol.for('adapter-uws.ws.wire-state');
431
+
407
432
  // - Bounded-by-default capacity caps ---------------------------------------
408
433
  // Single source of truth for the per-connection and module-level Map / Set
409
434
  // caps that handler.js, vite.js, and testing.js all enforce. The numbers