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