@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
211 lines (196 loc) • 7.91 kB
JavaScript
import { assert } from "../../../../core/assert.js";
import Signal from "../../../../core/events/signal/Signal.js";
import { Transport } from "../Transport.js";
/**
* Transport adapter over a browser `WebTransport` connection, using the
* unreliable/unordered **datagram** channel — the moral equivalent of
* UDP, exposed over HTTP/3.
*
* Properties:
* - Authenticated by TLS (https:// origin required by the API).
* - No head-of-line blocking, unlike WebSocket-over-TCP.
* - Datagrams have a per-implementation MTU (typically 1200 bytes
* after framing); larger payloads should go through the engine's
* fragment-send machinery as with any UDP-style transport.
*
* For reliable / ordered traffic (chat, room state, file transfer)
* the same `WebTransport` exposes bidirectional streams. This adapter
* does not surface them; build a separate stream-mode transport if
* needed, or use {@link WebSocketTransport} for that traffic.
*
* The `WebTransport` global is browser-only and requires a relatively
* recent runtime (Chromium 97+, Firefox 114+). Server-side, an HTTP/3
* server is required (e.g. `quiche-py`, `aioquic`, or a dedicated
* WebTransport library — out of scope for the engine).
*
* Either pass a pre-constructed `wt` instance (useful for tests via
* a mock) or pass `url` + optional `options` and let the adapter
* construct it.
*
* @author Alex Goldring
* @copyright Company Named Limited (c) 2025
*/
export class WebTransportTransport extends Transport {
#stats;
#writer;
#reader;
#read_loop_running;
#close_listened;
/**
* @param {{ wt?: object, url?: string, options?: object }} params
*/
constructor({ wt, url, options } = {}) {
super();
if (wt === undefined && typeof url !== "string") {
throw new Error("WebTransportTransport: either `wt` or `url` is required");
}
// Datagram channel: UDP-like — neither delivery nor ordering
// are guaranteed. Inherits the base's defaults; stated here
// for clarity, and so a future stream-mode subclass can flip
// them without ambiguity.
this.reliable = false;
this.ordered = false;
/**
* The underlying `WebTransport` instance. Exposed for callers
* that need to access stream-mode APIs or attach lifecycle
* listeners (`.closed`, `.draining`).
* @type {object}
*/
this.wt = wt !== undefined ? wt : new WebTransport(url, options);
assert.ok(this.wt.datagrams !== undefined,
'WebTransport instance must expose .datagrams (browser support / version)');
/** @private */
this.#stats = { bytes_in: 0, bytes_out: 0, packets_in: 0, packets_out: 0 };
/** @private */
this.#writer = null;
/** @private */
this.#reader = null;
/** @private */
this.#read_loop_running = false;
/** @private */
this.#close_listened = false;
/**
* Fires when `writer.write()` rejects but the underlying
* connection is still open — typically transient backpressure
* (writer queue full / slow consumer). Distinct from
* `onDisconnect`, which fires only on the actual `wt.closed`
* settlement (clean close or abrupt error). Callers can
* subscribe to throttle their send rate, drop optional traffic,
* or surface a warning.
*
* If you don't care, ignore — sends remain fire-and-forget and
* a dropped packet is no different from a UDP datagram lost on
* the network.
*
* @type {Signal}
*/
this.onSendDropped = new Signal();
}
/**
* Await `wt.ready`, acquire the datagram writer and reader, and
* start the receive loop. Idempotent.
*
* @returns {Promise<void>}
*/
async connect() {
if (this.#writer !== null) return;
await this.wt.ready;
this.#writer = this.wt.datagrams.writable.getWriter();
this.#reader = this.wt.datagrams.readable.getReader();
this.#start_read_loop();
this.#listen_for_close();
}
/**
* @param {Uint8Array} bytes
* @param {number} length
*/
send(bytes, length) {
assert.isNonNegativeInteger(length, 'length');
if (length === 0) return;
if (this.#writer === null) {
// Pre-`connect()` writes are dropped. Higher layers should
// gate sends on the `connect()` promise.
return;
}
const payload = (length === bytes.byteLength) ? bytes : bytes.subarray(0, length);
// Fire-and-forget at the call site, but distinguish two
// rejection sources: backpressure / writer-queue-full (transient,
// recoverable) vs. the transport actually closing (terminal —
// surfaced via the `.closed` listener below as `onDisconnect`).
// Backpressure fires `onSendDropped` so callers can throttle.
this.#writer.write(payload).catch((err) => {
// If the underlying connection has already entered close,
// `.closed` will surface the failure as onDisconnect — no
// need to double-report via onSendDropped.
if (this.#writer === null) return;
const reason = err && err.message ? err.message : 'write_rejected';
this.onSendDropped.send1(reason);
});
this.#stats.bytes_out += length;
this.#stats.packets_out += 1;
}
/**
* Cancel the read loop and close the WebTransport.
*/
disconnect() {
if (this.#reader !== null) {
try { this.#reader.cancel(); } catch (_) { /* swallow */ }
this.#reader = null;
}
if (this.#writer !== null) {
try { this.#writer.close(); } catch (_) { /* swallow */ }
this.#writer = null;
}
try { this.wt.close(); } catch (_) { /* swallow */ }
}
/**
* @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,
};
}
/** @private */
#start_read_loop() {
if (this.#read_loop_running) return;
this.#read_loop_running = true;
const pump = async () => {
try {
while (this.#reader !== null) {
const { value, done } = await this.#reader.read();
if (done) break;
if (!(value instanceof Uint8Array)) continue;
this.#stats.bytes_in += value.length;
this.#stats.packets_in += 1;
this.onReceive.send2(value, value.length);
}
} catch (_) {
// Stream errored out (e.g. transport closed). The
// `.closed` listener below dispatches `onDisconnect`.
} finally {
this.#read_loop_running = false;
}
};
pump();
}
/** @private */
#listen_for_close() {
if (this.#close_listened) return;
this.#close_listened = true;
// `wt.closed` resolves on clean close, rejects on abrupt close.
// Either way the transport is dead — surface it via `onDisconnect`.
this.wt.closed
.then((info) => {
const reason = (info && info.reason) ? info.reason : 'closed';
this.onDisconnect.send1(reason);
})
.catch((err) => {
const reason = err && err.message ? err.message : 'errored';
this.onDisconnect.send1(reason);
});
}
}