@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 +21 -0
- package/README.md +146 -0
- package/package.json +37 -0
- package/src/envelope.mjs +72 -0
- package/src/frame.mjs +98 -0
- package/src/index.mjs +6 -0
- package/src/priority-mux.mjs +234 -0
- package/src/reassembler.mjs +73 -0
- package/src/scheduler.mjs +99 -0
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
|
+
}
|
package/src/envelope.mjs
ADDED
|
@@ -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,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
|
+
}
|