UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

207 lines (188 loc) 7.47 kB
import { assert } from "../../../../core/assert.js"; import { createSocket } from "node:dgram"; import { Transport } from "../Transport.js"; /** * Transport adapter over Node's `dgram` (UDP) socket. * * Designed for one-to-one peer relationships: each adapter instance talks to * exactly one remote endpoint. For a server with many clients, create one * adapter per client (typically after observing the first packet from each * client's source `address:port`). * * The adapter binds locally on construction. To connect to a known remote * (client pattern), pass `remote: { address, port }`. To accept inbound from * any source (server pattern), leave `remote` unset and call * {@link set_remote} after the first inbound packet identifies the peer. * * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ export class NodeUDPTransport extends Transport { #remote; #stats; #on_message; #on_close; #on_socket_error; #disconnect_fired; /** * @param {{ * bind_address?: string, * bind_port?: number, * remote?: { address: string, port: number }, * }} [options] */ constructor({ bind_address = '0.0.0.0', bind_port = 0, remote = null } = {}) { super(); assert.isString(bind_address, 'bind_address'); assert.isNonNegativeInteger(bind_port, 'bind_port'); /** @type {import('node:dgram').Socket} */ this.socket = createSocket('udp4'); /** * Remote `{address, port}` to send to. May be null until the first inbound * packet identifies the peer (server pattern). Sending before this is set throws. * @type {{address: string, port: number}|null} * @private */ this.#remote = remote; /** @private */ this.#stats = { bytes_in: 0, bytes_out: 0, packets_in: 0, packets_out: 0 }; // A socket-level error and a subsequent close are the same failure to // higher layers — fire `onDisconnect` once. /** @private */ this.#disconnect_fired = false; /** @private */ this.#on_message = (msg, rinfo) => { // If we don't have a remote yet, latch onto the first sender (server pattern). if (this.#remote === null) { this.#remote = { address: rinfo.address, port: rinfo.port }; } this.#stats.bytes_in += msg.length; this.#stats.packets_in += 1; // Buffer extends Uint8Array; safe to pass directly. this.onReceive.send2(msg, msg.length); }; /** @private */ this.#on_close = () => { if (this.#disconnect_fired) return; this.#disconnect_fired = true; this.onDisconnect.send1('socket_closed'); }; /** @private */ this.#on_socket_error = (err) => { if (this.#disconnect_fired) return; this.#disconnect_fired = true; this.onDisconnect.send1(err && err.message ? err.message : 'socket_error'); }; this.socket.on('message', this.#on_message); this.socket.on('close', this.#on_close); // A persistent error listener is required — without one, Node throws // 'error' as an uncaught exception (`Error [ERR_UNHANDLED_ERROR]`). // The transient one wired up in `connect()` is removed once bind // succeeds, leaving runtime errors unguarded otherwise. this.socket.on('error', this.#on_socket_error); this.socket.bind(bind_port, bind_address); } /** * Set or update the remote endpoint that {@link send} targets. * @param {string} address * @param {number} port */ set_remote(address, port) { assert.isString(address, 'address'); assert.isNonNegativeInteger(port, 'port'); this.#remote = { address, port }; } /** * Returns the locally bound `{address, port}`, useful for "what port did I * end up on?" when constructed with `bind_port: 0`. * @returns {{address: string, port: number}|null} */ get_bound_address() { try { return this.socket.address(); } catch (e) { return null; // socket not yet bound or already closed } } /** * @param {Uint8Array} bytes * @param {number} length */ send(bytes, length) { assert.isNonNegativeInteger(length, 'length'); if (this.#remote === null) { throw new Error("NodeUDPTransport.send: no remote set; pass `remote` to constructor or call set_remote()"); } if (length === 0) return; // dgram.send accepts a typed array; subarray to bound the length. const payload = (length === bytes.byteLength) ? bytes : bytes.subarray(0, length); this.socket.send(payload, this.#remote.port, this.#remote.address); this.#stats.bytes_out += length; this.#stats.packets_out += 1; } /** * Block until the socket is bound and ready. Useful in tests; production * code typically doesn't need to await this (sending before bind is fine — * dgram queues internally). * @returns {Promise<void>} */ connect() { return new Promise((resolve, reject) => { if (this.socket.address && this.#try_bound()) { resolve(); return; } const on_listening = () => { this.socket.off('error', on_bind_error); resolve(); }; // Bind-phase error handler is separate from the persistent // runtime handler wired up in the constructor: bind failures // should reject the connect() promise rather than fire // `onDisconnect` on a transport that never came up. const on_bind_error = (err) => { this.socket.off('listening', on_listening); // Latch so the persistent runtime handler doesn't also // fire `onDisconnect` for the same bind failure. this.#disconnect_fired = true; reject(err); }; this.socket.once('listening', on_listening); this.socket.once('error', on_bind_error); }); } /** * Close the underlying socket. After this, calls to {@link send} will fail. * * Locally-initiated disconnect: the socket's `close` event would * otherwise fire `onDisconnect` afterward, which surprises callers * who explicitly tore down the transport. Suppressed via the latch. */ disconnect() { this.#disconnect_fired = true; try { this.socket.off('message', this.#on_message); this.socket.off('close', this.#on_close); this.socket.off('error', this.#on_socket_error); this.socket.close(); } catch (e) { // socket 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, }; } /** @private */ #try_bound() { try { this.socket.address(); return true; } catch { return false; } } }