@johnhenry/browsermesh-priority-mux 0.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 John Henry
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # browsermesh-priority-mux
2
+
3
+ Application-level priority scheduling for single-stream transports. A plain
4
+ WebSocket is one ordered TCP byte stream with no multi-stream capability at
5
+ all -- unlike WebRTC, which gets a second `RTCDataChannel` almost for free
6
+ (see `@johnhenry/browsermesh-transport`'s dual-datachannel priority split).
7
+ Over a WebSocket, the only way to stop a large bulk send (a file transfer, a
8
+ sync delta) from blocking a small urgent message (a control/heartbeat/chat
9
+ message) behind it in delivery order is an application-level scheduler that
10
+ chunks large messages and interleaves them with small ones by priority.
11
+
12
+ This package borrows its scheduling *idea* -- prioritizing short messages so
13
+ they don't queue behind long ones -- from Homa (Ousterhout et al., Stanford;
14
+ a receiver-driven, message-oriented, SRPT-style datacenter RPC transport). It
15
+ does not implement Homa's wire protocol, congestion control, or receiver-side
16
+ scheduling; it's a much smaller, sender-side, WFQ-with-anti-starvation
17
+ scheduler purpose-built for one already-open, ordered, message-boundary-
18
+ preserving connection (WebSocket, but really anything shaped the same way).
19
+
20
+ ## Why this exists
21
+
22
+ `@johnhenry/browsermesh-transport`'s `WebSocketTransport` is the real,
23
+ already-integrated WebSocket fallback `TransportFactory.negotiate()` picks
24
+ when WebRTC and WebTransport negotiation both fail (`preferredOrder =
25
+ ['webrtc', 'wsh-wt', 'wsh-ws']`). Once picked, it's a generic transport --
26
+ every kind of traffic a WebRTC data channel would have carried (chat, file
27
+ transfer, sync, consensus/control messages) gets tagged and multiplexed onto
28
+ that one WebSocket, with a raw `ws.send()` per message and no chunking or
29
+ priority separation at all. A large sync payload sent right before a small
30
+ urgent chat message will delay that chat message until the sync payload's
31
+ bytes clear the socket.
32
+
33
+ ## Design
34
+
35
+ ### Transport-agnostic adapter
36
+
37
+ `PriorityMux` wraps anything shaped like:
38
+
39
+ ```js
40
+ { send(bytes), on(event, cb), close?() } // events: 'open' | 'message' | 'close' | 'error'
41
+ ```
42
+
43
+ This is the same shape `@johnhenry/browsermesh-transport`'s
44
+ `WebSocketTransport`/`WebRTCTransport`/`WebTransportTransport` already
45
+ expose, so a mux instance is a drop-in wrapper: anywhere code did
46
+ `transport.send(x)` / `transport.on('message', cb)`, it can do `mux.send(x)`
47
+ / `mux.on('message', cb)` instead, unchanged. The core scheduler has no
48
+ WebSocket-specific code in it.
49
+
50
+ ### Wire framing
51
+
52
+ Each chunk is one frame, one `adapter.send()` call. Mirrors the binary
53
+ framing style already used in this monorepo
54
+ (`browsermesh-transport/src/wisp-client.mjs`'s `encodeFrame`/`decodeFrame`:
55
+ fixed-size header via `DataView`, little-endian) rather than
56
+ `JSON.stringify`-ing an object per chunk:
57
+
58
+ ```
59
+ [version:u8][priority:u8][msgId:u32][seq:u32][total:u32][payload:...]
60
+ byte 0 byte 1 bytes 2-5 bytes 6-9 bytes 10-13 bytes 14+
61
+ ```
62
+
63
+ Messages are transparently wrapped in a 1-byte envelope tag before chunking
64
+ (raw bytes / UTF-8 string / JSON value) so reassembly hands back the same
65
+ shape that was sent -- callers don't need to know chunking happened at all.
66
+
67
+ ### Priority queues + anti-starvation
68
+
69
+ Three tiers by default (`'high' | 'normal' | 'low'`, configurable). Draining
70
+ picks the highest non-empty tier -- *except* every Nth drain slot
71
+ (`starvationGuardInterval`, default 8), which is reserved unconditionally for
72
+ the lowest non-empty tier, regardless of how much higher-tier backlog is
73
+ queued. That gives a concrete bound: a message sitting alone in the lowest
74
+ tier is dequeued within `starvationGuardInterval` drain slots, even under a
75
+ continuous flood of higher-priority sends. See `src/scheduler.mjs`.
76
+
77
+ ### Reassembly
78
+
79
+ Chunks are buffered by `msgId` (indexed by `seq`, not append order) until
80
+ `total` distinct sequence numbers have arrived, then concatenated in order
81
+ and delivered as one `'message'` event -- transparent to whatever already
82
+ consumes `transport.on('message', ...)`. A message is never delivered
83
+ partially. Closing the underlying adapter mid-flight discards all pending
84
+ reassembly state so nothing hangs waiting for chunks that will never arrive.
85
+
86
+ ### Priority inference
87
+
88
+ ```js
89
+ new PriorityMux(adapter, {
90
+ priorityOf: (data) => {
91
+ if (data?.type === 'ping' || data?.type === 'consensus') return 'high';
92
+ if (data?.type === 'file-chunk') return 'low';
93
+ // return undefined/null to fall through to defaultPriority
94
+ },
95
+ });
96
+ ```
97
+
98
+ The classifier is entirely caller-supplied -- this package has no built-in
99
+ knowledge of any particular ecosystem's message-type names.
100
+
101
+ ## Install
102
+
103
+ ```bash
104
+ npm install @johnhenry/browsermesh-priority-mux
105
+ ```
106
+
107
+ ## Quick start
108
+
109
+ ```js
110
+ import { PriorityMux } from '@johnhenry/browsermesh-priority-mux';
111
+ import { WebSocketTransport } from '@johnhenry/browsermesh-transport';
112
+
113
+ const transport = new WebSocketTransport({ url: 'wss://example.com' });
114
+ await transport.connect();
115
+
116
+ const mux = new PriorityMux(transport, { chunkSize: 16 * 1024 });
117
+
118
+ mux.on('message', (data, meta) => {
119
+ console.log('received', meta.priority, data);
120
+ });
121
+
122
+ // A big sync payload...
123
+ mux.send(bigSyncDelta, { priority: 'low' });
124
+ // ...won't delay this urgent ping, even though it was queued first.
125
+ mux.send({ type: 'ping' }, { priority: 'high' });
126
+ ```
127
+
128
+ ## API
129
+
130
+ - `new PriorityMux(adapter, opts)` -- `opts`: `chunkSize` (default 16384),
131
+ `tiers` (default `['high','normal','low']`), `starvationGuardInterval`
132
+ (default 8), `priorityOf(data)`, `defaultPriority` (default `'normal'`),
133
+ `scheduleFn` (injectable drain scheduler, default `setImmediate`).
134
+ - `.send(data, { priority })` -> `{ msgId, total, priority }`
135
+ - `.on(event, cb)` -- `'open' | 'message' | 'close' | 'error'`
136
+ - `.close()`
137
+ - `.getStats()`
138
+ - `PriorityScheduler`, `Reassembler` -- the two pieces `PriorityMux` composes,
139
+ usable standalone.
140
+ - `encodeChunkFrame`/`decodeChunkFrame`/`splitIntoChunks` -- wire framing.
141
+ - `encodeEnvelope`/`decodeEnvelope` -- the transparent type-preserving
142
+ message envelope.
143
+
144
+ ## License
145
+
146
+ MIT
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@johnhenry/browsermesh-priority-mux",
3
+ "version": "0.0.1",
4
+ "description": "Application-level priority scheduler that chunks and interleaves messages to prevent head-of-line blocking on single-stream transports (WebSocket) for BrowserMesh",
5
+ "type": "module",
6
+ "main": "./src/index.mjs",
7
+ "exports": {
8
+ ".": "./src/index.mjs"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "LICENSE",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "test": "node --test test/*.test.mjs"
17
+ },
18
+ "keywords": [
19
+ "websocket",
20
+ "priority",
21
+ "scheduler",
22
+ "head-of-line-blocking",
23
+ "multiplexing",
24
+ "mesh",
25
+ "browser"
26
+ ],
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/johnhenry/browsermesh",
31
+ "directory": "packages/browsermesh-priority-mux"
32
+ },
33
+ "homepage": "https://opensource.johnhenry.me/browsermesh/",
34
+ "engines": {
35
+ "node": ">=24.0.0"
36
+ }
37
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * envelope.mjs -- transparent message envelope encoding.
3
+ *
4
+ * PriorityMux chunks and reassembles arbitrary messages. So that reassembly
5
+ * hands callers back the *same shape* they sent (string in -> string out,
6
+ * plain object in -> plain object out, bytes in -> bytes out) rather than
7
+ * always producing raw bytes, every message is wrapped in a 1-byte tag
8
+ * before chunking, and unwrapped again after reassembly.
9
+ *
10
+ * Wire layout: [tag:u8][body:...]
11
+ */
12
+
13
+ /** @type {0} raw bytes (Uint8Array/ArrayBuffer), passed through untouched */
14
+ export const ENVELOPE_BYTES = 0;
15
+ /** @type {1} UTF-8 string */
16
+ export const ENVELOPE_STRING = 1;
17
+ /** @type {2} JSON-serializable value */
18
+ export const ENVELOPE_JSON = 2;
19
+
20
+ const textEncoder = new TextEncoder();
21
+ const textDecoder = new TextDecoder();
22
+
23
+ /**
24
+ * Encode an arbitrary message into a tagged byte envelope.
25
+ * @param {Uint8Array|ArrayBuffer|string|*} data
26
+ * @returns {Uint8Array}
27
+ */
28
+ export function encodeEnvelope(data) {
29
+ let tag;
30
+ let body;
31
+
32
+ if (data instanceof Uint8Array) {
33
+ tag = ENVELOPE_BYTES;
34
+ body = data;
35
+ } else if (data instanceof ArrayBuffer) {
36
+ tag = ENVELOPE_BYTES;
37
+ body = new Uint8Array(data);
38
+ } else if (typeof data === 'string') {
39
+ tag = ENVELOPE_STRING;
40
+ body = textEncoder.encode(data);
41
+ } else {
42
+ tag = ENVELOPE_JSON;
43
+ body = textEncoder.encode(JSON.stringify(data));
44
+ }
45
+
46
+ const out = new Uint8Array(1 + body.byteLength);
47
+ out[0] = tag;
48
+ out.set(body, 1);
49
+ return out;
50
+ }
51
+
52
+ /**
53
+ * Decode a tagged byte envelope back into its original shape.
54
+ * @param {Uint8Array} bytes
55
+ * @returns {Uint8Array|string|*}
56
+ */
57
+ export function decodeEnvelope(bytes) {
58
+ if (bytes.byteLength < 1) throw new Error('priority-mux: envelope too short');
59
+ const tag = bytes[0];
60
+ const body = bytes.subarray(1);
61
+
62
+ switch (tag) {
63
+ case ENVELOPE_BYTES:
64
+ return body;
65
+ case ENVELOPE_STRING:
66
+ return textDecoder.decode(body);
67
+ case ENVELOPE_JSON:
68
+ return JSON.parse(textDecoder.decode(body));
69
+ default:
70
+ throw new Error(`priority-mux: unknown envelope tag ${tag}`);
71
+ }
72
+ }
package/src/frame.mjs ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * frame.mjs -- wire framing for chunked, prioritized messages.
3
+ *
4
+ * Mirrors the framing style already used in this monorepo (see
5
+ * `@johnhenry/browsermesh-transport`'s `wisp-client.mjs` `encodeFrame`/
6
+ * `decodeFrame`: a fixed-size binary header via DataView, little-endian,
7
+ * followed by a raw payload) rather than inventing a new convention or
8
+ * throwing an object at JSON.stringify.
9
+ *
10
+ * Frame layout (little-endian):
11
+ * [version:u8][priority:u8][msgId:u32][seq:u32][total:u32][payload:...]
12
+ * byte 0 byte 1 bytes 2-5 bytes 6-9 bytes 10-13 bytes 14+
13
+ *
14
+ * One frame is one discrete adapter `send()` call -- the underlying
15
+ * transport (WebSocket, WebRTC data channel, etc.) is expected to preserve
16
+ * message boundaries on its own (as WebSocket and RTCDataChannel both do),
17
+ * so no additional length-prefixing of the transport stream itself is
18
+ * needed here.
19
+ */
20
+
21
+ export const FRAME_VERSION = 1;
22
+
23
+ /** Priority tier -> wire code. Order here has no bearing on scheduling order. */
24
+ export const PRIORITY_CODES = Object.freeze({ high: 0, normal: 1, low: 2 });
25
+ /** Wire code -> priority tier name. */
26
+ export const PRIORITY_NAMES = Object.freeze(['high', 'normal', 'low']);
27
+
28
+ const HEADER_BYTES = 14;
29
+
30
+ /**
31
+ * Encode one chunk frame.
32
+ * @param {object} chunk
33
+ * @param {'high'|'normal'|'low'} chunk.priority
34
+ * @param {number} chunk.msgId - u32
35
+ * @param {number} chunk.seq - u32, 0-based index of this chunk
36
+ * @param {number} chunk.total - u32, total chunk count for this message
37
+ * @param {Uint8Array} chunk.payload
38
+ * @returns {Uint8Array}
39
+ */
40
+ export function encodeChunkFrame({ priority, msgId, seq, total, payload }) {
41
+ const priorityCode = PRIORITY_CODES[priority];
42
+ if (priorityCode === undefined) {
43
+ throw new Error(`priority-mux: unknown priority tier "${priority}"`);
44
+ }
45
+ const payloadBytes = payload || new Uint8Array(0);
46
+ const buf = new Uint8Array(HEADER_BYTES + payloadBytes.byteLength);
47
+ const view = new DataView(buf.buffer);
48
+ view.setUint8(0, FRAME_VERSION);
49
+ view.setUint8(1, priorityCode);
50
+ view.setUint32(2, msgId >>> 0, true);
51
+ view.setUint32(6, seq >>> 0, true);
52
+ view.setUint32(10, total >>> 0, true);
53
+ buf.set(payloadBytes, HEADER_BYTES);
54
+ return buf;
55
+ }
56
+
57
+ /**
58
+ * Decode one chunk frame.
59
+ * @param {ArrayBuffer|Uint8Array} data
60
+ * @returns {{ priority: 'high'|'normal'|'low', msgId: number, seq: number, total: number, payload: Uint8Array }}
61
+ */
62
+ export function decodeChunkFrame(data) {
63
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
64
+ if (bytes.byteLength < HEADER_BYTES) {
65
+ throw new Error('priority-mux: frame too short');
66
+ }
67
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
68
+ const version = view.getUint8(0);
69
+ if (version !== FRAME_VERSION) {
70
+ throw new Error(`priority-mux: unsupported frame version ${version}`);
71
+ }
72
+ const priority = PRIORITY_NAMES[view.getUint8(1)];
73
+ if (!priority) {
74
+ throw new Error(`priority-mux: unknown priority code ${view.getUint8(1)}`);
75
+ }
76
+ const msgId = view.getUint32(2, true);
77
+ const seq = view.getUint32(6, true);
78
+ const total = view.getUint32(10, true);
79
+ const payload = bytes.subarray(HEADER_BYTES);
80
+ return { priority, msgId, seq, total, payload };
81
+ }
82
+
83
+ /**
84
+ * Split an envelope's bytes into an ordered array of chunk descriptors
85
+ * (without `priority`/`msgId`, which the caller attaches).
86
+ * @param {Uint8Array} bytes
87
+ * @param {number} chunkSize
88
+ * @returns {{ seq: number, total: number, payload: Uint8Array }[]}
89
+ */
90
+ export function splitIntoChunks(bytes, chunkSize) {
91
+ const total = Math.max(1, Math.ceil(bytes.byteLength / chunkSize));
92
+ const chunks = new Array(total);
93
+ for (let seq = 0; seq < total; seq++) {
94
+ const start = seq * chunkSize;
95
+ chunks[seq] = { seq, total, payload: bytes.subarray(start, start + chunkSize) };
96
+ }
97
+ return chunks;
98
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,6 @@
1
+ // browsermesh-priority-mux -- WebSocket head-of-line-blocking mitigation
2
+ export * from './priority-mux.mjs';
3
+ export * from './scheduler.mjs';
4
+ export * from './reassembler.mjs';
5
+ export * from './frame.mjs';
6
+ export * from './envelope.mjs';
@@ -0,0 +1,234 @@
1
+ import { encodeEnvelope, decodeEnvelope } from './envelope.mjs';
2
+ import { encodeChunkFrame, decodeChunkFrame, splitIntoChunks } from './frame.mjs';
3
+ import { PriorityScheduler, DEFAULT_TIERS, DEFAULT_STARVATION_GUARD_INTERVAL } from './scheduler.mjs';
4
+ import { Reassembler } from './reassembler.mjs';
5
+
6
+ /** Default chunk size in bytes: large messages above this get split. */
7
+ export const DEFAULT_CHUNK_SIZE = 16 * 1024;
8
+
9
+ /** Events re-emitted by PriorityMux. */
10
+ const EVENTS = Object.freeze(['open', 'message', 'close', 'error']);
11
+
12
+ const defaultSchedule = typeof setImmediate === 'function'
13
+ ? setImmediate
14
+ : (fn) => setTimeout(fn, 0);
15
+
16
+ /**
17
+ * PriorityMux -- an application-level scheduler that prevents head-of-line
18
+ * blocking on a single ordered byte-stream transport (WebSocket and
19
+ * similar) by chunking large messages and interleaving them with small,
20
+ * high-priority ones, borrowing the scheduling idea (not the wire
21
+ * protocol) from Homa's SRPT-style short-message prioritization.
22
+ *
23
+ * Transport-agnostic: wraps anything shaped like
24
+ * `{ send(bytes), on(event, cb), close() }` -- the same adapter shape
25
+ * `@johnhenry/browsermesh-transport`'s `WebSocketTransport` (and its
26
+ * WebRTC/WebTransport siblings) already exposes. PriorityMux itself
27
+ * exposes that exact same shape back out, so it drops in as a transparent
28
+ * wrapper: anywhere code did `transport.send(x)` / `transport.on('message', cb)`,
29
+ * it can instead do `mux.send(x)` / `mux.on('message', cb)` unchanged.
30
+ */
31
+ export class PriorityMux {
32
+ #adapter;
33
+ #chunkSize;
34
+ #tiers;
35
+ #priorityOf;
36
+ #defaultPriority;
37
+ #scheduleFn;
38
+ #scheduler;
39
+ #reassembler = new Reassembler();
40
+ #callbacks = { open: [], message: [], close: [], error: [] };
41
+ #draining = false;
42
+ #closed = false;
43
+ #nextMsgId = Math.floor(Math.random() * 0xfffffffe) >>> 0;
44
+ #stats = { chunksSent: 0, chunksReceived: 0, messagesSent: 0, messagesReceived: 0 };
45
+
46
+ /**
47
+ * @param {{ send(bytes: Uint8Array): void, on(event: string, cb: Function): void, close?: Function }} adapter
48
+ * @param {object} [opts]
49
+ * @param {number} [opts.chunkSize=16384] - Messages whose encoded byte
50
+ * length exceeds this get split into multiple chunk frames.
51
+ * @param {string[]} [opts.tiers=['high','normal','low']] - Priority tier
52
+ * names, highest priority first. `send()`'s `priority` option and any
53
+ * `priorityOf()` classifier must return one of these.
54
+ * @param {number} [opts.starvationGuardInterval=8] - Anti-starvation
55
+ * bound: every Nth drain slot is reserved for the lowest non-empty
56
+ * tier regardless of higher-tier backlog. See `PriorityScheduler`.
57
+ * @param {(parsedEnvelope: *) => (string|undefined|null)} [opts.priorityOf]
58
+ * Optional classifier: given the *original* value passed to `send()`
59
+ * (before wire encoding), return a tier name to auto-derive priority.
60
+ * Domain-specific message-shape knowledge belongs here, supplied by
61
+ * the caller -- this package stays domain-agnostic.
62
+ * @param {string} [opts.defaultPriority='normal'] - Used when neither an
63
+ * explicit `send()` option nor `priorityOf()` yields a tier.
64
+ * @param {(fn: Function) => void} [opts.scheduleFn] - Injectable drain
65
+ * scheduler (defaults to `setImmediate`, falling back to
66
+ * `setTimeout(fn, 0)`). Exposed mainly for deterministic tests.
67
+ */
68
+ constructor(adapter, opts = {}) {
69
+ if (!adapter || typeof adapter.send !== 'function' || typeof adapter.on !== 'function') {
70
+ throw new Error('priority-mux: adapter must have send() and on()');
71
+ }
72
+ this.#adapter = adapter;
73
+ this.#chunkSize = opts.chunkSize ?? DEFAULT_CHUNK_SIZE;
74
+ if (this.#chunkSize < 1) throw new Error('priority-mux: chunkSize must be >= 1');
75
+ this.#tiers = opts.tiers || DEFAULT_TIERS;
76
+ this.#priorityOf = opts.priorityOf || null;
77
+ this.#defaultPriority = opts.defaultPriority || (this.#tiers.includes('normal') ? 'normal' : this.#tiers[0]);
78
+ this.#scheduleFn = opts.scheduleFn || defaultSchedule;
79
+ this.#scheduler = new PriorityScheduler({
80
+ tiers: this.#tiers,
81
+ starvationGuardInterval: opts.starvationGuardInterval ?? DEFAULT_STARVATION_GUARD_INTERVAL,
82
+ });
83
+
84
+ this.#adapter.on('message', (raw) => this.#handleIncoming(raw));
85
+ this.#adapter.on('close', (ev) => this.#handleClose(ev));
86
+ this.#adapter.on('open', (ev) => this.#emit('open', ev));
87
+ this.#adapter.on('error', (err) => this.#emit('error', err));
88
+ }
89
+
90
+ /**
91
+ * Send a message, transparently chunking it if it's large and queuing
92
+ * each chunk under the resolved priority tier.
93
+ * @param {Uint8Array|ArrayBuffer|string|*} data
94
+ * @param {{ priority?: string }} [opts]
95
+ * @returns {{ msgId: number, total: number, priority: string }}
96
+ */
97
+ send(data, opts = {}) {
98
+ if (this.#closed) throw new Error('priority-mux: mux is closed');
99
+
100
+ const priority = opts.priority
101
+ || (this.#priorityOf ? this.#priorityOf(data) : null)
102
+ || this.#defaultPriority;
103
+ if (!this.#tiers.includes(priority)) {
104
+ throw new Error(`priority-mux: unknown priority tier "${priority}"`);
105
+ }
106
+
107
+ const envelopeBytes = encodeEnvelope(data);
108
+ const msgId = this.#allocMsgId();
109
+ const chunks = splitIntoChunks(envelopeBytes, this.#chunkSize);
110
+
111
+ for (const { seq, total, payload } of chunks) {
112
+ this.#scheduler.enqueue(priority, { priority, msgId, seq, total, payload });
113
+ }
114
+ this.#stats.messagesSent++;
115
+ this.#scheduleDrain();
116
+
117
+ return { msgId, total: chunks.length, priority };
118
+ }
119
+
120
+ /**
121
+ * Register a listener. Supported events: 'open', 'message', 'close', 'error'.
122
+ * @param {string} event
123
+ * @param {Function} cb
124
+ */
125
+ on(event, cb) {
126
+ if (!EVENTS.includes(event)) throw new Error(`priority-mux: unknown event "${event}"`);
127
+ this.#callbacks[event].push(cb);
128
+ }
129
+
130
+ /**
131
+ * Close the underlying adapter (if it supports `close()`) and stop
132
+ * draining. Safe to call more than once.
133
+ */
134
+ close() {
135
+ if (this.#closed) return;
136
+ this.#closed = true;
137
+ if (typeof this.#adapter.close === 'function') {
138
+ this.#adapter.close();
139
+ }
140
+ this.#reassembler.reset();
141
+ }
142
+
143
+ /** Snapshot of internal counters, useful for tests/observability. */
144
+ getStats() {
145
+ return {
146
+ ...this.#stats,
147
+ pendingReassembly: this.#reassembler.pendingCount,
148
+ queued: this.#scheduler.size,
149
+ };
150
+ }
151
+
152
+ // -- Internal ----------------------------------------------------------
153
+
154
+ #allocMsgId() {
155
+ const id = this.#nextMsgId;
156
+ this.#nextMsgId = (this.#nextMsgId + 1) >>> 0;
157
+ return id;
158
+ }
159
+
160
+ #handleIncoming(raw) {
161
+ let frame;
162
+ try {
163
+ frame = decodeChunkFrame(raw);
164
+ } catch (err) {
165
+ this.#emit('error', err);
166
+ return;
167
+ }
168
+
169
+ this.#stats.chunksReceived++;
170
+
171
+ let result;
172
+ try {
173
+ result = this.#reassembler.receive(frame);
174
+ } catch (err) {
175
+ this.#emit('error', err);
176
+ return;
177
+ }
178
+ if (!result) return; // message still incomplete
179
+
180
+ let value;
181
+ try {
182
+ value = decodeEnvelope(result.bytes);
183
+ } catch (err) {
184
+ this.#emit('error', err);
185
+ return;
186
+ }
187
+
188
+ this.#stats.messagesReceived++;
189
+ this.#emit('message', value, { msgId: result.msgId, priority: result.priority });
190
+ }
191
+
192
+ #handleClose(ev) {
193
+ // Closing mid-flight must not leave partially-reassembled messages
194
+ // sitting around waiting for chunks that will never arrive, and must
195
+ // not leave the drain loop scheduling forever against a dead adapter.
196
+ this.#closed = true;
197
+ this.#draining = false;
198
+ this.#reassembler.reset();
199
+ this.#emit('close', ev);
200
+ }
201
+
202
+ #scheduleDrain() {
203
+ if (this.#draining || this.#closed) return;
204
+ this.#draining = true;
205
+ this.#scheduleFn(() => this.#drainTick());
206
+ }
207
+
208
+ #drainTick() {
209
+ if (this.#closed) {
210
+ this.#draining = false;
211
+ return;
212
+ }
213
+ const item = this.#scheduler.dequeue();
214
+ if (!item) {
215
+ this.#draining = false;
216
+ return;
217
+ }
218
+ try {
219
+ this.#adapter.send(encodeChunkFrame(item));
220
+ this.#stats.chunksSent++;
221
+ } catch (err) {
222
+ this.#draining = false;
223
+ this.#emit('error', err);
224
+ return;
225
+ }
226
+ this.#scheduleFn(() => this.#drainTick());
227
+ }
228
+
229
+ #emit(event, ...args) {
230
+ for (const cb of this.#callbacks[event] || []) {
231
+ try { cb(...args); } catch { /* listener errors must not break the mux */ }
232
+ }
233
+ }
234
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * reassembler.mjs -- reconstructs full messages from chunk frames.
3
+ *
4
+ * Buffers chunks by `msgId` until `total` distinct sequence numbers have
5
+ * arrived, then hands back the concatenated bytes in the correct order --
6
+ * regardless of the order chunks actually arrived in (interleaving across
7
+ * concurrent messages, or even out-of-order delivery of one message's own
8
+ * chunks, must both reassemble correctly). A message is never handed back
9
+ * partially: `receive()` returns a result only on the exact frame that
10
+ * completes it.
11
+ */
12
+
13
+ export class Reassembler {
14
+ #pending = new Map(); // msgId -> { total, priority, chunks: Array<Uint8Array|undefined>, receivedCount }
15
+
16
+ /**
17
+ * Feed one decoded chunk frame in. Returns the reassembled message once
18
+ * complete, otherwise `undefined`.
19
+ * @param {{ priority: string, msgId: number, seq: number, total: number, payload: Uint8Array }} frame
20
+ * @returns {{ msgId: number, priority: string, bytes: Uint8Array }|undefined}
21
+ */
22
+ receive({ priority, msgId, seq, total, payload }) {
23
+ if (total < 1) throw new Error('priority-mux: frame has invalid total');
24
+ if (seq < 0 || seq >= total) {
25
+ throw new Error(`priority-mux: chunk seq ${seq} out of range for total ${total}`);
26
+ }
27
+
28
+ let entry = this.#pending.get(msgId);
29
+ if (!entry) {
30
+ entry = { total, priority, chunks: new Array(total), receivedCount: 0 };
31
+ this.#pending.set(msgId, entry);
32
+ } else if (entry.total !== total) {
33
+ throw new Error(`priority-mux: msgId ${msgId} total mismatch (${entry.total} vs ${total})`);
34
+ }
35
+
36
+ // Duplicate delivery of the same (msgId, seq) is a no-op, not a
37
+ // duplicate-count bump -- keeps `receivedCount` accurate even if a
38
+ // transport ever redelivers.
39
+ if (entry.chunks[seq] === undefined) {
40
+ entry.chunks[seq] = payload;
41
+ entry.receivedCount++;
42
+ }
43
+
44
+ if (entry.receivedCount < entry.total) return undefined;
45
+
46
+ // Complete -- concatenate in seq order and remove the pending entry so
47
+ // it can never be double-delivered or leak memory.
48
+ this.#pending.delete(msgId);
49
+ let byteLength = 0;
50
+ for (const chunk of entry.chunks) byteLength += chunk.byteLength;
51
+ const bytes = new Uint8Array(byteLength);
52
+ let offset = 0;
53
+ for (const chunk of entry.chunks) {
54
+ bytes.set(chunk, offset);
55
+ offset += chunk.byteLength;
56
+ }
57
+ return { msgId, priority: entry.priority, bytes };
58
+ }
59
+
60
+ /** Number of messages currently mid-reassembly. */
61
+ get pendingCount() {
62
+ return this.#pending.size;
63
+ }
64
+
65
+ /**
66
+ * Discard all in-flight reassembly state. Must be called when the
67
+ * underlying transport closes so a partially-received message never
68
+ * hangs around waiting for chunks that will never arrive.
69
+ */
70
+ reset() {
71
+ this.#pending.clear();
72
+ }
73
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * scheduler.mjs -- priority queues with a weighted-fair-queueing drain
3
+ * order and a real anti-starvation bound.
4
+ *
5
+ * Borrows its scheduling *idea* (not its wire protocol) from Homa
6
+ * (Ousterhout et al., Stanford): prioritize short messages so they don't
7
+ * queue behind long ones, but bound how long any tier can be starved.
8
+ *
9
+ * Tiers drain in strict priority order (highest non-empty tier first) on
10
+ * every slot *except* one slot in every `starvationGuardInterval`, which
11
+ * is reserved unconditionally for the lowest non-empty tier -- regardless
12
+ * of how much backlog sits in higher tiers. That gives a concrete,
13
+ * testable bound: any single item sitting alone in the lowest tier is
14
+ * dequeued within `starvationGuardInterval` `dequeue()` calls, no matter
15
+ * how continuously higher tiers are fed.
16
+ */
17
+
18
+ /** Default tier order, highest priority first. */
19
+ export const DEFAULT_TIERS = Object.freeze(['high', 'normal', 'low']);
20
+
21
+ /** Default anti-starvation guard: 1 in every 8 drain slots is reserved. */
22
+ export const DEFAULT_STARVATION_GUARD_INTERVAL = 8;
23
+
24
+ export class PriorityScheduler {
25
+ /**
26
+ * @param {object} [opts]
27
+ * @param {string[]} [opts.tiers] - Tier names, highest priority first.
28
+ * @param {number} [opts.starvationGuardInterval] - Every Nth `dequeue()`
29
+ * call is forced to serve the lowest non-empty tier regardless of
30
+ * backlog elsewhere. Must be >= 1. Set to `Infinity` to disable (pure
31
+ * strict priority, no starvation guard -- not recommended).
32
+ */
33
+ constructor(opts = {}) {
34
+ this.tiers = opts.tiers || DEFAULT_TIERS;
35
+ if (this.tiers.length === 0) throw new Error('priority-mux: scheduler needs at least one tier');
36
+ this.starvationGuardInterval = opts.starvationGuardInterval ?? DEFAULT_STARVATION_GUARD_INTERVAL;
37
+ if (this.starvationGuardInterval < 1) {
38
+ throw new Error('priority-mux: starvationGuardInterval must be >= 1');
39
+ }
40
+ this.queues = new Map(this.tiers.map((t) => [t, []]));
41
+ this.slotCounter = 0;
42
+ }
43
+
44
+ /**
45
+ * Enqueue one item under a given tier.
46
+ * @param {string} tier
47
+ * @param {*} item
48
+ */
49
+ enqueue(tier, item) {
50
+ const q = this.queues.get(tier);
51
+ if (!q) throw new Error(`priority-mux: unknown tier "${tier}"`);
52
+ q.push(item);
53
+ }
54
+
55
+ /** Total number of items queued across all tiers. */
56
+ get size() {
57
+ let n = 0;
58
+ for (const q of this.queues.values()) n += q.length;
59
+ return n;
60
+ }
61
+
62
+ /** Size of a single tier's queue. */
63
+ sizeOf(tier) {
64
+ const q = this.queues.get(tier);
65
+ return q ? q.length : 0;
66
+ }
67
+
68
+ /**
69
+ * Pop the next item to send, or `undefined` if every queue is empty.
70
+ * Advances the internal slot counter (used by the starvation guard) on
71
+ * every call, including calls that find nothing to dequeue -- so the
72
+ * guard period is measured in *drain attempts*, matching how a real
73
+ * drain loop calls this once per tick.
74
+ * @returns {*|undefined}
75
+ */
76
+ dequeue() {
77
+ this.slotCounter++;
78
+
79
+ const isGuardSlot = Number.isFinite(this.starvationGuardInterval)
80
+ && this.slotCounter % this.starvationGuardInterval === 0;
81
+
82
+ if (isGuardSlot) {
83
+ // Anti-starvation: scan from the *lowest*-priority tier up, and
84
+ // serve the first non-empty one, ignoring higher-tier backlog.
85
+ for (let i = this.tiers.length - 1; i >= 0; i--) {
86
+ const q = this.queues.get(this.tiers[i]);
87
+ if (q.length) return q.shift();
88
+ }
89
+ return undefined; // everything empty -- fall through is unreachable
90
+ }
91
+
92
+ // Strict priority: highest non-empty tier wins.
93
+ for (const tier of this.tiers) {
94
+ const q = this.queues.get(tier);
95
+ if (q.length) return q.shift();
96
+ }
97
+ return undefined;
98
+ }
99
+ }