svelte-adapter-uws 0.5.7 → 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 +10 -0
- package/client.d.ts +22 -0
- package/client.js +130 -4
- package/files/handler.js +184 -7
- package/files/utils.js +12 -0
- package/files/wire.js +270 -0
- package/index.d.ts +52 -0
- package/package.json +2 -2
- package/plugins/cursor/client.js +10 -1
- package/plugins/cursor/codec.js +199 -0
- package/plugins/cursor/server.d.ts +17 -0
- package/plugins/cursor/server.js +50 -6
- package/plugins/presence/server.js +34 -12
- package/testing.js +105 -2
- package/vite.js +14 -0
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
|
-
//
|
|
997
|
-
//
|
|
998
|
-
|
|
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 */
|
|
@@ -366,12 +367,24 @@ const app = is_tls
|
|
|
366
367
|
: uWS.App();
|
|
367
368
|
|
|
368
369
|
// - Cross-worker pub/sub relay (batched) ------------------------------------
|
|
369
|
-
// Batch postMessage calls within a single
|
|
370
|
-
// publishes N events sends one structured-clone across the thread
|
|
371
|
-
// instead of N. No-op in single-process mode (parentPort is null).
|
|
370
|
+
// Batch postMessage calls within a single event-loop iteration. A SvelteKit
|
|
371
|
+
// action that publishes N events sends one structured-clone across the thread
|
|
372
|
+
// boundary instead of N. No-op in single-process mode (parentPort is null).
|
|
373
|
+
//
|
|
374
|
+
// Why `setTimeout(0)` and not `queueMicrotask`: uWS dispatches each WS message
|
|
375
|
+
// as its own JS task, and N-API drains microtasks at the C++/JS boundary
|
|
376
|
+
// between tasks. A microtask-deferred flush fires BEFORE the next socket's
|
|
377
|
+
// handler runs, so cross-socket coalescing is impossible at the microtask
|
|
378
|
+
// level - N publishes from N socket handlers in the same iteration produce N
|
|
379
|
+
// postMessage structured-clones instead of one batched. `setTimeout(0)` lands
|
|
380
|
+
// in libuv's timers phase, which fires only after the poll phase has
|
|
381
|
+
// dispatched every ready socket message in the current iteration. Same
|
|
382
|
+
// structural choice the 0.5.6 cursor always-tick rewrite locked in.
|
|
372
383
|
|
|
373
384
|
/** @type {Array<{topic: string, envelope: string}> | null} */
|
|
374
385
|
let relayBatch = null;
|
|
386
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
387
|
+
let relayTimer = null;
|
|
375
388
|
|
|
376
389
|
/**
|
|
377
390
|
* @param {string} topic
|
|
@@ -380,12 +393,14 @@ let relayBatch = null;
|
|
|
380
393
|
function batchRelay(topic, envelope) {
|
|
381
394
|
if (!relayBatch) {
|
|
382
395
|
relayBatch = [];
|
|
383
|
-
|
|
396
|
+
relayTimer = setTimeout(() => {
|
|
397
|
+
relayTimer = null;
|
|
384
398
|
if (relayBatch) {
|
|
385
399
|
parentPort.postMessage({ type: 'publish-batch', messages: relayBatch });
|
|
386
400
|
}
|
|
387
401
|
relayBatch = null;
|
|
388
|
-
});
|
|
402
|
+
}, 0);
|
|
403
|
+
if (relayTimer.unref) relayTimer.unref();
|
|
389
404
|
}
|
|
390
405
|
relayBatch.push({ topic, envelope });
|
|
391
406
|
}
|
|
@@ -898,6 +913,40 @@ function flushCoalescedFor(ws) {
|
|
|
898
913
|
if (aborted) pending.clear();
|
|
899
914
|
}
|
|
900
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
|
+
|
|
901
950
|
/** @type {import('./index.js').Platform} */
|
|
902
951
|
const platform = {
|
|
903
952
|
/**
|
|
@@ -965,6 +1014,126 @@ const platform = {
|
|
|
965
1014
|
return result;
|
|
966
1015
|
},
|
|
967
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
|
+
|
|
968
1137
|
/**
|
|
969
1138
|
* Send a message to a single connection with coalesce-by-key semantics.
|
|
970
1139
|
*
|
|
@@ -3307,7 +3476,12 @@ if (WS_ENABLED) {
|
|
|
3307
3476
|
for (let i = 0; i < msg.caps.length; i++) {
|
|
3308
3477
|
if (typeof msg.caps[i] === 'string') caps.add(msg.caps[i]);
|
|
3309
3478
|
}
|
|
3310
|
-
ws.getUserData()
|
|
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;
|
|
3311
3485
|
if (wsDebug) console.log('[ws] hello caps=%o', [...caps]);
|
|
3312
3486
|
return;
|
|
3313
3487
|
}
|
|
@@ -3398,6 +3572,9 @@ if (WS_ENABLED) {
|
|
|
3398
3572
|
} finally {
|
|
3399
3573
|
totalSubscriptions -= subscriptions.size;
|
|
3400
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);
|
|
3401
3578
|
wsConnections.delete(ws);
|
|
3402
3579
|
if (wsDebug) console.log('[ws] close code=%d connections=%d', code, wsConnections.size);
|
|
3403
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
|