svelte-adapter-uws 0.5.8 → 0.6.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/client.d.ts +22 -0
- package/client.js +130 -4
- package/files/handler.js +165 -2
- 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/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 */
|
|
@@ -912,6 +913,40 @@ function flushCoalescedFor(ws) {
|
|
|
912
913
|
if (aborted) pending.clear();
|
|
913
914
|
}
|
|
914
915
|
|
|
916
|
+
// - Binary wire (0x03) capability accounting + topic-id assignment ----------
|
|
917
|
+
// A connection opts into a binary plugin codec by advertising the codec's
|
|
918
|
+
// capability token in its `hello` frame (stored in WS_CAPS). capCounts tracks
|
|
919
|
+
// how many live connections advertised each token, so platform.publishWire
|
|
920
|
+
// takes a zero-cost JSON fast path when no connected client wants binary for a
|
|
921
|
+
// codec - a JSON-only deployment never enters the per-subscriber walk. The
|
|
922
|
+
// id-allocation, announce-frame, and cap-counting primitives are shared with
|
|
923
|
+
// the test / dev platforms via ./wire.js so the id space is identical.
|
|
924
|
+
const capCounts = createCapCounts();
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Resolve (allocating on first use) the per-connection binary topic-id for a
|
|
928
|
+
* topic. On a fresh assignment, announce the `name -> id` mapping to the
|
|
929
|
+
* client in a `{type:'wire-id'}` control frame so an inbound `0x03` frame's
|
|
930
|
+
* numeric id resolves back to the topic name. The announce rides the same
|
|
931
|
+
* socket immediately before the first binary frame for the topic, so ordering
|
|
932
|
+
* guarantees the client records the mapping first - no ack/frame race.
|
|
933
|
+
* Per-connection and reset on reconnect (a reconnect is a new connection with
|
|
934
|
+
* fresh userData).
|
|
935
|
+
* @param {import('uWebSockets.js').WebSocket<any>} ws
|
|
936
|
+
* @param {any} ud - ws.getUserData()
|
|
937
|
+
* @param {string} topic
|
|
938
|
+
* @returns {number}
|
|
939
|
+
*/
|
|
940
|
+
function ensureWireId(ws, ud, topic) {
|
|
941
|
+
const { id, isNew } = allocWireId(ud, WS_TOPIC_IDS, topic);
|
|
942
|
+
if (isNew) {
|
|
943
|
+
const announce = wireIdAnnounce(topic, id);
|
|
944
|
+
try { ws.send(announce, false, false); } catch { closedWsAborts++; return id; }
|
|
945
|
+
bumpOut(ws, announce);
|
|
946
|
+
}
|
|
947
|
+
return id;
|
|
948
|
+
}
|
|
949
|
+
|
|
915
950
|
/** @type {import('./index.js').Platform} */
|
|
916
951
|
const platform = {
|
|
917
952
|
/**
|
|
@@ -979,6 +1014,126 @@ const platform = {
|
|
|
979
1014
|
return result;
|
|
980
1015
|
},
|
|
981
1016
|
|
|
1017
|
+
/**
|
|
1018
|
+
* Publish via a plugin-declared binary wire codec. Binary-capable
|
|
1019
|
+
* subscribers (those that advertised `wire.capability`) receive a `0x03`
|
|
1020
|
+
* frame; everyone else receives the identical JSON envelope `publish()`
|
|
1021
|
+
* would have sent. When no connected client advertises the capability - or
|
|
1022
|
+
* the codec declines this frame (`encode` returns null) - this takes the
|
|
1023
|
+
* exact single `app.publish` JSON fan-out with no per-subscriber walk, so a
|
|
1024
|
+
* JSON-only deployment pays nothing for the binary machinery.
|
|
1025
|
+
*
|
|
1026
|
+
* The framework owns the `0x03 | schemaVersion | topicId | seq | payload`
|
|
1027
|
+
* envelope; the plugin's `encode` produces only the payload. seq is stamped
|
|
1028
|
+
* once and carried in both the JSON and binary forms so resume keeps working.
|
|
1029
|
+
*
|
|
1030
|
+
* @param {string} topic
|
|
1031
|
+
* @param {string} event
|
|
1032
|
+
* @param {any} data
|
|
1033
|
+
* @param {{ capability: string, schemaVersion: number, encode: (event: string, data: any) => (Uint8Array | null) }} wire
|
|
1034
|
+
* @param {{ seq?: boolean, relay?: boolean }} [options]
|
|
1035
|
+
* @returns {boolean}
|
|
1036
|
+
*/
|
|
1037
|
+
publishWire(topic, event, data, wire, options) {
|
|
1038
|
+
publishCountWindow++;
|
|
1039
|
+
const seq = (options && options.seq === false)
|
|
1040
|
+
? null
|
|
1041
|
+
: nextTopicSeq(topicSeqs, topic);
|
|
1042
|
+
const envelope = completeEnvelope(envelopePrefix(topic, event), data, seq);
|
|
1043
|
+
assert(envelope.length > 0, 'envelope.empty', { topic, event });
|
|
1044
|
+
let s = topicPublishStats.get(topic);
|
|
1045
|
+
if (!s) {
|
|
1046
|
+
s = { m: 0, b: 0 };
|
|
1047
|
+
topicPublishStats.set(topic, s);
|
|
1048
|
+
maybeWarnTopicRegistry();
|
|
1049
|
+
} else {
|
|
1050
|
+
assert(typeof s.m === 'number' && typeof s.b === 'number', 'topic.stats-shape', { topic });
|
|
1051
|
+
}
|
|
1052
|
+
s.m++;
|
|
1053
|
+
s.b += envelope.length;
|
|
1054
|
+
|
|
1055
|
+
// Encode once - but only when at least one live connection wants binary
|
|
1056
|
+
// for this codec. A null payload (no capable client, or the codec
|
|
1057
|
+
// declined this frame) falls through to the single C++ app.publish
|
|
1058
|
+
// fan-out, byte- and instruction-identical to platform.publish.
|
|
1059
|
+
const payload = capCounts.has(wire.capability) ? wire.encode(event, data) : null;
|
|
1060
|
+
const relayed = !!(parentPort && (!options || options.relay !== false));
|
|
1061
|
+
if (payload == null) {
|
|
1062
|
+
const result = app.publish(topic, envelope, false, false);
|
|
1063
|
+
if (relayed) batchRelay(topic, envelope);
|
|
1064
|
+
return result || relayed;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Mixed-capability fan-out: app.publish cannot vary payload per
|
|
1068
|
+
// recipient, so walk the topic's subscribers and send each the form it
|
|
1069
|
+
// negotiated. The codec payload is shared across recipients; only the
|
|
1070
|
+
// tiny per-connection frame header (topic-id + seq) differs, memoized
|
|
1071
|
+
// per distinct id so the common all-same-id case builds one frame and
|
|
1072
|
+
// reuses it for every binary send.
|
|
1073
|
+
const seqOnWire = seq == null ? 0 : seq;
|
|
1074
|
+
/** @type {Map<number, Uint8Array>} */
|
|
1075
|
+
const frameById = new Map();
|
|
1076
|
+
for (const ws of wsConnections) {
|
|
1077
|
+
let ud;
|
|
1078
|
+
try { ud = ws.getUserData(); } catch { continue; }
|
|
1079
|
+
const subs = ud[WS_SUBSCRIPTIONS];
|
|
1080
|
+
if (!subs || !subs.has(topic)) continue;
|
|
1081
|
+
const caps = ud[WS_CAPS];
|
|
1082
|
+
if (caps && caps.has(wire.capability)) {
|
|
1083
|
+
const id = ensureWireId(ws, ud, topic);
|
|
1084
|
+
let frame = frameById.get(id);
|
|
1085
|
+
if (!frame) {
|
|
1086
|
+
frame = buildBinaryFrame(wire.schemaVersion, id, seqOnWire, payload);
|
|
1087
|
+
frameById.set(id, frame);
|
|
1088
|
+
}
|
|
1089
|
+
try { ws.send(frame, true, false); } catch { closedWsAborts++; }
|
|
1090
|
+
} else {
|
|
1091
|
+
try { ws.send(envelope, false, false); } catch { closedWsAborts++; }
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
// Cross-worker subscribers receive the JSON envelope (binary is
|
|
1095
|
+
// same-worker only); their worker re-publishes it to them as JSON.
|
|
1096
|
+
if (relayed) batchRelay(topic, envelope);
|
|
1097
|
+
if (wsDebug) {
|
|
1098
|
+
console.log('[ws] publishWire topic=%s event=%s payloadBytes=%d', topic, event, payload.length);
|
|
1099
|
+
}
|
|
1100
|
+
return true;
|
|
1101
|
+
},
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* Single-target send via a plugin-declared binary wire codec. The target
|
|
1105
|
+
* receives a `0x03` frame when it advertised `wire.capability` and the
|
|
1106
|
+
* codec can encode this frame; otherwise it receives the JSON envelope
|
|
1107
|
+
* `send()` would have sent. No seq is stamped (matches `send()`); the
|
|
1108
|
+
* binary frame carries seq 0 ("no seq"). Used for snapshot/catalog frames.
|
|
1109
|
+
*
|
|
1110
|
+
* @param {import('uWebSockets.js').WebSocket<any>} ws
|
|
1111
|
+
* @param {string} topic
|
|
1112
|
+
* @param {string} event
|
|
1113
|
+
* @param {any} data
|
|
1114
|
+
* @param {{ capability: string, schemaVersion: number, encode: (event: string, data: any) => (Uint8Array | null) }} wire
|
|
1115
|
+
* @returns {number} uWS send status (0/1/2), or 2 on a freed handle
|
|
1116
|
+
*/
|
|
1117
|
+
sendWire(ws, topic, event, data, wire) {
|
|
1118
|
+
let ud;
|
|
1119
|
+
try { ud = ws.getUserData(); } catch { closedWsAborts++; return 2; }
|
|
1120
|
+
const caps = ud[WS_CAPS];
|
|
1121
|
+
const payload = (caps && caps.has(wire.capability)) ? wire.encode(event, data) : null;
|
|
1122
|
+
if (payload == null) {
|
|
1123
|
+
const json = envelopePrefix(topic, event) + JSON.stringify(data ?? null) + '}';
|
|
1124
|
+
let result;
|
|
1125
|
+
try { result = ws.send(json, false, false); } catch { closedWsAborts++; return 2; }
|
|
1126
|
+
bumpOut(ws, json);
|
|
1127
|
+
return result;
|
|
1128
|
+
}
|
|
1129
|
+
const id = ensureWireId(ws, ud, topic);
|
|
1130
|
+
const frame = buildBinaryFrame(wire.schemaVersion, id, 0, payload);
|
|
1131
|
+
let result;
|
|
1132
|
+
try { result = ws.send(frame, true, false); } catch { closedWsAborts++; return 2; }
|
|
1133
|
+
bumpOut(ws, frame);
|
|
1134
|
+
return result;
|
|
1135
|
+
},
|
|
1136
|
+
|
|
982
1137
|
/**
|
|
983
1138
|
* Send a message to a single connection with coalesce-by-key semantics.
|
|
984
1139
|
*
|
|
@@ -3321,7 +3476,12 @@ if (WS_ENABLED) {
|
|
|
3321
3476
|
for (let i = 0; i < msg.caps.length; i++) {
|
|
3322
3477
|
if (typeof msg.caps[i] === 'string') caps.add(msg.caps[i]);
|
|
3323
3478
|
}
|
|
3324
|
-
ws.getUserData()
|
|
3479
|
+
const ud = ws.getUserData();
|
|
3480
|
+
// Maintain the live per-capability connection counts so the
|
|
3481
|
+
// binary publish fast path knows whether any client wants
|
|
3482
|
+
// binary. A re-sent hello replaces the prior set; diff it.
|
|
3483
|
+
capCounts.adjust(ud[WS_CAPS], caps);
|
|
3484
|
+
ud[WS_CAPS] = caps;
|
|
3325
3485
|
if (wsDebug) console.log('[ws] hello caps=%o', [...caps]);
|
|
3326
3486
|
return;
|
|
3327
3487
|
}
|
|
@@ -3412,6 +3572,9 @@ if (WS_ENABLED) {
|
|
|
3412
3572
|
} finally {
|
|
3413
3573
|
totalSubscriptions -= subscriptions.size;
|
|
3414
3574
|
assert(totalSubscriptions >= 0, 'subs.total-negative', { totalSubscriptions });
|
|
3575
|
+
// Release this connection's advertised capabilities from the
|
|
3576
|
+
// live counts so the binary publish fast path stays accurate.
|
|
3577
|
+
capCounts.adjust(userData[WS_CAPS], null);
|
|
3415
3578
|
wsConnections.delete(ws);
|
|
3416
3579
|
if (wsDebug) console.log('[ws] close code=%d connections=%d', code, wsConnections.size);
|
|
3417
3580
|
}
|
package/files/utils.js
CHANGED
|
@@ -404,6 +404,18 @@ export const WS_PLATFORM = Symbol.for('adapter-uws.ws.platform');
|
|
|
404
404
|
*/
|
|
405
405
|
export const WS_CAPS = Symbol.for('adapter-uws.ws.caps');
|
|
406
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Per-connection binary wire-id allocation for `0x03` topic frames:
|
|
409
|
+
* `{ byName: Map<topicName, number>, next: number }`. Allocated lazily on the
|
|
410
|
+
* first binary publish to a connection (never for JSON-only connections, so
|
|
411
|
+
* the common case pays nothing). The id replaces the topic string on the wire;
|
|
412
|
+
* the server announces each `name -> id` assignment to the client in a
|
|
413
|
+
* `{type:'wire-id'}` control frame the first time it emits a binary frame for
|
|
414
|
+
* that topic. Per-connection and reset on reconnect - no cross-reconnect id
|
|
415
|
+
* stability and no server-side schema registry.
|
|
416
|
+
*/
|
|
417
|
+
export const WS_TOPIC_IDS = Symbol.for('adapter-uws.ws.topic-ids');
|
|
418
|
+
|
|
407
419
|
// - Bounded-by-default capacity caps ---------------------------------------
|
|
408
420
|
// Single source of truth for the per-connection and module-level Map / Set
|
|
409
421
|
// caps that handler.js, vite.js, and testing.js all enforce. The numbers
|
package/files/wire.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binary wire primitives for the `0x03` topic-PAYLOAD frame.
|
|
3
|
+
*
|
|
4
|
+
* The leading-byte demux on a WebSocket connection is:
|
|
5
|
+
* - `0x01` upload chunk (svelte-realtime layer, client -> server, never inbound here)
|
|
6
|
+
* - `0x02` upload cancel (svelte-realtime layer, client -> server)
|
|
7
|
+
* - `0x03` binary topic frame (this module, server -> client)
|
|
8
|
+
*
|
|
9
|
+
* The `0x03` frame envelope, owned by the framework (not the plugin codec):
|
|
10
|
+
*
|
|
11
|
+
* [0x03][schemaVersion:u8][topicId:varint][seq:varint][codec payload...]
|
|
12
|
+
*
|
|
13
|
+
* `topicId` is the per-connection short id assigned by the server and
|
|
14
|
+
* advertised to the client out-of-band (see the `wire-id` control frame).
|
|
15
|
+
* `seq` is the per-topic monotonic sequence (0 means "no seq", matching the
|
|
16
|
+
* seq-less single-target send path). The codec payload is opaque to the
|
|
17
|
+
* framework - a plugin's `wire.encode` produced it and that plugin's
|
|
18
|
+
* `wire.decode` consumes it.
|
|
19
|
+
*
|
|
20
|
+
* Integers use unsigned LEB128 varints. Division/multiplication (not bit
|
|
21
|
+
* shifts) carry values past 2^31 so a long-lived per-topic `seq` never wraps
|
|
22
|
+
* or corrupts. Floats use big-endian IEEE-754 single precision, matching the
|
|
23
|
+
* big-endian convention of the upload frame builders.
|
|
24
|
+
*
|
|
25
|
+
* @module svelte-adapter-uws/files/wire
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Leading byte of a binary topic-PAYLOAD frame. */
|
|
29
|
+
export const WIRE_BINARY_TAG = 0x03;
|
|
30
|
+
|
|
31
|
+
const ENC = new TextEncoder();
|
|
32
|
+
const DEC = new TextDecoder();
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Growable byte buffer with varint / float32 / length-prefixed-string writers.
|
|
36
|
+
* Backed by an ArrayBuffer that doubles on demand; `take()` returns an
|
|
37
|
+
* exact-length copy safe to retain, share, or hand to `ws.send`.
|
|
38
|
+
*/
|
|
39
|
+
export class ByteWriter {
|
|
40
|
+
constructor(initial = 64) {
|
|
41
|
+
this._ab = new ArrayBuffer(initial);
|
|
42
|
+
this._buf = new Uint8Array(this._ab);
|
|
43
|
+
this._view = new DataView(this._ab);
|
|
44
|
+
this.len = 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @param {number} need - additional bytes required */
|
|
48
|
+
_ensure(need) {
|
|
49
|
+
const want = this.len + need;
|
|
50
|
+
if (want <= this._buf.length) return;
|
|
51
|
+
let cap = this._buf.length * 2;
|
|
52
|
+
while (cap < want) cap *= 2;
|
|
53
|
+
const ab = new ArrayBuffer(cap);
|
|
54
|
+
const buf = new Uint8Array(ab);
|
|
55
|
+
buf.set(this._buf.subarray(0, this.len));
|
|
56
|
+
this._ab = ab;
|
|
57
|
+
this._buf = buf;
|
|
58
|
+
this._view = new DataView(ab);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Write one byte. @param {number} n */
|
|
62
|
+
u8(n) {
|
|
63
|
+
this._ensure(1);
|
|
64
|
+
this._buf[this.len++] = n & 0xff;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Write an unsigned LEB128 varint. @param {number} value - non-negative, < 2^53 */
|
|
68
|
+
varint(value) {
|
|
69
|
+
// Math (not >>>) so values above 2^31 stay correct - `seq` can exceed
|
|
70
|
+
// 32 bits on a long-lived high-throughput topic.
|
|
71
|
+
while (value > 0x7f) {
|
|
72
|
+
this.u8((value & 0x7f) | 0x80);
|
|
73
|
+
value = Math.floor(value / 128);
|
|
74
|
+
}
|
|
75
|
+
this.u8(value & 0x7f);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Write a big-endian float32. @param {number} n */
|
|
79
|
+
f32(n) {
|
|
80
|
+
this._ensure(4);
|
|
81
|
+
this._view.setFloat32(this.len, n, false);
|
|
82
|
+
this.len += 4;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Write a length-prefixed (varint byte length) UTF-8 string. @param {string} s */
|
|
86
|
+
str(s) {
|
|
87
|
+
const bytes = ENC.encode(s);
|
|
88
|
+
this.varint(bytes.length);
|
|
89
|
+
this._ensure(bytes.length);
|
|
90
|
+
this._buf.set(bytes, this.len);
|
|
91
|
+
this.len += bytes.length;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Append raw bytes. @param {Uint8Array} bytes */
|
|
95
|
+
bytes(bytes) {
|
|
96
|
+
this._ensure(bytes.length);
|
|
97
|
+
this._buf.set(bytes, this.len);
|
|
98
|
+
this.len += bytes.length;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @returns {Uint8Array} exact-length copy of the written bytes */
|
|
102
|
+
take() {
|
|
103
|
+
return this._buf.slice(0, this.len);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Sequential reader over a byte payload (typically a zero-copy subarray of an
|
|
109
|
+
* inbound frame). Symmetric to {@link ByteWriter}. A read past the end throws
|
|
110
|
+
* a RangeError, which callers turn into a dropped frame.
|
|
111
|
+
*/
|
|
112
|
+
export class ByteReader {
|
|
113
|
+
/** @param {Uint8Array} buf */
|
|
114
|
+
constructor(buf) {
|
|
115
|
+
this._buf = buf;
|
|
116
|
+
this._view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
117
|
+
this.pos = 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
get done() {
|
|
121
|
+
return this.pos >= this._buf.length;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** @returns {number} */
|
|
125
|
+
u8() {
|
|
126
|
+
if (this.pos >= this._buf.length) throw new RangeError('wire: read past end');
|
|
127
|
+
return this._buf[this.pos++];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @returns {number} */
|
|
131
|
+
varint() {
|
|
132
|
+
// Fast path: single-byte varint (the common case for lengths/counts).
|
|
133
|
+
const first = this._buf[this.pos];
|
|
134
|
+
if (first === undefined) throw new RangeError('wire: read past end');
|
|
135
|
+
if (first < 0x80) { this.pos++; return first; }
|
|
136
|
+
let result = 0;
|
|
137
|
+
let mul = 1;
|
|
138
|
+
let b;
|
|
139
|
+
do {
|
|
140
|
+
b = this._buf[this.pos++];
|
|
141
|
+
if (b === undefined) throw new RangeError('wire: read past end');
|
|
142
|
+
result += (b & 0x7f) * mul;
|
|
143
|
+
mul *= 128;
|
|
144
|
+
} while (b & 0x80);
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** @returns {number} big-endian float32 */
|
|
149
|
+
f32() {
|
|
150
|
+
if (this.pos + 4 > this._buf.length) throw new RangeError('wire: read past end');
|
|
151
|
+
const v = this._view.getFloat32(this.pos, false);
|
|
152
|
+
this.pos += 4;
|
|
153
|
+
return v;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** @returns {string} length-prefixed UTF-8 string */
|
|
157
|
+
str() {
|
|
158
|
+
const len = this.varint();
|
|
159
|
+
if (this.pos + len > this._buf.length) throw new RangeError('wire: read past end');
|
|
160
|
+
const s = DEC.decode(this._buf.subarray(this.pos, this.pos + len));
|
|
161
|
+
this.pos += len;
|
|
162
|
+
return s;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Build a complete `0x03` topic-PAYLOAD frame from a codec payload.
|
|
168
|
+
*
|
|
169
|
+
* @param {number} schemaVersion - 1-byte plugin codec schema version
|
|
170
|
+
* @param {number} topicId - per-connection short topic id
|
|
171
|
+
* @param {number} seq - per-topic monotonic seq, or 0 for "no seq"
|
|
172
|
+
* @param {Uint8Array} payload - bytes returned by the plugin's `wire.encode`
|
|
173
|
+
* @returns {Uint8Array}
|
|
174
|
+
*/
|
|
175
|
+
export function buildBinaryFrame(schemaVersion, topicId, seq, payload) {
|
|
176
|
+
const w = new ByteWriter(8 + payload.length);
|
|
177
|
+
w.u8(WIRE_BINARY_TAG);
|
|
178
|
+
w.u8(schemaVersion & 0xff);
|
|
179
|
+
w.varint(topicId);
|
|
180
|
+
w.varint(seq);
|
|
181
|
+
w.bytes(payload);
|
|
182
|
+
return w.take();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Parse the framework header of a `0x03` frame and return the header fields
|
|
187
|
+
* plus a zero-copy view of the codec payload.
|
|
188
|
+
*
|
|
189
|
+
* @param {Uint8Array} bytes - the full inbound binary frame
|
|
190
|
+
* @returns {{ schemaVersion: number, topicId: number, seq: number, payload: Uint8Array } | null}
|
|
191
|
+
* null when the frame is not a `0x03` frame or is truncated.
|
|
192
|
+
*/
|
|
193
|
+
export function parseBinaryFrame(bytes) {
|
|
194
|
+
if (bytes.length < 2 || bytes[0] !== WIRE_BINARY_TAG) return null;
|
|
195
|
+
try {
|
|
196
|
+
const r = new ByteReader(bytes);
|
|
197
|
+
r.u8(); // tag, already checked
|
|
198
|
+
const schemaVersion = r.u8();
|
|
199
|
+
const topicId = r.varint();
|
|
200
|
+
const seq = r.varint();
|
|
201
|
+
return { schemaVersion, topicId, seq, payload: bytes.subarray(r.pos) };
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Allocate (or return the existing) per-connection binary topic-id for a
|
|
209
|
+
* topic, managing the `WS_TOPIC_IDS` slot `{ byName, next }` on the userData
|
|
210
|
+
* object. Shared across the prod / test / dev platform implementations so the
|
|
211
|
+
* id space is identical in all three.
|
|
212
|
+
*
|
|
213
|
+
* @param {any} ud - ws.getUserData()
|
|
214
|
+
* @param {symbol} slotKey - the WS_TOPIC_IDS symbol
|
|
215
|
+
* @param {string} topic
|
|
216
|
+
* @returns {{ id: number, isNew: boolean }} isNew is true on first allocation
|
|
217
|
+
*/
|
|
218
|
+
export function allocWireId(ud, slotKey, topic) {
|
|
219
|
+
let slot = ud[slotKey];
|
|
220
|
+
if (!slot) {
|
|
221
|
+
slot = { byName: new Map(), next: 1 };
|
|
222
|
+
ud[slotKey] = slot;
|
|
223
|
+
}
|
|
224
|
+
const existing = slot.byName.get(topic);
|
|
225
|
+
if (existing !== undefined) return { id: existing, isNew: false };
|
|
226
|
+
const id = slot.next++;
|
|
227
|
+
slot.byName.set(topic, id);
|
|
228
|
+
return { id, isNew: true };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Build the `{type:'wire-id'}` control frame announcing which numeric topic-id
|
|
233
|
+
* maps to which topic name, so an inbound `0x03` frame resolves to a topic.
|
|
234
|
+
* @param {string} topic
|
|
235
|
+
* @param {number} id
|
|
236
|
+
* @returns {string}
|
|
237
|
+
*/
|
|
238
|
+
export function wireIdAnnounce(topic, id) {
|
|
239
|
+
return '{"type":"wire-id","topic":' + JSON.stringify(topic) + ',"id":' + id + '}';
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Per-entry-point live capability accounting: how many connections have
|
|
244
|
+
* advertised each capability token. Lets the binary publish path skip the
|
|
245
|
+
* per-subscriber walk entirely when no connected client wants binary for a
|
|
246
|
+
* codec (a JSON-only deployment pays nothing).
|
|
247
|
+
*
|
|
248
|
+
* @returns {{ has(cap: string): boolean, adjust(prev: Set<string>|null|undefined, next: Set<string>|null|undefined): void }}
|
|
249
|
+
*/
|
|
250
|
+
export function createCapCounts() {
|
|
251
|
+
/** @type {Map<string, number>} */
|
|
252
|
+
const counts = new Map();
|
|
253
|
+
return {
|
|
254
|
+
has(cap) {
|
|
255
|
+
return (counts.get(cap) || 0) > 0;
|
|
256
|
+
},
|
|
257
|
+
adjust(prev, next) {
|
|
258
|
+
if (prev) {
|
|
259
|
+
for (const c of prev) {
|
|
260
|
+
const n = (counts.get(c) || 0) - 1;
|
|
261
|
+
if (n > 0) counts.set(c, n);
|
|
262
|
+
else counts.delete(c);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (next) {
|
|
266
|
+
for (const c of next) counts.set(c, (counts.get(c) || 0) + 1);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
}
|
package/index.d.ts
CHANGED
|
@@ -1062,6 +1062,58 @@ export interface Platform {
|
|
|
1062
1062
|
*/
|
|
1063
1063
|
publish(topic: string, event: string, data?: unknown, options?: { relay?: boolean; seq?: boolean }): boolean;
|
|
1064
1064
|
|
|
1065
|
+
/**
|
|
1066
|
+
* Publish via a plugin-declared binary wire codec. Subscribers that
|
|
1067
|
+
* advertised `wire.capability` in their `hello` frame receive a compact
|
|
1068
|
+
* binary `0x03` frame; everyone else receives the identical JSON envelope
|
|
1069
|
+
* `publish()` would have sent. App authors never call this - it is the
|
|
1070
|
+
* plugin-author surface for a high-throughput topic family (the cursor
|
|
1071
|
+
* plugin is the first beneficiary, gated by `cursor.protocol:2`).
|
|
1072
|
+
*
|
|
1073
|
+
* Zero-cost when no connected client wants binary: this takes the same
|
|
1074
|
+
* single `app.publish` fan-out as `publish()`. The codec's `encode` may
|
|
1075
|
+
* return `null` for a frame it cannot represent, which transparently falls
|
|
1076
|
+
* back to JSON for that one frame.
|
|
1077
|
+
*
|
|
1078
|
+
* @param topic - Topic string
|
|
1079
|
+
* @param event - Event name (the codec maps it to an opcode)
|
|
1080
|
+
* @param data - Payload (passed to `wire.encode`, or JSON-serialized on fallback)
|
|
1081
|
+
* @param wire - The plugin's wire codec: a negotiated `capability` token, a
|
|
1082
|
+
* 1-byte `schemaVersion`, and an `encode(event, data)` returning the
|
|
1083
|
+
* payload bytes or `null` to fall back to JSON for this frame.
|
|
1084
|
+
* @param options - Same `relay` / `seq` semantics as `publish()`.
|
|
1085
|
+
*/
|
|
1086
|
+
publishWire(
|
|
1087
|
+
topic: string,
|
|
1088
|
+
event: string,
|
|
1089
|
+
data: unknown,
|
|
1090
|
+
wire: {
|
|
1091
|
+
capability: string;
|
|
1092
|
+
schemaVersion: number;
|
|
1093
|
+
encode: (event: string, data: unknown) => Uint8Array | null;
|
|
1094
|
+
},
|
|
1095
|
+
options?: { relay?: boolean; seq?: boolean }
|
|
1096
|
+
): boolean;
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Single-target counterpart to `publishWire()`. The connection receives a
|
|
1100
|
+
* binary `0x03` frame when it advertised `wire.capability` and the codec
|
|
1101
|
+
* can encode the frame; otherwise the JSON envelope `send()` would have
|
|
1102
|
+
* sent. No per-topic seq is stamped (matches `send()`). Used for
|
|
1103
|
+
* snapshot/catalog frames to a single late-joining subscriber.
|
|
1104
|
+
*/
|
|
1105
|
+
sendWire(
|
|
1106
|
+
ws: WebSocket<any>,
|
|
1107
|
+
topic: string,
|
|
1108
|
+
event: string,
|
|
1109
|
+
data: unknown,
|
|
1110
|
+
wire: {
|
|
1111
|
+
capability: string;
|
|
1112
|
+
schemaVersion: number;
|
|
1113
|
+
encode: (event: string, data: unknown) => Uint8Array | null;
|
|
1114
|
+
}
|
|
1115
|
+
): number;
|
|
1116
|
+
|
|
1065
1117
|
/**
|
|
1066
1118
|
* Publish multiple messages, returning per-message delivery results.
|
|
1067
1119
|
*
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svelte-adapter-uws",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0-next.1",
|
|
4
4
|
"publishConfig": {
|
|
5
|
-
"tag": "
|
|
5
|
+
"tag": "next"
|
|
6
6
|
},
|
|
7
7
|
"description": "SvelteKit adapter for uWebSockets.js - high-performance C++ HTTP server with built-in WebSocket support",
|
|
8
8
|
"author": "Kevin Radziszewski",
|
package/plugins/cursor/client.js
CHANGED
|
@@ -28,8 +28,17 @@
|
|
|
28
28
|
|
|
29
29
|
const TOPIC_PREFIX = '__cursor:';
|
|
30
30
|
|
|
31
|
-
import { on, connect, status } from '../../client.js';
|
|
31
|
+
import { on, connect, status, registerWireCodec } from '../../client.js';
|
|
32
32
|
import { writable } from 'svelte/store';
|
|
33
|
+
import { decodeCursor, CURSOR_CAPABILITY } from './codec.js';
|
|
34
|
+
|
|
35
|
+
// Opt this connection into binary cursor frames: advertise the capability in
|
|
36
|
+
// the `hello` frame and route inbound `0x03` frames on `__cursor:` topics
|
|
37
|
+
// through the cursor decoder, which yields the identical { event, data } the
|
|
38
|
+
// JSON path produced - so the store merge logic below is untouched. Registered
|
|
39
|
+
// at module load so the first `hello` already carries the capability. Fully
|
|
40
|
+
// transparent: nothing in the cursor() store knows whether a frame was binary.
|
|
41
|
+
registerWireCodec(TOPIC_PREFIX, { capability: CURSOR_CAPABILITY, decode: decodeCursor });
|
|
33
42
|
|
|
34
43
|
/** @type {Map<string, ReturnType<typeof cursor>>} */
|
|
35
44
|
const cursorStores = new Map();
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binary wire codec for the cursor plugin.
|
|
3
|
+
*
|
|
4
|
+
* Produces / consumes the `codec payload` that rides inside the framework's
|
|
5
|
+
* `0x03` topic frame (see files/wire.js for the frame envelope). The payload
|
|
6
|
+
* is `[op:u8][op-specific...]`, one op per cursor wire event:
|
|
7
|
+
*
|
|
8
|
+
* UPDATE [op][key][x:f32][y:f32]
|
|
9
|
+
* BULK [op][count:varint]({key}{x:f32}{y:f32})*
|
|
10
|
+
* REMOVE [op][key]
|
|
11
|
+
* JOIN [op][key][userJson]
|
|
12
|
+
* CATALOG [op][count:varint]({key}{userJson})*
|
|
13
|
+
*
|
|
14
|
+
* where `key` and `userJson` are length-prefixed UTF-8 strings.
|
|
15
|
+
*
|
|
16
|
+
* Design notes (why the encoding is shaped this way, recorded so the choices
|
|
17
|
+
* are legible):
|
|
18
|
+
*
|
|
19
|
+
* - The key is a length-prefixed UTF-8 string, NOT 16 raw UUID bytes. Cursor
|
|
20
|
+
* keys are server-assigned connection ids - `"42"` in-process,
|
|
21
|
+
* `"<instanceId>:42"` in the Redis-backed variant - never UUIDs, and the
|
|
22
|
+
* same client decoder serves both backends. A length-prefixed string is
|
|
23
|
+
* correct for any key shape.
|
|
24
|
+
* - Positions are big-endian float32, NOT i16. Real cursor positions are
|
|
25
|
+
* fractional doubles (e.g. `clientX - getBoundingClientRect().left`), so an
|
|
26
|
+
* integer-only schema would fall back to JSON for essentially every frame.
|
|
27
|
+
* float32 precision (sub-0.01 px at screen scale) is imperceptible for an
|
|
28
|
+
* ephemeral cursor.
|
|
29
|
+
*
|
|
30
|
+
* The encoder returns `null` for any frame it cannot represent (data that is
|
|
31
|
+
* not exactly `{x, y}`-numeric, a non-string key, or a `user` that will not
|
|
32
|
+
* JSON-serialize). A `null` return tells the framework to send JSON for that
|
|
33
|
+
* one frame, so apps that put richer data on the cursor channel keep working.
|
|
34
|
+
*
|
|
35
|
+
* @module svelte-adapter-uws/plugins/cursor/codec
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { ByteWriter, ByteReader } from '../../files/wire.js';
|
|
39
|
+
|
|
40
|
+
/** Negotiated capability token. Bumped to `:3` only for an incompatible schema. */
|
|
41
|
+
export const CURSOR_CAPABILITY = 'cursor.protocol:2';
|
|
42
|
+
|
|
43
|
+
/** 1-byte in-frame schema version (the fine gate within the capability). */
|
|
44
|
+
export const CURSOR_SCHEMA_VERSION = 1;
|
|
45
|
+
|
|
46
|
+
const OP_UPDATE = 1;
|
|
47
|
+
const OP_BULK = 2;
|
|
48
|
+
const OP_REMOVE = 3;
|
|
49
|
+
const OP_JOIN = 4;
|
|
50
|
+
const OP_CATALOG = 5;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* True when `d` is exactly a `{ x, y }` pair of finite numbers and nothing
|
|
54
|
+
* else - the only shape the binary position encoding is lossless for. Extra
|
|
55
|
+
* fields or non-numeric coords fall back to JSON so no data is silently lost.
|
|
56
|
+
* @param {any} d
|
|
57
|
+
*/
|
|
58
|
+
function isXY(d) {
|
|
59
|
+
if (d === null || typeof d !== 'object') return false;
|
|
60
|
+
if (typeof d.x !== 'number' || !Number.isFinite(d.x)) return false;
|
|
61
|
+
if (typeof d.y !== 'number' || !Number.isFinite(d.y)) return false;
|
|
62
|
+
// Reject anything carrying fields beyond x/y so they are not dropped.
|
|
63
|
+
for (const k in d) {
|
|
64
|
+
if (k !== 'x' && k !== 'y') return false;
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Encode a cursor wire event into a codec payload.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} event - one of 'update' | 'bulk' | 'remove' | 'join' | 'catalog'
|
|
73
|
+
* @param {any} data - the same value `platform.publish`/`send` would carry
|
|
74
|
+
* @returns {Uint8Array | null} payload bytes, or null to fall back to JSON
|
|
75
|
+
*/
|
|
76
|
+
export function encodeCursor(event, data) {
|
|
77
|
+
try {
|
|
78
|
+
switch (event) {
|
|
79
|
+
case 'update': {
|
|
80
|
+
if (!data || typeof data.key !== 'string' || !isXY(data.data)) return null;
|
|
81
|
+
const w = new ByteWriter(24);
|
|
82
|
+
w.u8(OP_UPDATE);
|
|
83
|
+
w.str(data.key);
|
|
84
|
+
w.f32(data.data.x);
|
|
85
|
+
w.f32(data.data.y);
|
|
86
|
+
return w.take();
|
|
87
|
+
}
|
|
88
|
+
case 'bulk': {
|
|
89
|
+
if (!Array.isArray(data)) return null;
|
|
90
|
+
for (let i = 0; i < data.length; i++) {
|
|
91
|
+
const e = data[i];
|
|
92
|
+
if (!e || typeof e.key !== 'string' || !isXY(e.data)) return null;
|
|
93
|
+
}
|
|
94
|
+
const w = new ByteWriter(16 + data.length * 16);
|
|
95
|
+
w.u8(OP_BULK);
|
|
96
|
+
w.varint(data.length);
|
|
97
|
+
for (let i = 0; i < data.length; i++) {
|
|
98
|
+
const e = data[i];
|
|
99
|
+
w.str(e.key);
|
|
100
|
+
w.f32(e.data.x);
|
|
101
|
+
w.f32(e.data.y);
|
|
102
|
+
}
|
|
103
|
+
return w.take();
|
|
104
|
+
}
|
|
105
|
+
case 'remove': {
|
|
106
|
+
if (!data || typeof data.key !== 'string') return null;
|
|
107
|
+
const w = new ByteWriter(16);
|
|
108
|
+
w.u8(OP_REMOVE);
|
|
109
|
+
w.str(data.key);
|
|
110
|
+
return w.take();
|
|
111
|
+
}
|
|
112
|
+
case 'join': {
|
|
113
|
+
if (!data || typeof data.key !== 'string') return null;
|
|
114
|
+
const w = new ByteWriter(32);
|
|
115
|
+
w.u8(OP_JOIN);
|
|
116
|
+
w.str(data.key);
|
|
117
|
+
w.str(JSON.stringify(data.user ?? null));
|
|
118
|
+
return w.take();
|
|
119
|
+
}
|
|
120
|
+
case 'catalog': {
|
|
121
|
+
if (!Array.isArray(data)) return null;
|
|
122
|
+
const w = new ByteWriter(16 + data.length * 24);
|
|
123
|
+
w.u8(OP_CATALOG);
|
|
124
|
+
w.varint(data.length);
|
|
125
|
+
for (let i = 0; i < data.length; i++) {
|
|
126
|
+
const e = data[i];
|
|
127
|
+
if (!e || typeof e.key !== 'string') return null;
|
|
128
|
+
w.str(e.key);
|
|
129
|
+
w.str(JSON.stringify(e.user ?? null));
|
|
130
|
+
}
|
|
131
|
+
return w.take();
|
|
132
|
+
}
|
|
133
|
+
default:
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// Any encode failure (e.g. a `user` value that will not JSON-serialize)
|
|
138
|
+
// falls back to JSON for this frame rather than throwing into publish.
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Decode a cursor codec payload back into the `{ event, data }` shape the
|
|
145
|
+
* JSON path would have dispatched. Returns null on an unknown opcode or a
|
|
146
|
+
* truncated / malformed frame (the frame is then dropped; cursor is
|
|
147
|
+
* best-effort).
|
|
148
|
+
*
|
|
149
|
+
* @param {Uint8Array} payload - codec bytes (frame header already stripped)
|
|
150
|
+
* @returns {{ event: string, data: any } | null}
|
|
151
|
+
*/
|
|
152
|
+
export function decodeCursor(payload) {
|
|
153
|
+
try {
|
|
154
|
+
const r = new ByteReader(payload);
|
|
155
|
+
const op = r.u8();
|
|
156
|
+
switch (op) {
|
|
157
|
+
case OP_UPDATE: {
|
|
158
|
+
const key = r.str();
|
|
159
|
+
const x = r.f32();
|
|
160
|
+
const y = r.f32();
|
|
161
|
+
return { event: 'update', data: { key, data: { x, y } } };
|
|
162
|
+
}
|
|
163
|
+
case OP_BULK: {
|
|
164
|
+
const count = r.varint();
|
|
165
|
+
const arr = new Array(count);
|
|
166
|
+
for (let i = 0; i < count; i++) {
|
|
167
|
+
const key = r.str();
|
|
168
|
+
const x = r.f32();
|
|
169
|
+
const y = r.f32();
|
|
170
|
+
arr[i] = { key, data: { x, y } };
|
|
171
|
+
}
|
|
172
|
+
return { event: 'bulk', data: arr };
|
|
173
|
+
}
|
|
174
|
+
case OP_REMOVE: {
|
|
175
|
+
const key = r.str();
|
|
176
|
+
return { event: 'remove', data: { key } };
|
|
177
|
+
}
|
|
178
|
+
case OP_JOIN: {
|
|
179
|
+
const key = r.str();
|
|
180
|
+
const user = JSON.parse(r.str());
|
|
181
|
+
return { event: 'join', data: { key, user } };
|
|
182
|
+
}
|
|
183
|
+
case OP_CATALOG: {
|
|
184
|
+
const count = r.varint();
|
|
185
|
+
const arr = new Array(count);
|
|
186
|
+
for (let i = 0; i < count; i++) {
|
|
187
|
+
const key = r.str();
|
|
188
|
+
const user = JSON.parse(r.str());
|
|
189
|
+
arr[i] = { key, user };
|
|
190
|
+
}
|
|
191
|
+
return { event: 'catalog', data: arr };
|
|
192
|
+
}
|
|
193
|
+
default:
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -89,6 +89,23 @@ export interface CursorOptions<UserData = unknown, UserInfo = unknown> {
|
|
|
89
89
|
* @default 8192 (8 KB)
|
|
90
90
|
*/
|
|
91
91
|
maxDataBytes?: number;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Binary wire transport. When `true` (the default), cursor frames are sent
|
|
95
|
+
* as compact binary `0x03` frames to clients that negotiated the
|
|
96
|
+
* `cursor.protocol:2` capability, and as JSON to everyone else - fully
|
|
97
|
+
* transparent, no app-code change, and a ~65-85% wire-size reduction on the
|
|
98
|
+
* position hot path. Set `false` to force JSON for every client (e.g. to
|
|
99
|
+
* keep DevTools' WS inspector readable). The wire format is the server's
|
|
100
|
+
* decision - clients never opt out via a URL parameter.
|
|
101
|
+
*
|
|
102
|
+
* Non-`{x, y}`-numeric cursor data (extra fields, non-numeric positions)
|
|
103
|
+
* transparently falls back to JSON per frame, so richer cursor payloads
|
|
104
|
+
* keep working regardless of this flag.
|
|
105
|
+
*
|
|
106
|
+
* @default true
|
|
107
|
+
*/
|
|
108
|
+
binary?: boolean;
|
|
92
109
|
}
|
|
93
110
|
|
|
94
111
|
export interface CursorEntry<UserInfo = unknown, Data = unknown> {
|
package/plugins/cursor/server.js
CHANGED
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
* @module svelte-adapter-uws/plugins/cursor
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
+
import { encodeCursor, CURSOR_CAPABILITY, CURSOR_SCHEMA_VERSION } from './codec.js';
|
|
40
|
+
|
|
39
41
|
const TOPIC_PREFIX = '__cursor:';
|
|
40
42
|
|
|
41
43
|
/** Wire-protocol event names. */
|
|
@@ -154,6 +156,14 @@ export function createCursor(options = {}) {
|
|
|
154
156
|
const maxTopicLength = options.maxTopicLength ?? 256;
|
|
155
157
|
const maxDataBytes = options.maxDataBytes ?? 8192;
|
|
156
158
|
|
|
159
|
+
// Binary wire is on by default and fully transparent: binary-capable
|
|
160
|
+
// clients receive compact `0x03` cursor frames, everyone else (and any
|
|
161
|
+
// platform without the publishWire/sendWire methods, e.g. the unit-test
|
|
162
|
+
// mock) receives the identical JSON frames. `binary: false` forces JSON.
|
|
163
|
+
const wireCodec = options.binary === false
|
|
164
|
+
? null
|
|
165
|
+
: { capability: CURSOR_CAPABILITY, schemaVersion: CURSOR_SCHEMA_VERSION, encode: encodeCursor };
|
|
166
|
+
|
|
157
167
|
if (typeof throttleMs !== 'number' || !Number.isFinite(throttleMs) || throttleMs < 0) {
|
|
158
168
|
throw new Error('cursor: throttle must be a non-negative number');
|
|
159
169
|
}
|
|
@@ -284,13 +294,47 @@ export function createCursor(options = {}) {
|
|
|
284
294
|
dirtyTopics.delete(topic);
|
|
285
295
|
}
|
|
286
296
|
|
|
297
|
+
/**
|
|
298
|
+
* Broadcast a cursor wire event. Routes through the binary `publishWire`
|
|
299
|
+
* path when a codec is configured AND the platform supports it (production /
|
|
300
|
+
* dev / test-server); otherwise falls back to the JSON `publish` - so the
|
|
301
|
+
* unit-test mock platform and `binary: false` both keep the exact JSON shape.
|
|
302
|
+
* @param {string} fullTopic - the channel name, already TOPIC_PREFIX-scoped
|
|
303
|
+
* @param {string} event
|
|
304
|
+
* @param {any} data
|
|
305
|
+
* @param {import('../../index.js').Platform} platform
|
|
306
|
+
*/
|
|
307
|
+
function emit(fullTopic, event, data, platform) {
|
|
308
|
+
if (wireCodec && typeof platform.publishWire === 'function') {
|
|
309
|
+
platform.publishWire(fullTopic, event, data, wireCodec);
|
|
310
|
+
} else {
|
|
311
|
+
platform.publish(fullTopic, event, data);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Single-target variant of {@link emit} (snapshot catalog + positions).
|
|
317
|
+
* @param {any} ws
|
|
318
|
+
* @param {string} fullTopic
|
|
319
|
+
* @param {string} event
|
|
320
|
+
* @param {any} data
|
|
321
|
+
* @param {import('../../index.js').Platform} platform
|
|
322
|
+
*/
|
|
323
|
+
function emitTo(ws, fullTopic, event, data, platform) {
|
|
324
|
+
if (wireCodec && typeof platform.sendWire === 'function') {
|
|
325
|
+
platform.sendWire(ws, fullTopic, event, data, wireCodec);
|
|
326
|
+
} else {
|
|
327
|
+
platform.send(ws, fullTopic, event, data);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
287
331
|
/**
|
|
288
332
|
* Emit `join` for a (ws, topic) pair the first time the ws moves on
|
|
289
333
|
* the topic. Broadcast (not single-target) so existing subscribers
|
|
290
334
|
* pick up the new user before any position frames arrive.
|
|
291
335
|
*/
|
|
292
336
|
function emitJoin(topic, key, user, platform) {
|
|
293
|
-
|
|
337
|
+
emit(TOPIC_PREFIX + topic, EVENTS.JOIN, { key, user }, platform);
|
|
294
338
|
}
|
|
295
339
|
|
|
296
340
|
/**
|
|
@@ -301,7 +345,7 @@ export function createCursor(options = {}) {
|
|
|
301
345
|
* @param {import('../../index.js').Platform} platform
|
|
302
346
|
*/
|
|
303
347
|
function doBroadcast(topic, key, data, platform) {
|
|
304
|
-
|
|
348
|
+
emit(TOPIC_PREFIX + topic, EVENTS.UPDATE, { key, data }, platform);
|
|
305
349
|
}
|
|
306
350
|
|
|
307
351
|
/**
|
|
@@ -325,7 +369,7 @@ export function createCursor(options = {}) {
|
|
|
325
369
|
flushPlatform = v.platform;
|
|
326
370
|
}
|
|
327
371
|
if (flushPlatform) {
|
|
328
|
-
|
|
372
|
+
emit(TOPIC_PREFIX + topic, EVENTS.BULK, entries, flushPlatform);
|
|
329
373
|
}
|
|
330
374
|
}
|
|
331
375
|
|
|
@@ -533,7 +577,7 @@ export function createCursor(options = {}) {
|
|
|
533
577
|
const flushState = topicFlush.get(topic);
|
|
534
578
|
if (flushState) flushState.dirty.delete(state.key);
|
|
535
579
|
}
|
|
536
|
-
|
|
580
|
+
emit(TOPIC_PREFIX + topic, EVENTS.REMOVE, { key: state.key }, platform);
|
|
537
581
|
}
|
|
538
582
|
}
|
|
539
583
|
|
|
@@ -561,8 +605,8 @@ export function createCursor(options = {}) {
|
|
|
561
605
|
positions.push({ key, data: entry.data });
|
|
562
606
|
}
|
|
563
607
|
}
|
|
564
|
-
|
|
565
|
-
|
|
608
|
+
emitTo(ws, TOPIC_PREFIX + topic, EVENTS.CATALOG, catalog, platform);
|
|
609
|
+
emitTo(ws, TOPIC_PREFIX + topic, EVENTS.BULK, positions, platform);
|
|
566
610
|
},
|
|
567
611
|
|
|
568
612
|
clear() {
|
package/testing.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { parseCookies } from './files/cookies.js';
|
|
3
|
-
import { nextTopicSeq, completeEnvelope, wrapBatchEnvelope, collapseByCoalesceKey, esc, isValidWireTopic, createScopedTopic, resolveRequestId, createChaosState, createUpgradeAdmission, readAssertionCounts, assert, WS_SUBSCRIPTIONS, WS_COALESCED, WS_SESSION_ID, WS_PENDING_REQUESTS, WS_STATS, WS_PLATFORM, WS_REQUEST_ID_KEY, WS_CAPS, MAX_SUBSCRIPTIONS_PER_CONNECTION, MAX_PENDING_REQUESTS_PER_CONNECTION } from './files/utils.js';
|
|
3
|
+
import { nextTopicSeq, completeEnvelope, wrapBatchEnvelope, collapseByCoalesceKey, esc, isValidWireTopic, createScopedTopic, resolveRequestId, createChaosState, createUpgradeAdmission, readAssertionCounts, assert, WS_SUBSCRIPTIONS, WS_COALESCED, WS_SESSION_ID, WS_PENDING_REQUESTS, WS_STATS, WS_PLATFORM, WS_REQUEST_ID_KEY, WS_CAPS, WS_TOPIC_IDS, MAX_SUBSCRIPTIONS_PER_CONNECTION, MAX_PENDING_REQUESTS_PER_CONNECTION } from './files/utils.js';
|
|
4
|
+
import { buildBinaryFrame, allocWireId, wireIdAnnounce, createCapCounts } from './files/wire.js';
|
|
4
5
|
|
|
5
6
|
// Curated re-exports for downstream test code (extensions, app-side
|
|
6
7
|
// integration tests, custom transport bridges that need to assert on
|
|
@@ -208,6 +209,50 @@ export async function createTestServer(options = {}) {
|
|
|
208
209
|
return result;
|
|
209
210
|
}
|
|
210
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Binary-frame variant of sendOutboundT (isBinary=true). Routes through the
|
|
214
|
+
* same chaos chokepoint so drop/slow-drain scenarios apply to `0x03` frames.
|
|
215
|
+
* @param {import('uWebSockets.js').WebSocket<any>} ws
|
|
216
|
+
* @param {Uint8Array} frame
|
|
217
|
+
*/
|
|
218
|
+
function sendOutboundBinaryT(ws, frame) {
|
|
219
|
+
if (chaos.shouldDropOutbound()) return 0;
|
|
220
|
+
const delay = chaos.getDelayMs();
|
|
221
|
+
if (delay > 0) {
|
|
222
|
+
setTimeout(() => {
|
|
223
|
+
try { ws.send(frame, true, false); }
|
|
224
|
+
catch { closedWsAbortsT++; return; }
|
|
225
|
+
bumpOutT(ws, frame);
|
|
226
|
+
}, delay);
|
|
227
|
+
return 1;
|
|
228
|
+
}
|
|
229
|
+
let result;
|
|
230
|
+
try { result = ws.send(frame, true, false); }
|
|
231
|
+
catch { closedWsAbortsT++; return 2; }
|
|
232
|
+
bumpOutT(ws, frame);
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Binary wire (0x03) capability accounting + topic-id assignment, mirroring
|
|
237
|
+
// production handler.js so the cap-gated binary publish path is exercised
|
|
238
|
+
// by createTestServer-based suites. Shared primitives live in ./files/wire.js.
|
|
239
|
+
const capCountsT = createCapCounts();
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Per-connection topic-id resolution + lazy `wire-id` announce. Binary
|
|
243
|
+
* frames and the announce flow through sendOutboundT so chaos scenarios
|
|
244
|
+
* apply to them too.
|
|
245
|
+
* @param {import('uWebSockets.js').WebSocket<any>} ws
|
|
246
|
+
* @param {any} ud
|
|
247
|
+
* @param {string} topic
|
|
248
|
+
* @returns {number}
|
|
249
|
+
*/
|
|
250
|
+
function ensureWireIdT(ws, ud, topic) {
|
|
251
|
+
const { id, isNew } = allocWireId(ud, WS_TOPIC_IDS, topic);
|
|
252
|
+
if (isNew) sendOutboundT(ws, wireIdAnnounce(topic, id));
|
|
253
|
+
return id;
|
|
254
|
+
}
|
|
255
|
+
|
|
211
256
|
const platform = {
|
|
212
257
|
publish(topic, event, data, options) {
|
|
213
258
|
const seq = (options && options.seq === false)
|
|
@@ -233,6 +278,61 @@ export async function createTestServer(options = {}) {
|
|
|
233
278
|
const payload = envelope(topic, event, data);
|
|
234
279
|
return sendOutboundT(ws, payload);
|
|
235
280
|
},
|
|
281
|
+
publishWire(topic, event, data, wire, options) {
|
|
282
|
+
const seq = (options && options.seq === false)
|
|
283
|
+
? null
|
|
284
|
+
: nextTopicSeq(topicSeqs, topic);
|
|
285
|
+
const env = envelope(topic, event, data, seq);
|
|
286
|
+
const payload = capCountsT.has(wire.capability) ? wire.encode(event, data) : null;
|
|
287
|
+
if (payload == null) {
|
|
288
|
+
// No capable client (or codec declined): single C++ fan-out,
|
|
289
|
+
// identical to platform.publish's fast path.
|
|
290
|
+
if (chaos.scenario === null) return app.publish(topic, env, false, false);
|
|
291
|
+
let delivered = false;
|
|
292
|
+
for (const ws of wsConnections) {
|
|
293
|
+
if (!ws.isSubscribed(topic)) continue;
|
|
294
|
+
sendOutboundT(ws, env);
|
|
295
|
+
delivered = true;
|
|
296
|
+
}
|
|
297
|
+
return delivered;
|
|
298
|
+
}
|
|
299
|
+
const seqOnWire = seq == null ? 0 : seq;
|
|
300
|
+
/** @type {Map<number, Uint8Array>} */
|
|
301
|
+
const frameById = new Map();
|
|
302
|
+
let delivered = false;
|
|
303
|
+
for (const ws of wsConnections) {
|
|
304
|
+
let ud;
|
|
305
|
+
try { ud = ws.getUserData(); } catch { continue; }
|
|
306
|
+
const subs = ud[WS_SUBSCRIPTIONS];
|
|
307
|
+
if (!subs || !subs.has(topic)) continue;
|
|
308
|
+
const caps = ud[WS_CAPS];
|
|
309
|
+
if (caps && caps.has(wire.capability)) {
|
|
310
|
+
const id = ensureWireIdT(ws, ud, topic);
|
|
311
|
+
let frame = frameById.get(id);
|
|
312
|
+
if (!frame) {
|
|
313
|
+
frame = buildBinaryFrame(wire.schemaVersion, id, seqOnWire, payload);
|
|
314
|
+
frameById.set(id, frame);
|
|
315
|
+
}
|
|
316
|
+
sendOutboundBinaryT(ws, frame);
|
|
317
|
+
} else {
|
|
318
|
+
sendOutboundT(ws, env);
|
|
319
|
+
}
|
|
320
|
+
delivered = true;
|
|
321
|
+
}
|
|
322
|
+
return delivered;
|
|
323
|
+
},
|
|
324
|
+
sendWire(ws, topic, event, data, wire) {
|
|
325
|
+
let ud;
|
|
326
|
+
try { ud = ws.getUserData(); } catch { closedWsAbortsT++; return 2; }
|
|
327
|
+
const caps = ud[WS_CAPS];
|
|
328
|
+
const payload = (caps && caps.has(wire.capability)) ? wire.encode(event, data) : null;
|
|
329
|
+
if (payload == null) {
|
|
330
|
+
return sendOutboundT(ws, envelope(topic, event, data));
|
|
331
|
+
}
|
|
332
|
+
const id = ensureWireIdT(ws, ud, topic);
|
|
333
|
+
const frame = buildBinaryFrame(wire.schemaVersion, id, 0, payload);
|
|
334
|
+
return sendOutboundBinaryT(ws, frame);
|
|
335
|
+
},
|
|
236
336
|
sendTo(filter, topic, event, data) {
|
|
237
337
|
const msg = envelope(topic, event, data);
|
|
238
338
|
let count = 0;
|
|
@@ -681,7 +781,9 @@ export async function createTestServer(options = {}) {
|
|
|
681
781
|
for (let i = 0; i < msg.caps.length; i++) {
|
|
682
782
|
if (typeof msg.caps[i] === 'string') caps.add(msg.caps[i]);
|
|
683
783
|
}
|
|
684
|
-
ws.getUserData()
|
|
784
|
+
const helloUd = ws.getUserData();
|
|
785
|
+
capCountsT.adjust(helloUd[WS_CAPS], caps);
|
|
786
|
+
helloUd[WS_CAPS] = caps;
|
|
685
787
|
return;
|
|
686
788
|
}
|
|
687
789
|
if (msg.type === 'subscribe-batch' && Array.isArray(msg.topics)) {
|
|
@@ -809,6 +911,7 @@ export async function createTestServer(options = {}) {
|
|
|
809
911
|
}
|
|
810
912
|
: { code, message, platform: closePlatform, subscriptions: subs };
|
|
811
913
|
handler.close?.(ws, ctx);
|
|
914
|
+
capCountsT.adjust(ud[WS_CAPS], null);
|
|
812
915
|
wsConnections.delete(ws);
|
|
813
916
|
}
|
|
814
917
|
});
|
package/vite.js
CHANGED
|
@@ -319,6 +319,20 @@ export default function uws(options = {}) {
|
|
|
319
319
|
const platform = {
|
|
320
320
|
publish,
|
|
321
321
|
publishBatched,
|
|
322
|
+
// Binary wire (publishWire/sendWire) is a production transport
|
|
323
|
+
// optimization. Dev mode delegates to the JSON publish/send: a
|
|
324
|
+
// binary-capable client receives JSON text frames, which its cursor
|
|
325
|
+
// store consumes identically (the binary path is transparent and
|
|
326
|
+
// optional). This mirrors dev's existing simpler-than-prod posture
|
|
327
|
+
// (dev also skips per-topic seq stamping). The full binary `0x03` path
|
|
328
|
+
// ships and is tested in production (files/handler.js) and the test
|
|
329
|
+
// server (testing.js).
|
|
330
|
+
publishWire(topic, event, data, _wire, options) {
|
|
331
|
+
return publish(topic, event, data, options);
|
|
332
|
+
},
|
|
333
|
+
sendWire(ws, topic, event, data, _wire) {
|
|
334
|
+
return send(ws, topic, event, data);
|
|
335
|
+
},
|
|
322
336
|
batch(messages) {
|
|
323
337
|
const results = [];
|
|
324
338
|
for (let i = 0; i < messages.length; i++) {
|