UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

181 lines (163 loc) 6.79 kB
import { assert } from "../../../../core/assert.js"; import { Transport } from "../Transport.js"; /** * Transport adapter over a `WebSocket`. * * **Not** the right choice for game state — WebSocket runs over TCP, which * means head-of-line blocking under packet loss. Use {@link WebRTCDataChannelTransport} * for game state. WebSocket is appropriate for lobby/matchmaking, chat, replay * uploads, and other use cases where ordering and reliability matter more * than tail latency. * * Duck-typed against the standard browser `WebSocket` interface so tests can * pass a mock. The expected interface: * - `binaryType` (writable; we set it to `'arraybuffer'`) * - `addEventListener(type, handler)` for `'message'`, `'close'`, `'error'`, `'open'` * - `send(ArrayBufferView | ArrayBuffer)` * - `close()` * - `readyState` * * Either pass an already-constructed WebSocket via `socket`, or pass a `url` * and let the adapter construct one (browser only — `WebSocket` must be available). * * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ export class WebSocketTransport extends Transport { #stats; #on_message; #on_close; #disconnect_fired; /** * @param {{ socket?: WebSocket, url?: string }} options */ constructor({ socket, url } = {}) { super(); if (socket === undefined && typeof url !== "string") { throw new Error("WebSocketTransport: either `socket` or `url` is required"); } // WebSocket sits on TCP — every message is delivered in order // with no loss until the socket closes. this.reliable = true; this.ordered = true; /** @type {WebSocket} */ this.socket = socket !== undefined ? socket : new WebSocket(url); assert.ok(typeof this.socket.send === 'function', 'socket must implement send()'); this.socket.binaryType = 'arraybuffer'; /** @private */ this.#stats = { bytes_in: 0, bytes_out: 0, packets_in: 0, packets_out: 0 }; // Latch so that local disconnect() and the subsequent socket // 'close' event don't both fire `onDisconnect`. /** @private */ this.#disconnect_fired = false; /** @private */ this.#on_message = (event) => { // event.data is ArrayBuffer (binaryType set above) for binary frames, // or string for text frames. We treat strings as text payloads converted to UTF-8 bytes. let bytes; if (event.data instanceof ArrayBuffer) { bytes = new Uint8Array(event.data); } else if (typeof event.data === "string") { bytes = new TextEncoder().encode(event.data); } else { // Some browsers wrap as Blob if binaryType is misset; ignore. return; } this.#stats.bytes_in += bytes.length; this.#stats.packets_in += 1; this.onReceive.send2(bytes, bytes.length); }; // Forward socket close into the `onDisconnect` Signal so higher // layers (e.g. `NetworkSession`'s reconnect ladder) can react. // `'close'` covers both clean and abrupt closures; some // implementations fire `'error'` first and then `'close'`, so we // only fire ours on close. The latch additionally suppresses the // echo when callers locally disconnect. /** @private */ this.#on_close = (ev) => { if (this.#disconnect_fired) return; this.#disconnect_fired = true; const reason = ev && ev.reason ? ev.reason : `code:${ev && ev.code}`; this.onDisconnect.send1(reason); }; this.socket.addEventListener('message', this.#on_message); this.socket.addEventListener('close', this.#on_close); } /** * @param {Uint8Array} bytes * @param {number} length */ send(bytes, length) { assert.isNonNegativeInteger(length, 'length'); if (length === 0) return; const payload = (length === bytes.byteLength) ? bytes : bytes.subarray(0, length); this.socket.send(payload); this.#stats.bytes_out += length; this.#stats.packets_out += 1; } /** * Resolve when the socket is OPEN. If already open, resolves immediately. * Useful when constructed with a `url` and the caller wants to await readiness. * * Rejects on either `'error'` or `'close'` — some failure modes (server * rejects the handshake with a clean close, network drops mid-handshake) * fire `'close'` without `'error'`; listening on only one would hang the * promise forever. * * @returns {Promise<void>} */ connect() { const OPEN = 1; // WebSocket.OPEN if (this.socket.readyState === OPEN) return Promise.resolve(); return new Promise((resolve, reject) => { const cleanup = () => { this.socket.removeEventListener('open', on_open); this.socket.removeEventListener('error', on_error); this.socket.removeEventListener('close', on_close); }; const on_open = () => { cleanup(); resolve(); }; const on_error = (err) => { cleanup(); reject(err); }; const on_close = (ev) => { cleanup(); reject(new Error(`WebSocketTransport.connect: socket closed before opening (code=${ev && ev.code})`)); }; this.socket.addEventListener('open', on_open); this.socket.addEventListener('error', on_error); this.socket.addEventListener('close', on_close); }); } /** * Close the socket. Subsequent sends throw via the underlying WebSocket. * * Locally-initiated disconnect: latched so the socket's own subsequent * `close` event doesn't fire `onDisconnect` again — callers who * explicitly tore down don't expect a notification echo. */ disconnect() { this.#disconnect_fired = true; try { this.socket.removeEventListener('message', this.#on_message); this.socket.removeEventListener('close', this.#on_close); this.socket.close(); } catch (e) { // Already 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, }; } }