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 +15 -0
- package/client.d.ts +41 -0
- package/client.js +182 -4
- package/files/handler.js +289 -2
- package/files/utils.js +25 -0
- package/files/wire.js +270 -0
- package/index.d.ts +65 -0
- package/package.json +2 -2
- package/plugins/cursor/client.js +20 -1
- package/plugins/cursor/codec.js +410 -0
- package/plugins/cursor/server.d.ts +38 -0
- package/plugins/cursor/server.js +89 -6
- package/testing.js +205 -4
- package/vite.js +14 -0
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
|
-
//
|
|
997
|
-
//
|
|
998
|
-
|
|
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()
|
|
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
|