// This module is the CJS entry point for the library. import * as addon from 'warpsocket/addon-loader'; import { Worker } from 'node:worker_threads'; import * as os from 'node:os'; import * as pathMod from 'node:path'; type ChannelDebug = { channel: Uint8Array, subscribers: { [socketId: number]: /*ref count */ number } }; type SocketDebug = { ip: string, workerId: number } | { targetSocketId: number, userPrefix?: Uint8Array }; type WorkerDebug = { hasTextHandler: boolean, hasBinaryHandler: boolean, hasCloseHandler: boolean, hasOpenHandler: boolean, hasHttpHandler: boolean }; type KVDebug = { key: Uint8Array, value: Uint8Array }; declare module "warpsocket/addon-loader" { function start(bind: string): void; function registerWorkerThread(worker: WorkerInterface): number; function deregisterWorkerThread(workerId: number): boolean; function send(target: number | number[] | Uint8Array | ArrayBuffer | string | (number | Uint8Array | ArrayBuffer | string)[], data: Uint8Array | ArrayBuffer | string): number; function subscribe(socketIdOrChannelName: number | number[] | Uint8Array | ArrayBuffer | string | (number | Uint8Array | ArrayBuffer | string)[], channelName: Uint8Array | ArrayBuffer | string, delta?: number): number[]; function hasSubscriptions(channelName: Uint8Array | ArrayBuffer | string): boolean; function createVirtualSocket(socketId: number, userPrefix?: Uint8Array | ArrayBuffer | string): number; function deleteVirtualSocket(virtualSocketId: number, expectedTargetSocketId?: number): boolean; function getKey(key: Uint8Array | ArrayBuffer | string): Uint8Array | undefined; function setKey(key: Uint8Array | ArrayBuffer | string, value?: Uint8Array | ArrayBuffer | string | undefined): Uint8Array | undefined; function setKeyIf(key: Uint8Array | ArrayBuffer | string, newValue?: Uint8Array | ArrayBuffer | string | undefined, checkValue?: Uint8Array | ArrayBuffer | string | undefined): boolean; function getDebugState(mode: "channels"): ChannelDebug[]; function getDebugState(mode: "channels", channelName: Uint8Array | ArrayBuffer | string): ChannelDebug | undefined; function getDebugState(mode: "channels", filterSocketId: number): ChannelDebug[]; function getDebugState(mode: "sockets"): Record; function getDebugState(mode: "sockets", socketId: number): SocketDebug | undefined; function getDebugState(mode: "workers"): Record; function getDebugState(mode: "workers", workerId: number): WorkerDebug | undefined; function getDebugState(mode: "kv"): KVDebug[]; } /** * Incoming HTTP request passed to `handleHttpRequest`. * * Extends Node.js `stream.Readable` — `req.pipe()`, `req.on('data')`, and `req.on('end')` all * work as expected. The body is pre-read by WarpSocket and pushed synchronously as a single * chunk, so the stream is immediately readable without waiting for async I/O. * * The API is a useful subset of Node's `http.IncomingMessage`. There are no `'data'` / `'end'` * events beyond what the Readable provides, no access to the underlying socket, and no HTTP/2. * WebSocket upgrade requests are detected automatically and routed through the normal * `handleOpen` flow — they never reach `handleHttpRequest`. */ export interface HttpRequest extends NodeJS.ReadableStream { method: string; url: string; httpVersion: string; httpVersionMajor: number; httpVersionMinor: number; headers: Record; /** Raw headers as a flat alternating `[name, value, name, value, ...]` array, * preserving the original wire order. Header names are lowercased (as normalised * by the HTTP/1.1 parser). */ rawHeaders: string[]; remoteAddress: string; /** Always `true` — the body is fully pre-buffered before the handler is called. */ complete: boolean; /** Pre-read full request body. Also available via the Readable stream interface. */ body: Buffer; } /** * Outgoing HTTP response passed to `handleHttpRequest`. * * Instances are proper Node.js `stream.Writable` objects — `someReadable.pipe(res)` works. * Chunks from `write()` / `end()` and piped data are buffered; the complete response is sent * once the handler's Promise resolves. Real-time streaming to the client is not supported; * for push-based patterns use WebSockets instead. * * `setHeader()`, `getHeader()`, `removeHeader()`, `writeHead(status, [reason,] [headers])`, * `statusCode = N`, `write(chunk)` and `end([chunk])` all work as expected. * Array values for `setHeader()` produce multiple header lines (e.g. for `Set-Cookie`). * There are no trailers and no upgrade hook for non-WebSocket upgrades. */ export interface HttpResponse extends NodeJS.EventEmitter { statusCode: number; setHeader(name: string, value: string | string[]): void; getHeader(name: string): string | string[] | undefined; getHeaders(): Record; hasHeader(name: string): boolean; removeHeader(name: string): void; writeHead(statusCode: number, headers?: Record): this; writeHead(statusCode: number, reasonPhrase: string, headers?: Record): this; write(chunk: string | Buffer | Uint8Array, encoding?: string, cb?: () => void): boolean; end(chunk?: string | Buffer | Uint8Array, encoding?: string, cb?: () => void): this; /** True once `end()` has been called. */ readonly writableEnded: boolean; /** True once the 'finish' event has fired. */ readonly writableFinished: boolean; } /** * Interface that worker threads must implement to handle WebSocket events. * All handler methods are optional - if not provided, the respective functionality will be unavailable. */ export interface WorkerInterface { /** * Called when the worker is starting up, before registering with the native addon. * This allows for initialization logic that needs to run before handling WebSocket events. * @param workerArg - Optional argument passed from the start() function's workerArg option. */ handleStart?(workerArg?: any): Promise | void; /** * Handles new WebSocket connections and can reject them. If not provided, all connections are accepted. * @param socketId - The unique identifier of the WebSocket connection. * @param ip - The client's IP address. * @param headers - HTTP headers from the WebSocket handshake request. * @returns true to accept the connection, false to reject it. */ handleOpen?(socketId: number, ip: string, headers: Record): Promise | boolean; /** * Handles incoming WebSocket text messages from clients. * @param data - The message data as a string. * @param socketId - The unique identifier of the WebSocket connection. */ handleTextMessage?(data: string, socketId: number): Promise | void; /** * Handles incoming WebSocket binary messages from clients. * @param data - The message data as a Uint8Array. * @param socketId - The unique identifier of the WebSocket connection. */ handleBinaryMessage?(data: Uint8Array, socketId: number): Promise | void; /** * Handles WebSocket connection closures. * @param socketId - The unique identifier of the closed WebSocket connection. */ handleClose?(socketId: number): Promise | void; /** * Handles plain HTTP/1.1 requests arriving on the same port as the WebSocket server. * Handy for health checks, landing pages, or webhook endpoints without a second server. * HTTP requests are load-balanced across worker threads using round-robin (no per-connection * pinning), since each HTTP request is independent. * * `req` exposes `method`, `url`, `headers`, `rawHeaders`, `httpVersion`, `remoteAddress`, * and `body` (a pre-read `Buffer`). It also implements Node.js `stream.Readable` — * `req.pipe()`, `req.on('data')`, etc. work synchronously (body is pushed before your * handler is called, so no async I/O is needed before reading). * * `res` implements Node.js `stream.Writable`. Written chunks are buffered; the complete * response is sent once the handler's Promise resolves. `statusCode`, `setHeader()`, * `getHeader()`, `removeHeader()`, `writeHead()`, `write()`, and `end()` all work as * expected. Array values for `setHeader()` produce multiple header lines (e.g. `Set-Cookie`). * Real-time streaming is not supported — use WebSockets for that. * * For small JSON/HTML responses or proxying webhook bodies to `send()`, these APIs are * drop-in compatible with code written against `http.createServer`. * * There is no HTTP/2, no trailers, and no upgrade hook for non-WebSocket upgrades. * WebSocket upgrade requests are detected automatically and routed through `handleOpen`. * * @param req - The incoming request. * @param res - The response object. */ handleHttpRequest?(req: HttpRequest, res: HttpResponse): Promise | void; } // Internal map to keep Worker instances alive and allow termination const workers = new Map(); // Worker monitoring state const WORKER_TIMEOUT_MS = 3000; // 3 seconds timeout let monitoringInterval: NodeJS.Timeout | null = null; let lastMonitorTime = 0; /* * Start the worker monitoring system that pings workers and terminates hanging ones. */ function startMonitoring() { if (monitoringInterval) return; // Already running monitoringInterval = setInterval(() => { const now = Date.now(); // If the gap since the last check exceeds the timeout, the system likely resumed // from suspend or is trashing. Reset lastSeen for all workers to avoid false timeout // terminations. if (lastMonitorTime > 0 && now - lastMonitorTime > 2000) { for (const worker of workers.values()) worker.lastSeen = now; } lastMonitorTime = now; for (const [workerId, worker] of workers) { const timeSinceLastSeen = now - worker.lastSeen; if (timeSinceLastSeen > WORKER_TIMEOUT_MS) { console.error(`WarpSocket worker ${workerId} unresponsive for ${timeSinceLastSeen}ms, terminating`); workers.delete(workerId); addon.deregisterWorkerThread(workerId); worker.worker.terminate(); spawnWorker(); // Start a replacement worker continue; // Don't ping a terminated worker } worker.worker.postMessage({ type: '__ping', timestamp: now }); } // Stop monitoring if no workers left if (workers.size === 0) { clearInterval(monitoringInterval!); monitoringInterval = null; } }, 500); // Check twice per second } let workerData: {workerPath: string, workerArg: any, nodePath: string} | undefined; /** * Starts a WebSocket server bound to the given address and spawns worker threads * that handle WebSocket events. * * @param options - Configuration object: * * bind: Required. Address string to bind the server to (e.g. "127.0.0.1:8080"), * or an array of such strings to bind multiple addresses. * * workerPath: Required. Path (absolute or relative to process.cwd()) to the * worker JavaScript module. This module will be imported in each * worker thread and its exported handlers will be registered with * the native addon. Worker modules may export any subset of the * `WorkerInterface` handlers. * * threads: Optional. Number of worker threads to spawn. When a positive * integer is provided, that number of Node.js `Worker` threads * are created and set up to handle WebSocket events. When omitted, * defaults to the number of CPU cores or 4, whichever is higher. * * workerArg: Optional. Argument to pass to the handleStart() method of * worker modules, if they implement it. This allows passing * initialization data or configuration to workers. * * @returns A Promise that resolves after worker threads (if any) have been * started and the native addon has been instructed to bind to the * address. The Promise rejects if worker initialization fails. * * @throws If `options` is or the `bind` and `workerPath` properties are * missing or invalid, or if already started. */ export async function start(options: { bind: string | string[], workerPath?: string, threads?: number, workerArg?: any }): Promise { if (workerData) { throw new Error('already started'); } if (!options || !options.bind || !options.workerPath) { throw new Error('options.bind and options.workerPath are required'); } if (!pathMod.isAbsolute(options.workerPath)) { options.workerPath = pathMod.resolve(process.cwd(), options.workerPath); } workerData = {workerPath: options.workerPath, workerArg: options.workerArg, nodePath: (addon as any).__nodePath}; options.threads = options.threads == null ? Math.max(os.cpus()?.length || 1, 4) : Math.max(1, 0|options.threads); // console.log(`WarpSocket starting`, options); // Start a single worker first.. allow it to fail and throw before starting more await spawnWorker(); // Now start and await the rest in parallel const promises = []; for (let i = 1; i < options.threads; i++) { promises.push(spawnWorker()); } await Promise.all(promises); if (Array.isArray(options.bind)) { for (const b of options.bind) addon.start(b); } else { addon.start(options.bind); } } const BOOTSTRAP_WORKER = ` const { workerData, parentPort } = require('node:worker_threads'); // Load the native addon directly via process.dlopen instead of require('warpsocket/addon-loader'). // Bun (as of v1.3.11) has a bug (?) where require() in eval'd worker threads returns a cached module // object with stale/non-functional napi function bindings from the main thread's isolate. // Using process.dlopen ensures neon properly initializes fresh function bindings for this // worker's V8/JSC isolate, sharing the same underlying native statics (DashMaps, atomics, etc.). const addon = { exports: {} }; process.dlopen(addon, workerData.nodePath); const nativeAddon = addon.exports; // Handle ping messages from main thread parentPort.on('message', (msg) => { if (msg && msg.type === '__ping') { parentPort.postMessage({ type: '__pong', timestamp: msg.timestamp }); } }); (async () => { const workerModule = await import(workerData.workerPath); // Call handleStart if it exists, passing workerArg if (typeof workerModule.handleStart === 'function') { await workerModule.handleStart(workerData.workerArg); } // WarpHttpRequest / WarpHttpResponse are defined ONCE per worker here, so that all // per-request HTTP methods (setHeader, getHeader, …) live on the shared prototype // rather than being re-assigned as own properties on every individual request object. const { Readable, Writable } = require('node:stream'); // req: proper Node.js Readable. Body is pre-buffered by WarpSocket and pushed in the // constructor, so req.pipe(), req.on('data'), req.on('end'), etc. all work immediately. // HTTP metadata and the raw body Buffer are set directly by Rust after construction. class WarpHttpRequest extends Readable { constructor() { super({ read() {} }); // Properties (method, url, headers, rawHeaders, body, …) are set by Rust // on the instance immediately after new WarpHttpRequest() returns. // The body is always pre-buffered, so complete is always true. this.complete = true; } } // res: proper Node.js Writable with the http.ServerResponse surface. // Written chunks are buffered; the complete response is returned once the handler resolves. // Real-time streaming to the client is not supported — use WebSockets for that. class WarpHttpResponse extends Writable { constructor() { super(); this.statusCode = 200; this._headers = Object.create(null); this._bodyChunks = []; } _write(chunk, _enc, callback) { this._bodyChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); callback(); } setHeader(name, value) { const k = String(name).toLowerCase(); this._headers[k] = Array.isArray(value) ? value.map(String) : String(value); } getHeader(name) { return this._headers[String(name).toLowerCase()]; } getHeaders() { return Object.assign(Object.create(null), this._headers); } hasHeader(name) { return Object.prototype.hasOwnProperty.call(this._headers, String(name).toLowerCase()); } removeHeader(name) { delete this._headers[String(name).toLowerCase()]; } writeHead(code, reasonOrHeaders, headers) { this.statusCode = (code | 0) || 200; const h = headers && typeof headers === 'object' ? headers : reasonOrHeaders && typeof reasonOrHeaders === 'object' ? reasonOrHeaders : null; if (h) for (const k of Object.keys(h)) this.setHeader(k, h[k]); return this; } // Collect the buffered response for the native side _collect() { return { status: this.statusCode, headers: Object.assign(Object.create(null), this._headers), body: Buffer.concat(this._bodyChunks), }; } // Called by Rust after the user handler resolves: drains any pending pipe data // and returns the complete buffered response as { status, headers, body }. async _finalize() { // Yield a tick so that any synchronously-set-up pipe (req.pipe(res)) // has a chance to drain. Stream internals use process.nextTick / // queueMicrotask, which run before setImmediate. await new Promise((resolve) => setImmediate(resolve)); // Wait for the handler to call res.end() (and all data to flush). // We never force-end here: like real Node.js http.ServerResponse, // it's the handler's responsibility to call end(). This also // correctly handles async pipes such as createReadStream().pipe(res). if (!this.writableFinished) { await new Promise((resolve) => { if (this.writableFinished) { resolve(); return; } this.once('finish', resolve); }); } return this._collect(); } } // Shallow copy to avoid mutating an ESM namespace. const registered = Object.assign(Object.create(null), workerModule); // Pass WarpHttpRequest and WarpHttpResponse to Rust so it can instantiate req/res // directly and call res._finalize() after the handler resolves. const workerId = nativeAddon.registerWorkerThread(registered, WarpHttpRequest, WarpHttpResponse); parentPort.postMessage({ type: 'registered', workerId }); })(); `; function spawnWorker(): Promise { return new Promise((resolve, reject) => { let running = false; const w = new Worker(BOOTSTRAP_WORKER, { eval: true, workerData }); let workerId: number; w.on('message', (msg) => { if (msg && (msg as any).type === 'registered') { workerId = (msg as any).workerId; // Worker IDs are 1-based. A return of 0 (or non-positive) means the native // registerWorkerThread call was a no-op — likely due to a stale cached module // object in the runtime (a known Bun bug with napi addons in worker threads). if (!workerId || workerId <= 0) { reject(new Error( `warpsocket: registerWorkerThread returned invalid worker_id=${workerId}. ` + `The native addon may not be functioning correctly in worker threads. ` + `Loaded from: ${workerData!.nodePath}` )); w.terminate(); return; } // Verify the main thread can actually see this worker in the shared native state. // If this fails, the worker loaded a separate copy of the native addon. const debugState = addon.getDebugState('workers', workerId); if (!debugState) { reject(new Error( `warpsocket: worker registered with id=${workerId} but main thread ` + `cannot see it via getDebugState. The worker may have loaded a separate ` + `instance of the native addon. Loaded from: ${workerData!.nodePath}` )); w.terminate(); return; } workers.set(workerId, {worker: w, lastSeen: Date.now()}); // console.log(`WarpSocket worker #${workerId} registered`); startMonitoring(); // Start monitoring when we have workers running = true; resolve(); } else if (msg && (msg as any).type === '__pong') { workers.get(workerId)!.lastSeen = Date.now(); } }); w.on('error', (err) => { console.error('WarpSocket worker thread error:', err); }); w.on('exit', (code) => { if (!workers.has(workerId)) return; console.error('WarpSocket worker thread exited with code', code); workers.delete(workerId); addon.deregisterWorkerThread(workerId); if (running) { // Start a replacement worker spawnWorker(); } else { reject(new Error(`Could not start worker`)); } }); }); } /** * Sends data to a specific WebSocket connection, multiple connections, or broadcasts to all subscribers of a channel. * @param target - The target for the message: * - A socket ID (number): sends to that specific socket * - A channel name (Buffer, ArrayBuffer, or string): broadcasts to all subscribers of that channel * - An array of socket IDs and/or channel names: sends to each socket and broadcasts to each channel * @param data - The data to send (Buffer, ArrayBuffer, or string). * @returns the number of recipients that got sent the message. * * When target is a virtual socket with user prefix (or a channel that has such a subscriber), that prefix is prepended to the message. In case of a text message, the prefix bytes are assumed to be valid UTF-8. * * When target is an array, the message is sent to each target in the array. */ export const send = addon.send; /** * Subscribes one or more WebSocket connections to a channel, or copies subscriptions from one channel to another. * Multiple subscriptions to the same channel by the same connection are reference-counted. * * @param socketIdOrChannelName - Can be: * - A single socket ID (number): applies delta to that socket's subscription * - An array of socket IDs (number[]): applies delta to all sockets' subscriptions * - A channel name (Buffer/ArrayBuffer/string): applies delta to all subscribers of this source channel * - An array mixing socket IDs and channel names: applies delta to sockets and source channel subscribers * @param channelName - The target channel name (Buffer, ArrayBuffer, or string). * @param delta - Optional. The amount to change the subscription count by (default: 1). * Positive values add subscriptions, negative values remove them. When the count reaches zero, the subscription is removed. * @returns An array of socket IDs that were affected by the operation: * - For positive delta: socket IDs that became newly subscribed (reference count went from 0 to positive) * - For negative delta: socket IDs that became completely unsubscribed (reference count reached 0) */ export const subscribe = addon.subscribe; /** * Exactly the same as `subscribe`, only with a negative delta (defaulting to 1, which means a single unsubscribe, or a subscribe with delta -1). */ export function unsubscribe(socketIdOrChannelName: number | number[] | Uint8Array | ArrayBuffer | string | (number | Uint8Array | ArrayBuffer | string)[], channelName: Uint8Array | ArrayBuffer | string, delta: number = 1): number[] { return addon.subscribe(socketIdOrChannelName, channelName, -delta); } /** * **DEPRECATED:** Use subscribe(fromChannelName, toChannelName) instead. * * Copies all subscribers from one channel to another channel. Uses reference counting - if a subscriber * is already subscribed to the destination channel, their reference count will be incremented instead * of creating duplicate subscriptions. * @param fromChannelName - The source channel name (Buffer, ArrayBuffer, or string). * @param toChannelName - The destination channel name (Buffer, ArrayBuffer, or string). * @returns An array of socket IDs that were newly added to the destination channel. Sockets that were * already subscribed (and had their reference count incremented) are not included. */ export function copySubscriptions(fromChannelName: Uint8Array | ArrayBuffer | string, toChannelName: Uint8Array | ArrayBuffer | string): number[] { const result = addon.subscribe(fromChannelName, toChannelName); return result as number[]; } /** * Checks if a channel has any subscribers. * @param channelName - The name of the channel to check (Buffer, ArrayBuffer, or string). * @returns True if the channel has subscribers, false otherwise. */ export const hasSubscriptions = addon.hasSubscriptions; /** * Creates a virtual socket that points to an actual WebSocket connection. * Virtual sockets can be subscribed to channels, and messages will be relayed to the underlying actual socket. * This allows for convenient bulk unsubscription by deleting the virtual socket. * Virtual sockets can also point to other virtual sockets, creating a chain that resolves to an actual socket. * @param socketId - The identifier of the actual WebSocket connection or another virtual socket to point to. * @param userPrefix - Optional user prefix (up to 15 bytes) that will be prepended to all messages sent to this virtual socket (possibly through a channel). For text messages, this prefix is assumed to be valid UTF-8. * @returns The unique identifier of the newly created virtual socket, which can be used just like another socket. */ export const createVirtualSocket = addon.createVirtualSocket; /** * Deletes a virtual socket and unsubscribes it from all channels. * This is a convenient way to bulk-unsubscribe a virtual socket from all its channels at once. * @param virtualSocketId - The unique identifier of the virtual socket to delete. * @param expectedTargetSocketId - Optional. If provided, the virtual socket will only be deleted * if it points to this specific target socket ID. This can help prevent unauthorized unsubscribes. * @returns true if the virtual socket was deleted, false if it was not found or target didn't match. */ export const deleteVirtualSocket = addon.deleteVirtualSocket; /** * Reads the raw bytes stored for a key in the shared in-memory store. * @param key - Key to read (Buffer, ArrayBuffer, or string). * @returns A Uint8Array when the key exists, or undefined otherwise. */ export const getKey = addon.getKey; /** * Stores or deletes a value in the shared key/value store. * @param key - Key to upsert (Buffer, ArrayBuffer, or string). * @param value - Optional value to store. Pass `undefined` to delete the key instead. * @returns The previous value as a Uint8Array when the key existed, or undefined if it did not. */ export const setKey = addon.setKey; /** * Atomically updates a key only when its current value matches the expected check value. * @param key - Key to update (Buffer, ArrayBuffer, or string). * @param newValue - Optional replacement value. Pass `undefined` to delete the key on success. * @param checkValue - Optional expected value. Pass `undefined` to require that the key is absent. * @returns true when the compare-and-set succeeds, false otherwise. */ export const setKeyIf = addon.setKeyIf; /** * Retrieves debug state information about the internal WarpSocket data structures. * @param mode - The type of data to retrieve: "channels" for channel subscriptions, "sockets" for socket details, "workers" for worker thread info, "kv" for key-value store entries. * @param singleKey - Optional. When provided, returns only the data for a specific item: for "channels", a channel name (bytes) or socket ID (number); for "sockets" and "workers", a socket ID or worker ID (number); for "kv", a key (bytes). * @returns For "channels" and "kv", an array of objects with detailed state information, capped at 2000 entries. For "sockets" and "workers", an object keyed by socketId/workerId with values being the debug info objects. When singleKey is provided, a single object or undefined is returned instead of an array/object (except when mode is "channels" and singleKey is a number). */ export const getDebugState = addon.getDebugState;