UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

211 lines (196 loc) 7.91 kB
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); }); } }