Handlers

A handler is a plain JavaScript module. The broker loads it once, opens its port, and calls its exports as traffic arrives. What a module must export is fixed by its transport — TCP or UDP — read from the filename. Proposed the signatures below.

The filename grammar

A module is an entry point only if it is a top-level file whose name matches one of these patterns:

<name>.tcp.<port>.port.js     a TCP handler on <port>
<name>.udp.<port>.port.js     a UDP handler on <port>
TokenRule
<name>A label. Surfaces in logs and as ctx.name; has no effect on what is opened. Must be a valid path segment (see Files).
tcp / udpThe transport. Selects the contract the module must satisfy.
<port>Integer 165535. Authoritative — exactly the port opened on the OS firewall.
One handler per protocol+port. If two files claim the same pair, the later one is refused and logged; the first keeps the port.

TCP — handle(conn, ctx)

Exported by a .tcp. module. Called once per accepted connection, after the broker has screened it against the access rules. conn is a Node socket-like duplex stream — read and write it, listen for data, end, error, close; end it with conn.end() or drop it with conn.destroy(). You never call listen() and never set up TLS.

// echo.tcp.7000.port.js
export function handle(conn, ctx) {
  conn.setEncoding("utf8");
  conn.write(`hello from :${ctx.port}\n`);
  conn.on("data", (chunk) => conn.write(chunk));
  conn.on("error", () => conn.destroy());
}

handle may be async — the broker does not wait on the returned promise; it is your handle on the connection's lifetime.

UDP — message(msg, rinfo, ctx)

Exported by a .udp. module. UDP is connectionless — there is nothing to hand off — so the broker binds the socket, checks each datagram's source, and calls message per datagram. msg is a Buffer; rinfo is the sender ({ address, port, family, size }). Reply with the broker-provided ctx.reply(buf, rinfo), which uses the already-bound listening socket.

// ping.udp.9000.port.js
export async function message(msg, rinfo, ctx) {
  const n = (await ctx.shared.get("count")) ?? 0;
  await ctx.shared.set("count", n + 1);
  ctx.reply(Buffer.from(`pong #${n + 1}\n`), rinfo);
}

Lifecycle — open(ctx) / close(ctx)

Both are optional and apply to either transport.

ExportWhenUse for
open(ctx)Once, when the module is loaded — before the first connection or datagram.Seeding ctx.shared, warming a cache, subscribing to a channel. May be async; the broker awaits it before delivering traffic.
close(ctx)Once, when the module is being unloaded — on shutdown or a reload that replaces it.Releasing whatever open acquired. Also observe ctx.signal to stop in-flight work.

The ctx object

Every export receives the same shape. Proposed its fields:

FieldTypeMeaning
ctx.namestringThe <name> from the filename.
ctx.portnumberThe port this handler serves.
ctx.protocolstring"tcp" or "udp".
ctx.sharedobjectThe broker's in-memory shared store — get/set/delete plus publish/subscribe, visible across connections and handlers. Full API on Shared.
ctx.signalAbortSignalFires when the handler is shutting down or being reloaded. Pass it to timers, loops and fetch so in-flight work aborts cleanly.
ctx.replyfunctionUDP only — reply(buf, rinfo) sends a datagram back over the listening socket.

Concurrency

The module is loaded once and that instance serves many concurrent connections or datagrams — the broker simply calls the export again for each. There is no per-connection module instance. Anything that must be shared between calls belongs in ctx.shared (durable to the process) or, for durability beyond the process, in datahoster — not in a module-level variable you expect the broker to isolate.

See also