@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
207 lines (188 loc) • 7.47 kB
JavaScript
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; }
}
}