@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
132 lines (118 loc) • 4.8 kB
JavaScript
import { assert } from "../../../../core/assert.js";
import { Transport } from "../Transport.js";
/**
* Transport adapter that wraps a browser `RTCDataChannel` configured for
* unreliable, unordered delivery — the only sane low-latency channel on the web.
*
* The data channel must be created with `{ ordered: false, maxRetransmits: 0 }`
* and reach `readyState === "open"` before this adapter is used. Channel setup
* (negotiating the `RTCPeerConnection`, ICE, signalling) is the application's
* responsibility; this adapter only handles byte transport.
*
* Duck-typed against `RTCDataChannel` so the same adapter works with mocks in
* tests. The expected interface is:
* - `binaryType` (writable; we set it to `'arraybuffer'`)
* - `addEventListener(type, handler)` for `'message'`, `'close'`, `'error'`
* - `send(ArrayBuffer)`
* - `close()`
* - `readyState` (read in some debug paths)
*
* @author Alex Goldring
* @copyright Company Named Limited (c) 2025
*/
export class WebRTCDataChannelTransport extends Transport {
#stats;
#on_message;
#on_close;
#on_error;
#disconnect_fired;
/**
* @param {{ data_channel: RTCDataChannel }} options
*/
constructor({ data_channel }) {
super();
assert.ok(data_channel && typeof data_channel.send === 'function', 'data_channel must implement send()');
/** @type {RTCDataChannel} */
this.data_channel = data_channel;
this.data_channel.binaryType = 'arraybuffer';
/** @private */
this.#stats = { bytes_in: 0, bytes_out: 0, packets_in: 0, packets_out: 0 };
// RTCDataChannel may fire 'close' and 'error' separately for the same
// failure (browsers vary). Latch on the first to avoid double-dispatch.
/** @private */
this.#disconnect_fired = false;
/** @private */
this.#on_message = (event) => {
// event.data is an ArrayBuffer because we set binaryType above.
const ab = event.data;
const bytes = new Uint8Array(ab);
this.#stats.bytes_in += bytes.length;
this.#stats.packets_in += 1;
this.onReceive.send2(bytes, bytes.length);
};
/** @private */
this.#on_close = () => {
if (this.#disconnect_fired) return;
this.#disconnect_fired = true;
this.onDisconnect.send1('channel_closed');
};
/** @private */
this.#on_error = (event) => {
if (this.#disconnect_fired) return;
this.#disconnect_fired = true;
const reason = event && event.error && event.error.message
? event.error.message
: 'channel_error';
this.onDisconnect.send1(reason);
};
this.data_channel.addEventListener('message', this.#on_message);
this.data_channel.addEventListener('close', this.#on_close);
this.data_channel.addEventListener('error', this.#on_error);
}
/**
* @param {Uint8Array} bytes
* @param {number} length
*/
send(bytes, length) {
assert.isNonNegativeInteger(length, 'length');
// RTCDataChannel.send wants the exact bytes; if the source view extends past `length`,
// we need to slice. The slice copies, which is unavoidable for over-MTU correctness.
if (length === 0) return;
const payload = (length === bytes.byteLength)
? bytes
: bytes.subarray(0, length);
// .send accepts ArrayBufferView; pass the typed array directly.
this.data_channel.send(payload);
this.#stats.bytes_out += length;
this.#stats.packets_out += 1;
}
/**
* Close the underlying channel. After this, sends are no-ops at the channel level.
*
* Locally-initiated disconnect: we suppress the channel's own `close`
* event from firing `onDisconnect` again — callers who explicitly
* disconnect don't expect a notification echo.
*/
disconnect() {
this.#disconnect_fired = true;
this.data_channel.removeEventListener('message', this.#on_message);
this.data_channel.removeEventListener('close', this.#on_close);
this.data_channel.removeEventListener('error', this.#on_error);
try {
this.data_channel.close();
} catch (e) {
// Channel may already be closed; ignore.
}
}
/**
* @returns {{bytes_in: number, bytes_out: number, packets_in: number, packets_out: number}}
*/
getStats() {
return {
bytes_in: this.#stats.bytes_in,
bytes_out: this.#stats.bytes_out,
packets_in: this.#stats.packets_in,
packets_out: this.#stats.packets_out,
};
}
}