npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

node-serialport-server

v2.0.0

Published

Remote serialport server

Readme

Remote-Serialport

Language: English · 中文

English

⚠ Node.js only. This library uses Node's Buffer, worker_threads, node:http2, the native serialport binding, and other Node-specific APIs. It does not work in browsers — bundling it via webpack / Vite / Rollup will fail at Buffer/stream/net imports. Even though socket.io and ws themselves have browser builds, this library is the Node side of a Node↔Node deployment. For a browser front-end, write a thin app-layer adapter against your own WebSocket / WebRTC and forward bytes — don't try to load this package in the browser.

Proxy a host's physical serial ports over the network: the server holds the real serial ports; the client connects and gets a local virtual serial port (a mock-binding–backed SerialPortStream) that mirrors a remote one. Reads/writes on the virtual port are forwarded to the physical port, and vice versa — so existing Node serialport code (serialport, modbus-serial, …) works across the network with almost no changes.

This project is split into three packages:

| Package | npm | Role | |---|---|---| | remote-serialport-types | remote-serialport-types | Shared protocol types & abstract contracts (consumed by the other two via git submodule). | | remote-serialport-server | node-serialport-server | Owns the physical serial ports; exposes them over socket.io. | | remote-serialport-client | node-serialport-client | Connects to the server; surfaces remote ports as local virtual ports. |

Developing from source? The types package is included as a git submodule under src/types/remote-serialport-types. After git submodule update --init, run npm install inside that submodule directory too (it has its own dependencies) so tsc resolves all types.

Two modes

  • Namespace mode — one socket.io namespace = one remote serial port. client.connect("/dev/ttyUSB0", …). Simple and socket.io-idiomatic. Multiple connect() calls reuse the client's single transport connection (socket.io namespace multiplexing), so one connection can still carry many ports.
  • Mux mode — one connection on a "mux namespace" carries any number of remote ports, addressed dynamically by path inside the messages. For cases where you don't want a namespace per port (e.g. IoT mesh, dynamic addressing). client.mux("/site-A").open("/dev/ttyUSB0", …).

The two modes share the same protocol shape; the only difference is whether the socket itself identifies the port (namespace mode, payloads are raw bytes) or each payload carries a path (mux mode).

Install

npm install node-serialport-server   # server
npm install node-serialport-client   # client

Server

Namespace mode

const { RemoteSerialportServer } = require("node-serialport-server");

const server = new RemoteSerialportServer(
  { cors: { origin: "*", methods: ["GET", "POST"] } }, // socket.io ServerOptions
  17991,                                               // port
  { auto_pipe: true /* , strict_path, serialport_factory, ... */ }
);
server.listen();

// REQUIRED: register an `on("connection", ...)` listener — even if its body is empty.
// Without this call, the underlying socket.io namespace's `connection` handler is never
// wired, and the server accepts TCP connections but never instantiates the per-connection
// wrapper — so auth, ACL, `auto_pipe` and port routing do NOT run. (Library logs a `warn`
// 1.5 s after `listen()` if no listener is registered.)
server.of().on("connection", (socket) => {
  // With `auto_pipe: true` you can leave this empty. Add hooks here only if you need to
  // observe / transform bytes in transit:
  // socket.port.on("data", (chunk) => console.log("device says", chunk));
  // socket.port.on("write-command", (chunk) => console.log("client wrote", chunk));
});

// Without `auto_pipe`, call `socket.pipe()` yourself inside the connection handler.
// (Don't both `pipe()` AND re-emit `serialport_packet` manually — data would be forwarded twice.)

Gotcha: auto_pipe: true is not "set and forget." It enables the post-handshake auto-pipe step on each accepted socket, but the namespace's socket.io connection event only gets wired when you call server.of(...).on("connection", listener). An empty () => {} listener is enough; missing the call entirely will silently drop every client. The 1.5-second post-listen() warning catches this in dev; ship code should still register explicitly.

All RemoteSerialServerOptions:

| Option | Default | What it does | |---|---|---| | serialport_namespace_regexp | /^(\/dev\/tty(USB\|AMA\|ACM)\|\/COM)[0-9]+$/ | Regex namespaces must match to be accepted as serial-port paths. Override to allow custom paths like /dev/ttyMODBUS or /dev/ttyUSB100 (the default's [0-9]+$ tail rejects suffixes that aren't pure digits). | | strict_path | true | If false, namespace is just a routing label and client's options.path is used (still regexp-validated). | | auto_pipe | false | If true, every accepted connection auto-pipe()s (read direction). Recommended for the common "pure relay" case — keeps the connection handler empty. | | serialport_factory | (opts) => new SerialPort(opts) | Inject a mock-backed factory in tests. | | port_list_provider | () => SerialPort.list() | Override what serialport_list RPC returns. Pair with serialport_factory in tests: real SerialPort.list() doesn't see SerialPortMock ports, so if you mock the factory you usually need to mock this too. | | logger | warn/error → console, debug/info silent | Inject pino / winston / your own Logger (see below). | | multi_access | 'reject' | 'shared' lets multiple clients connect to the same physical path (refcounted). | | shared_mode | 'fifo' | Only used when multi_access='shared'. See Shared mode. | | cow_snapshot_buffer_bytes | 65536 | Ring buffer size for shared_mode: 'cow-snapshot'. | | txn_timeout_ms | 5000 | Per-transaction timeout; reset on each serialport_send_chunk. Drops buffered chunks if no _end/_abort arrives. | | txn_timeout_action | 'log' | 'log' / 'state' (also emit serialport_state: ERROR) / 'both'. | | auth_validator | — | Credential validator. See Authentication and ACL. | | acl | — | Per-operation hooks (can_open / can_write / can_read). See Authentication and ACL. | | transport_server | new SocketIoServer(...) | Inject a custom transport (e.g. NodeIpcServer). See Transports. |

Mux mode

server.mux().on("connection", (socket) => {
  socket.pipe(); // forward reads for every port on this connection (existing and future)
  // Per-port proxies: socket.port("/dev/ttyUSB0").on("data", …)
});

server.mux("/site-A") (or a regexp) to use a specific / dynamic mux namespace; default /.

Client

Namespace mode — auto open

const { RemoteSerialportClient } = require("node-serialport-client");
const { ByteLengthParser } = require("serialport");

const rsc = new RemoteSerialportClient("ws://localhost:17991");

// connect to the remote port (the 1st arg is the socket.io namespace; options.path is the real
// remote serial path — usually the same string). The port is opened automatically after handshake.
const conn = rsc.connect("/dev/ttyUSB0", { path: "/dev/ttyUSB0", baudRate: 115200 });

// map the remote port to a local virtual port path and get a SerialPortStream-compatible object
const port = conn.create_port("/dev/ttyV0").get_port({ baudRate: 115200, autoOpen: true });

port.pipe(new ByteLengthParser({ length: 30 })).on("data", (data) => console.log(data));
port.write(Buffer.from([0x01, 0x02, 0x03])); // forwarded to the remote physical port

console.log(conn.state); // "idle" | "opening" | "open" | "closing" | "closed" | "error"

Namespace mode — manual open

const conn = rsc.connect("/dev/ttyUSB0");      // connect only, don't open yet
// ...later...
conn.open({ path: "/dev/ttyUSB0", baudRate: 9600 });

With modbus-serial

const ModbusRTU = require("modbus-serial");
const rsc = new RemoteSerialportClient("ws://localhost:17991");
const conn = rsc.connect("/dev/ttyUSB0", { path: "/dev/ttyUSB0", baudRate: 9600 });
const port = conn.create_port("/dev/ttyV0").get_port({ baudRate: 9600 });

const modbus = new ModbusRTU(port);
modbus.setID(1);
modbus.readHoldingRegisters(3, 2).then(console.log).catch(console.error);

Mux mode

const mux = rsc.mux();                 // or rsc.mux("/site-A")
mux.open("/dev/ttyUSB0", { path: "/dev/ttyUSB0", baudRate: 115200 });
mux.open("/dev/ttyACM0", { path: "/dev/ttyACM0", baudRate: 9600 });

const portA = mux.create_port("/dev/ttyUSB0", "/dev/ttyVA").get_port({ baudRate: 115200, autoOpen: true });
const portB = mux.create_port("/dev/ttyACM0", "/dev/ttyVB").get_port({ baudRate: 9600, autoOpen: true });

console.log(mux.get_state("/dev/ttyUSB0"));

Remote port control / discovery (RPC)

These act on the physical port on the server (the local virtual port's own set/get/update/flush still go to the local mock binding and are unaffected). get/list return a Promise (socket.io ack under the hood, ~5s timeout).

const ports  = await conn.list_ports();          // PortInfo[] — server host's SerialPort.list()
const status = await conn.get_remote_status();   // { cts, dsr, dcd } of the real port
conn.set_remote({ dtr: true, rts: false });      // toggle modem control lines on the real port
conn.update_remote({ baudRate: 9600 });          // change baud rate on the real port
conn.flush_remote();                             // discard the real port's buffers

// mux variants take the remote path (list_ports is server-wide, no path):
const muxStatus = await mux.get_remote_status("/dev/ttyUSB0");
mux.set_remote("/dev/ttyUSB0", { dtr: true });
const muxPorts  = await mux.list_ports();

The server reads the port list from () => SerialPort.list() by default; pass port_list_provider in the server options to override (e.g. in tests, where SerialPort.list() doesn't see mock ports).

Multiple servers

const { RemoteSerialportServerManager } = require("node-serialport-server");
const mgr = new RemoteSerialportServerManager();
mgr.create("line-A", { cors: { origin: "*" } }, 17991);
mgr.create("modbus", { cors: { origin: "*" } }, 17992);
mgr.listen_all();
// mgr.get("line-A"), mgr.servers, mgr.close_all()

Disconnect

rsc.disconnect("/dev/ttyUSB0"); // one socket
rsc.disconnect();               // everything + drop all virtual ports + reset mock registry

Multi-chunk transactions (txn)

For writes that span multiple chunks and must be atomic on the device side (e.g. one Modbus PDU sent as several pieces; network may delay/drop chunks; the server must not write partial bytes).

const portInst = conn.create_port("/dev/ttyV0");

// build the transaction; server starts buffering on .txn() (sends serialport_send_begin)
const tx = portInst.txn();
tx.write(Buffer.from([0x01, 0x03]));
tx.write(Buffer.from([0x00, 0x00, 0x00, 0x02]));
await tx.end(); // Promise resolves when the FULL buffer has drained on the physical port

// scoped: auto end on success, auto abort on throw
await portInst.with_txn(async (tx) => {
  tx.write(part1);
  if (some_error) throw new Error("rollback"); // auto-aborts; bytes never reach device
  tx.write(part2);
});
  • Server-side timeout (txn_timeout_ms, default 5s, resets on every chunk) drops buffered chunks if no _end / _abort arrives.
  • Only _end consumes a slot in the client's send window; _begin / _chunk / _abort are free.

Logger

RemoteSerialServerOptions.logger (server) and RemoteSerialportClientOptions.logger (client) accept any object matching:

interface Logger {
  debug(message: string, ...meta: unknown[]): void;
  info(message: string, ...meta: unknown[]): void;
  warn(message: string, ...meta: unknown[]): void;
  error(message: string, ...meta: unknown[]): void;
}

Default: warn / error to console, debug / info discarded. Plug in pino/winston/etc. for production logging.

Reconnect behavior (client)

Both connect() and mux() survive transport drops:

  • Namespace mode: _open_options is preserved; on reconnect the client auto-replays serialport_open after the handshake. The server reopens the physical port.
  • Mux mode: every mux.open(remote_path, options) is persisted in an _active_opens map and replayed on every reconnect. Removed only by mux.close(remote_path) or rsc.disconnect().
  • In-flight RPCs (get_remote_status / list_ports): replayed on reconnect with the original deadline (not reset). Configure via the constructor:
const rsc = new RemoteSerialportClient("ws://localhost:17991", {
  logger: my_logger,
  rpc: { timeout_ms: 5000, replay_on_reconnect: true }, // defaults shown
  txn_id_allocator: "counter", // or "uuid" or () => string
});

Shared mode (multi_access: 'shared')

Multiple clients can connect to the same physical path; the server refcounts one SharedPortSession per path. Works for both namespace mode and mux mode; a namespace client and a mux client on the same path share the same underlying physical port.

const server = new RemoteSerialportServer({ cors: { origin: "*" } }, 17991, {
  multi_access: "shared",
  shared_mode: "fifo", // or "fifo-strict" / "batch" / "pipe"
});

| shared_mode | Write scheduling | Reads | |---|---|---| | 'fifo' (default) | Strict by _begin arrival order; head-of-line blocking (B's _end arriving first does NOT cut in front of A whose _begin arrived earlier). | Fan-out to all subscribers. | | 'fifo-strict' | Same as 'fifo' + only one in-flight write at a time across all clients (server stop-and-wait). | Fan-out. | | 'batch' | Immediate flush on _end/single-shot arrival — _end-order on the wire, no HOL. | Fan-out. | | 'pipe' | First-connected client is writer; others are read-only (their writes are dropped, drain still acked). Writer disconnect → next-earliest client auto-promoted, gets serialport_state: OPEN, message: 'promoted to writer (pipe mode)'. | Fan-out. | | 'cow-write-isolate' | Immediate flush; server remembers each subscriber's recent writes and routes prefix-matching device bytes only to the originator. | Echo-filtered fan-out: matched bytes → writer only; unmatched → everyone. | | 'cow-snapshot' | Immediate flush. | Fan-out + per-subscriber catch-up: a new subscriber receives the ring buffer (cow_snapshot_buffer_bytes, default 64 KB) as serialport_packets before live forwarding begins. | | 'cow-virtual-port' | Immediate flush + per-subscriber set/update state cache (last-write-wins on the physical port; library logs a warn on conflict). | Fan-out. |

Writer policy + scheduling are per-path. Drain acks for each write route back to the originating client only (no cross-client window stalls).

cow-write-isolate caveat: the echo filter is byte-level prefix matching. Devices that transform written bytes before echoing (line-ending translation, transcoding, etc.) defeat the filter — those unmatched bytes fall through to the global fanout and are observed by every subscriber. Best for line-echo or full-duplex devices that emit verbatim back; don't rely on it for true byte-level isolation.

Hooking socket.port in shared mode: the per-socket port exposed in the connection handler is the shared physical-port EventEmitter, not a per-subscriber view. So socket.port.on("data", …) and socket.port.on("write-command", …) fire once per event for every subscriber socket that installed the listener — and any closure variables (socket.identity, the user tag you captured) refer to the listener-installing socket, not the writing client. Practical patterns:

// WRONG in shared mode: installs N listeners, each fires for every write,
// and `id.user` tags writes with the wrong subscriber.
server.of().on("connection", (socket) => {
  const id = socket.identity;
  socket.port.on("write-command", (chunk) => log(`${id.user} wrote ${chunk}`));
});

// Right: install once, treat the chunk as anonymous (no per-write identity).
let installed = false;
server.of().on("connection", (socket) => {
  if (installed) return; installed = true;
  socket.port.on("write-command", (chunk) => log(`someone wrote ${chunk}`));
});

If you need writer attribution in shared mode today, gate writes through acl.can_write (which is called per-subscriber with the originator's identity) and record there. A subscriber_id argument on write-command listeners is a candidate API for a future minor version.

Testing shared mode with SerialPortMock: when the last subscriber leaves, the SharedPortSession closes the underlying physical port. If your test harness captured the mock via serialport_factory to push synthetic device output, that captured handle is now closed and emitData(...) throws Port must be open to pretend to receive data. The next subscriber gets a fresh mock via the factory — so don't capture once; re-capture on every factory invocation, and guard emitData calls with mock.isOpen plus a try/catch:

let captured = null;
new RemoteSerialportServer({}, 0, {
  multi_access: "shared", shared_mode: "fifo",
  serialport_factory: (opts) => { const m = new SerialPortMock(opts); captured = m; return m; }
});
setInterval(() => {
  if (captured === null || captured.isOpen === false) return;
  try { captured.port.emitData(buf); } catch { /* port flipped to closed between check + emit */ }
}, 400);

Authentication and ACL (auth_validator + acl)

const server = new RemoteSerialportServer({ cors: { origin: "*" } }, 17991, {
  auth_validator: async (credential, meta) => {
    // credential is whatever the client put in its `auth` field (JWT, API key, ...).
    // `meta` = { transport_id, endpoint_label }.
    const user = await my_verify(credential);
    if (user === null) return { ok: false, message: "bad token" };
    return { ok: true, identity: user };  // attached to socket.identity for ACL hooks
  },
  acl: {
    can_open: (identity, path) => identity.role === "admin" || allowed_paths_for(identity).includes(path),
    can_write: (identity, path) => identity.role !== "viewer",
    can_read:  (identity, path) => true
  }
});

auth_validator runs once per accepted transport (and again on socket.io reconnect, since Manager({auth}) is replayed automatically). Rejection sends serialport_state: ERROR { message } and closes the transport — the user's connection handler never fires for that socket.

The acl hooks gate per-operation:

| Hook | Trigger | Deny effect | |---|---|---| | can_open(identity, path) | client's serialport_open / serialport_mux_open | serialport_state: ERROR { message: "open denied: ..." }. Port stays IDLE. | | can_write(identity, path) | every write-direction message: _send_packet, _send_begin, _send_end, _set, _update, _flush | serialport_state: ERROR { message: "write denied: ..." } + serialport_drain (so the client's backpressure window stays clean). Bytes are dropped before reaching the device. | | can_read(identity, path) | once per open | one-shot serialport_state: ERROR { message: "read denied: ..." } + silent filter on subsequent device→client packets for this client. |

The client surfaces ERROR states through the local virtual stream's 'error' event, so unmodified existing serialport code reacts to deny states the same way it does to device errors.

Encryption / TLS

This library doesn't wrap transports in TLS itself. For each transport:

  • socket.io: build your own https.Server and attach the library's socket.io instance to it via the public srv.io accessor — do NOT call srv.listen() afterwards (it would create a second plain-HTTP listener):

    const httpsServer = require("https").createServer({ key, cert });
    const srv = new RemoteSerialportServer({ cors: { origin: "*" } }, 17991, { /* ... */ });
    srv.io.attach(httpsServer);                       // attach socket.io to https
    srv.of("/dev/ttyUSB0").on("connection", () => {});  // friction#9 — still required
    httpsServer.listen(17991);                        // listen on https
    // (skip srv.listen() in this mode)
  • Raw TCP / Raw WebSocket / HTTP/2: built-in { tls: ... } option on the transport ctor.

  • gRPC: built-in credentials option; pass grpc.ServerCredentials.createSsl(...).

  • MQTT: connect to an mqtts:// broker URL; pass any TLS options the mqtt lib accepts via mqtt_options.

  • WebRTC: SCTP-over-DTLS-over-ICE is always encrypted by spec — no app-layer cert plumbing.

  • IPC: OS-level sandbox / process isolation.

mTLS for socket.io: pass {requestCert: true, ca, ...} to https.createServer(...).

Wire backpressure (device→client)

When socket.pipe() is forwarding physical bytes faster than the client can drain them, the server samples the underlying socket.io transport's bufferedAmount after each emit and pause()s the physical port once it crosses WIRE_BACKPRESSURE_HIGH_WATER (1 MB). A periodic check (WIRE_BACKPRESSURE_POLL_MS, 50 ms) resume()s it once the buffer falls below WIRE_BACKPRESSURE_LOW_WATER (256 KB). Only active in multi_access: 'reject' mode — shared mode pause/resume would starve other subscribers, so it's disabled there. IPC transports report get_buffered_amount() as null, so wire backpressure is skipped on IPC.

Transports (AbsTransport)

The wire protocol is transport-agnostic. The default transport is socket.io (TCP/WebSocket); a second built-in transport speaks Node IPC (worker_threads.MessagePort / Electron utility process MessagePortMain). Swap by injecting transport_server into the constructor.

IPC label is the serial port path. new NodeIpcServer(port, label) — the label argument becomes the transport's endpoint_label, which becomes the implicit serialport_path for sockets on this IPC pair. So in namespace mode, pass the actual device path (e.g. "/dev/ttyUSB0", "COM3"), not a debug name. (server.of(label).on("connection", …) on an IPC server does not route by label — there is only one connection per IPC pair — so the constructor label is the only place the path is set.) In mux mode the label is unused for routing; pass anything readable (the path comes from each open() payload).

import { MessageChannel } from "worker_threads";
import { RemoteSerialportServer, NodeIpcServer } from "node-serialport-server";

const channel = new MessageChannel();
const server = new RemoteSerialportServer(undefined, 0, {
    // Namespace mode: `label` IS the serial port path. Pass the device path here.
    transport_server: new NodeIpcServer(channel.port1, "/dev/ttyUSB0"),
    serialport_factory: (opts) => new SerialPort(opts),
    auto_pipe: true
});
server.listen(); // no-op on IPC
server.of().on("connection", (socket) => {
  console.log("client opened", socket.serialport_path); // "/dev/ttyUSB0"
});
// pass channel.port2 to the consumer process / utility / worker via postMessage / Worker workerData

The client side is symmetric — inject NodeIpcClient(port, label) (same rule: namespace mode label = device path) via transport_client. See client README.

IPC transport differences vs socket.io:

| Behavior | socket.io | Node IPC | |---|---|---| | Reconnect | auto-reconnect, client RPC replay | no reconnect — port pair is single-shot | | Wire backpressure | sampled via bufferedAmount | get_buffered_amount() returns null; skipped | | Endpoint multiplexing | one namespace per of(label) | single endpoint per IPC pair; use mux mode for many ports | | Connection model | many clients → server | one peer pair per server instance |

Use IPC when both sides live in the same Node process tree (worker threads, Electron utility process). Use socket.io for cross-host / cross-network.

Raw TCP / Unix Domain Socket / Named Pipe (RawTcpServer)

A length-prefix-framed JSON envelope transport over net.Socket. Use when you want minimal dependency footprint, run on a closed LAN where you don't need socket.io's HTTP polling fallback, or want to talk through an OS-level pipe (UDS / Named Pipe) instead of TCP.

const { RemoteSerialportServer, RawTcpServer } = require("node-serialport-server");

// Plain TCP, mux mode (default — many namespaces share one connection)
new RemoteSerialportServer(undefined, 0, {
    transport_server: new RawTcpServer({ port: 17993 }),
    auto_pipe: true
});

// TLS over TCP
new RawTcpServer({ port: 17994 }, { tls: { key, cert } });

// 1-to-1 (one connection per namespace, IPC-like)
new RawTcpServer({ port: 17995 }, { mode: "single-namespace" });

// UDS (Linux/Mac) / Named Pipe (Windows) — `path` instead of `port`
new RawTcpServer({ path: "/tmp/rsp.sock" });                  // *nix
new RawTcpServer({ path: "\\\\.\\pipe\\rsp" });               // Windows

RawTcpServerOptions:

| Option | Default | What it does | |---|---|---| | mode | "mux" | "mux": 1 TCP connection multiplexes many namespaces via envelope ns field (socket.io-like). "single-namespace": 1 TCP connection = 1 namespace, bound at hello time (IPC-like). | | tls | — | tls.TlsOptions ({ key, cert, ca, ... }). Pass to enable TLS; omit for plain TCP. Ignored when target uses path: (UDS / Named Pipe have no TLS concept — file permissions are the security boundary). | | keep_alive | true (30 s) | TCP keepalive. false disables; { initial_delay_ms } customises. | | permissions | — | Octal chmod for UDS path after listen() (e.g. 0o660). No-op for TCP / Pipe. | | logger | default | Inject your Logger. |

Wire format (per frame): [4 bytes BE total len][4 bytes BE json len][JSON envelope][optional binary tail]. Binary tail carries Buffer payloads on hot channels (serialport_packet, mux/txn data) directly without JSON-encoding — avoids the 4 KB → 20 KB inflation that naïve JSON-of-Buffer suffers. Other envelopes are plain JSON.

Transport differences for Raw TCP vs the other built-ins:

| Behavior | socket.io | Node IPC | Raw TCP (plain/TLS) | Raw TCP (UDS / Pipe) | |---|---|---|---|---| | Reconnect | auto-reconnect, RPC replay | none (single-shot) | none (single-shot) | none (single-shot) | | Wire backpressure | bufferedAmount sampling | null (skipped) | socket.writableLength | socket.writableLength | | Endpoint multiplexing | one namespace per of() | single endpoint (use mux mode) | mode-controlled | mode-controlled | | TLS | external (https.Server) | OS-level only | built-in { tls: ... } | OS-level only (file perms) | | Browser support | yes | no | no | no |

Throughput / latency (localhost, Node 24, 4 KB device-emit chunks):

| | Throughput | RTT p50 / p99 | |---|---|---| | Raw WebSocket (mux, plain) | 44.16 MB/s | 0.46 / 2.33 ms | | socket.io | 18.18 MB/s | 0.66 / 1.67 ms | | HTTP/2 (h2c) | 16.92 MB/s | 0.48 / 1.48 ms | | WebRTC DataChannel (loopback, no NAT) | 12.79 MB/s | 0.67 / 3.35 ms | | Raw TCP (mux, plain) | 11.72 MB/s | 0.40 / 3.71 ms | | gRPC (insecure) | 6.80 MB/s | 1.34 / 14.87 ms | | Node IPC (worker_threads) | 4.70 MB/s | n/a (single-process) | | MQTT (aedes broker localhost) | 4.24 MB/s | 2.62 / 11.99 ms |

ws has tighter binary framing than Engine.IO; socket.io wins overall stability via its extra layers; HTTP/2 has the best p99 (per-stream flow control = no head-of-line blocking); Raw TCP wins p50 by stack thinness. WebRTC localhost numbers don't reflect real-world deployment — add NAT/ICE/TURN cost in production. gRPC pays per-message protobuf overhead — middle of the pack on throughput, slower p99 from the extra framing. MQTT pays a broker round-trip per direction — slowest of the bunch, but you get pub-sub semantics and broker persistence for free.

Raw WebSocket (RawWebSocketServer)

Same envelope + framing as Raw TCP, but delivered over the ws library's WebSocket protocol. Use when you want a TCP-only socket.io replacement (no HTTP polling fallback, no Engine.IO), need browser clients, or want to share a port with an existing HTTP server (you can attach to your own http.Server instead of letting the constructor make one).

const { RemoteSerialportServer, RawWebSocketServer } = require("node-serialport-server");

// Plain ws://, mux mode (default)
new RemoteSerialportServer(undefined, 0, {
    transport_server: new RawWebSocketServer({ port: 17996 }),
    auto_pipe: true
});

// TLS (wss://)
new RawWebSocketServer({ port: 17997 }, { tls: { key, cert } });

// 1-to-1 namespace mode
new RawWebSocketServer({ port: 17998 }, { mode: "single-namespace" });

// Custom HTTP path (default "/")
new RawWebSocketServer({ port: 17999, path: "/rsp" });

RawWebSocketServerOptions:

| Option | Default | What it does | |---|---|---| | mode | "mux" | Same semantics as Raw TCP: "mux" shares one ws across many namespaces; "single-namespace" is 1 ws = 1 namespace. | | tls | — | tls.TlsOptions. When set, the underlying HTTP server is upgraded to HTTPS and the URL becomes wss://. | | logger | default | Inject your Logger. |

The client is RawWebSocketClient (see client README).

HTTP/2 (Http2Server)

Uses Node's built-in node:http2. Each namespace = one HTTP/2 stream within a session, so HTTP/2's native stream multiplexing IS the mux model — there is no mode option. Each stream has its own flow-control window, which means a slow consumer on one namespace can't head-of-line block traffic on another (best p99 of all built-in transports).

const { RemoteSerialportServer, Http2Server } = require("node-serialport-server");

// h2 over TLS (recommended; ALPN "h2")
new RemoteSerialportServer(undefined, 0, {
    transport_server: new Http2Server({ port: 17996 }, { tls: { key, cert } }),
    auto_pipe: true
});

// h2c cleartext (trusted internal networks only — most non-Node clients refuse h2c)
new Http2Server({ port: 17997 });

Http2ServerOptions:

| Option | Default | What it does | |---|---|---| | tls | — | http2.SecureServerOptions. When set the server runs h2 over TLS with allowHTTP1: false. When absent the server runs h2c cleartext. | | logger | default | Inject your Logger. |

The :path header on each incoming stream is treated as the namespace label. So server.of("/dev/ttyUSB0").on("connection", ...) matches client streams opened with :path: /dev/ttyUSB0. The client class (Http2Client) sets :path automatically from the label you pass to open(label).

Auth + the deferred-accept hello-gate: the HTTP/2 server defers firing endpoint.accept(transport) until the client's first hello envelope arrives on the stream. That ensures transport.credential is populated with the client's auth payload before the wrapping RemoteSerialServerSocket's auth_validator(credential, meta) runs — without this gate the validator would be called with credential === undefined because hello arrives microseconds after the stream is opened. Pass the credential into the client transport's own ctor (new Http2Client({...}, { auth: ... })), not the top-level RemoteSerialportClient options. (Duplicate hello envelopes on the same stream after the first are dropped with a warn log — a misbehaving client cannot upgrade its credential mid-session.)

MQTT broker pattern (MqttServer)

Connects to an external MQTT broker (mosquitto / EMQX / HiveMQ / aedes / NATS-MQTT / etc.) and turns the broker into the wire. Topic conventions (default prefix "rsp"):

<prefix>/c2s/<client_id>/<label>     client → server (incl. the `hello` envelope)
<prefix>/s2c/<client_id>/<label>     server → client

The server subscribes to <prefix>/c2s/+/# (multi-level wildcard catches labels containing /), demultiplexes on (client_id, label), and routes to the matching endpoint registered via server.of(label).

const { RemoteSerialportServer, MqttServer } = require("node-serialport-server");

new RemoteSerialportServer(undefined, 0, {
    transport_server: new MqttServer({
        broker_url: "mqtt://broker.internal:1883",
        topic_prefix: "rsp",            // optional, default "rsp"
        qos: 1,                          // QoS 0 will lose bytes; do not use for serial streams
        mqtt_options: { username: "u", password: "p" }
    }),
    auto_pipe: true
});

MqttServerOptions:

| Option | Default | What it does | |---|---|---| | broker_url | (required) | e.g. mqtt://host:1883 or mqtts://host:8883. | | topic_prefix | "rsp" | First topic level; must match the client's topic_prefix. | | server_id | random UUID | Appears in the MQTT client id; helps broker-side tracing. | | qos | 1 | MQTT QoS for both directions. Don't set 0 — at-most-once delivery corrupts the serial stream. | | mqtt_options | — | Forwarded to mqtt.connect: auth, TLS, keepalive, will, etc. | | logger | default | Inject your Logger. |

The walkthrough's mqtt-smoke.js bundles aedes as an in-process broker so you can try this without installing mosquitto.

Trade-offs vs. direct transports:

  • Slowest of the built-ins (broker hop per direction): ~4 MB/s, ~11 ms p99 RTT locally
  • Decoupled deployment — server and clients don't need to know each other's network address
  • Free pub-sub semantics (multiple clients can naturally subscribe to one device)
  • Broker can persist messages / retry on flaky links (depends on broker config + QoS)

⚠ QoS 1 retransmits and write-critical workloads. MQTT QoS 1 is "at-least-once": under packet loss, the broker may redeliver the same PUBLISH and the receiver acks each copy. This library does NOT deduplicate retransmitted messages at the application layer — if the network drops a PUBACK and the broker resends, a serialport_send_packet envelope can reach the device's port twice, writing the same Modbus PDU / RS-485 command twice. For write-idempotent workloads (status polling, log streaming) this is usually fine. For write-critical workloads (commanding actuators, Modbus function-code 5/6/15/16) one of the following is required:

  • Set qos: 2 (exactly-once; broker uses 4-step handshake — higher latency, no duplicates).
  • Use a different transport (Raw TCP / Raw WS / HTTP/2) where the underlying TCP stream gives in-order non-duplicated delivery.
  • Dedup at the app layer by stamping each command with a sequence number.

Also, MQTT KeepAlive defaults to ~60 s in the mqtt lib: a dead server is not detected on the client side until KeepAlive expires. Tune via mqtt_options.keepalive if you need faster failure detection.

gRPC (GrpcServer)

Wraps @grpc/grpc-js. Wire protocol is a single bi-directional streaming RPC defined in proto/remote-serialport.proto. Each Channel(stream Envelope) call is one AbsTransport (one namespace). The namespace label rides in stream metadata under rsp-label; the auth credential under rsp-auth (JSON-encoded).

Use when you're already running in a gRPC ecosystem (envoy / linkerd / service mesh, observability via OpenTelemetry, mTLS via grpc.ServerCredentials.createSsl) and want this library to fit in naturally.

const { RemoteSerialportServer, GrpcServer } = require("node-serialport-server");
const grpc = require("@grpc/grpc-js");

// Insecure (dev / trusted internal networks)
new RemoteSerialportServer(undefined, 0, {
    transport_server: new GrpcServer({ address: "0.0.0.0:17996" }),
    auto_pipe: true
});

// TLS (server auth)
const tls_creds = grpc.ServerCredentials.createSsl(
    null,  // no client CA = no mTLS
    [{ private_key: fs.readFileSync("server.key"), cert_chain: fs.readFileSync("server.crt") }],
    false  // checkClientCertificate
);
new GrpcServer({ address: "0.0.0.0:17997" }, { credentials: tls_creds });

GrpcServerOptions:

| Option | Default | What it does | |---|---|---| | credentials | grpc.ServerCredentials.createInsecure() | Standard gRPC credentials. Pass createSsl(...) for TLS / mTLS. | | logger | default | Inject your Logger. |

The .proto file is shipped in this package under proto/; the server loads it via @grpc/proto-loader at first listen. Don't rename or delete that file at install time.

Trade-offs:

  • Slower than direct transports (protobuf wrap + HTTP/2 stream management): ~7 MB/s throughput
  • p99 RTT ~15 ms — middle of the pack but worse than raw HTTP/2 because of the per-message wrap
  • Native gRPC tooling works: grpcurl, envoy, gRPC reflection, etc.

WebRTC DataChannel (WebRtcServer) — experimental

True peer-to-peer transport using node-datachannel (WebRTC over SCTP-over-DTLS-over-ICE). Marked experimental because node-datachannel is still maturing and WebRTC introduces a hard dependency on an out-of-band signaling channel for SDP + ICE exchange.

const { RemoteSerialportServer, WebRtcServer } = require("node-serialport-server");

// You provide a SignalingChannel — anything that can pass SDP offers/answers + ICE
// candidates between server and client. The walkthrough uses an in-process EventEmitter;
// production deployments typically use a small WebSocket signaling server.
const signaling = {
    on_message(handler) { /* hook handler to your channel of choice */ },
    send(msg) { /* push msg to the matching peer */ }
};

new RemoteSerialportServer(undefined, 0, {
    transport_server: new WebRtcServer({
        signaling,
        ice_servers: ["stun:stun.l.google.com:19302"]   // strings only — not W3C objects
    }),
    auto_pipe: true
});

WebRtcServerOptions:

| Option | Default | What it does | |---|---|---| | signaling | (required) | A WebRtcSignalingChannel that exchanges {kind: "offer" \| "answer" \| "candidate", peer_id, sdp?, candidate?, mid?} messages with each remote peer. | | ice_servers | ["stun:stun.l.google.com:19302"] | Array of strings — node-datachannel does NOT accept the W3C {urls, username, credential} object form. For TURN: "turn:user:pass@host:3478". | | logger | default | Inject your Logger. |

Each DataChannel(label) opened by a client maps to one transport on the server side. The namespace label is the DataChannel label (dc.getLabel()).

Trade-offs:

  • True P2P — once the connection is up, traffic doesn't pass through any server. Great for fan-in / fan-out where you want to skip a relay.
  • Setup cost: 1-3 seconds for ICE gathering + DTLS handshake in real-world (loopback is much faster).
  • Bring-your-own signaling server. The library ships a WebRtcSignalingChannel interface and the walkthrough demo uses in-process EventEmitter. For browsers, the typical setup is a WebSocket signaling server with a cross-origin policy.
  • node-datachannel is a native dependency (libdatachannel + libsrtp + libjuice) — heavier install footprint than the JS-only transports.
  • WebRTC has no built-in app-layer auth — if you need to gate connections, put your auth in the signaling layer (the user controls the signaling channel), or send a hello envelope as the first message and reject in auth_validator.
  • Shared mode (multi_access: "shared") works on WebRTC just like every other transport: multiple peers, each with their own WebRtcClient + own signaling channel pair, can attach to one WebRtcServer against the same physical port. The server demuxes by peer_id from signaling messages; per-path SharedSessionRegistry refcounts them. Your signaling adapter must support multiple peer_ids — the walkthrough's round10-webrtc-shared.js shows the pattern (one make_client_channel(peer_id) per client).

Real-world deployment notes (TURN / NAT traversal)

The walkthrough numbers (12.79 MB/s, 0.67 ms p50) are loopback — they do NOT reflect production. Plan for these realities:

  • STUN alone is not enough on most corporate / mobile networks. Symmetric NAT (common on enterprise firewalls, 4G/5G carriers, hotel Wi-Fi) cannot be traversed with STUN. Such peers will fail to connect unless you provide a TURN relay.

  • TURN reverts WebRTC to a relayed path — all traffic flows through the TURN server. You lose the "true P2P" benefit and pay the TURN server's bandwidth. Throughput and latency look more like a TCP relay than direct UDP.

  • ICE gathering takes time. Loopback: ~ms. Same-LAN: ~100-300 ms. Across the public internet with TURN candidates: 1-3 s typical, 5-10 s on slow networks.

  • DTLS handshake adds another round-trip on top of ICE.

  • Provide both STUN and TURN in ice_servers. Example:

    ice_servers: [
        "stun:stun.l.google.com:19302",
        "turn:turn-user:[email protected]:3478",
        "turns:turn-user:[email protected]:5349",  // TURN over TLS
    ]

    Run your own coturn or use a hosted service (Twilio NAT Traversal, Cloudflare TURN, Xirsys). Free public STUN servers are fine; do NOT use free public TURN — they're rate-limited or not available.

  • The library does NOT diagnose NAT failures for you. If a peer cannot establish ICE, the WebRtcClient simply never reaches state === "open". Set a connection timeout in your app code and surface "WebRTC connection failed (check NAT/TURN config)" to the user.

  • Signaling is your responsibility — the library's WebRtcSignalingChannel is just the protocol shape (offer/answer/candidate). Real deployments use a small WebSocket signaling service that routes messages between peers; the walkthrough uses an in-process EventEmitter only because there's no real network in the test.

For browsers as the peer, see the Node-only banner at the top of this README — the signaling side can be a browser (browsers can speak WebRTC natively), but the Node side of the connection still runs this library.

Protocol (v2)

serialport_handshake (S→C) announces the protocol version on connect; the client checks compatibility.

| Direction | Namespace mode | Mux mode (payload carries path) | |---|---|---| | S→C | serialport_handshake {protocolVersion} | serialport_handshake | | C→S | serialport_open {path, options} | serialport_mux_open {path, options} | | S→C | serialport_state {state, message?} | serialport_mux_state {path, state, message?} | | S→C | serialport_packet (raw bytes) | serialport_mux_packet {path, data} | | C→S | serialport_send_packet (raw bytes) | serialport_mux_send_packet {path, data} | | S→C | serialport_drain (backpressure ack) | serialport_mux_drain {path} | | C→S | serialport_close | serialport_mux_close {path} | | C→S RPC | serialport_set {options} / serialport_update {options} / serialport_flush | serialport_mux_set / _update / _flush {path, …} | | C→S RPC (ack) | serialport_get{cts,dsr,dcd} · serialport_listPortInfo[] | serialport_mux_get {path} → status · serialport_list (shared) | | C→S Txn | serialport_send_begin/_chunk/_end/_abort {txn_id, …} | serialport_mux_send_begin/_chunk/_end/_abort {path, txn_id, …} |

RemoteSerialPortState: idle → opening → open → closing → closed, plus error.

Backpressure (client → device): the client uses a write window; each serialport_send_packet (or serialport_send_end) consumes a slot and each serialport_drain from the server frees one. When the window fills, port.write(...) returns false and the stream emits 'drain' once it clears.

Backpressure (device → client): when socket.pipe() is forwarding, the server samples the underlying transport's buffered byte count after each emit and pause()s the physical port if it crosses 1 MB; resumes once below 256 KB. Only in multi_access: 'reject' mode (see above).

Features

  • [x] Transport abstraction (AbsTransport) — swap socket.io for IPC or future transports
    • [x] WebSocket transport (socket.io)
      • [x] Namespace mode (one namespace per remote port)
      • [x] Mux mode (many remote ports per connection, dynamic addressing)
    • [x] Node IPC transport (worker_threads / Electron utility process; mux-only)
  • [x] Read from serial port (device → client)
  • [x] Write to serial port (client → device)
  • [x] Port lifecycle state machine + serialport_state
  • [x] Backpressure both directions (write window + device pause/resume)
  • [x] Injectable physical-port factory (testable without hardware)
  • [x] Remote port control RPCs (set / get / update / flush) and list_ports
  • [x] Multiple-server registry (RemoteSerialportServerManager)
  • [x] Remote error / close re-emitted on the local virtual stream
  • [x] Multi-chunk atomic transactions (txn() / with_txn()) with timeout
  • [x] Injectable Logger interface (pino / winston / etc.)
  • [x] Auto-reconnect: client replays opens + in-flight RPCs after transport drop (socket.io only)
  • [x] Multi-client access policy (multi_access: 'shared' with fifo / fifo-strict / batch / pipe scheduling)
  • [x] COW shared modes (cow-write-isolate / cow-snapshot / cow-virtual-port)
  • [x] Authentication (auth_validator, pluggable; works on socket.io Manager({auth}) + IPC hello envelope)
  • [x] ACL hooks (acl.can_open / can_write / can_read)
  • [x] Identity propagation (socket.identity exposed to app code in the connection handler)
  • [ ] Encryption: delegated to transport (TLS for socket.io, OS sandbox for IPC); no in-protocol encryption

Known limitations

  • Per-port MockBinding cleanup (test mode only): @serialport/binding-mock's registry (MockBinding.serialPorts) has no removePort() API. port_instance.close() releases the lock and stream, but the path entry persists in the process-wide registry until MockBinding.reset() (which rsc.disconnect() with no argument calls). In long-running test processes that create many ports under different paths, expect the registry to grow until a full disconnect().
  • Per-txn success/failure ack: client tx.end() resolves on the serialport_drain ack. If the server times out the txn before _end arrives, the eventual _end still gets an "unknown txn" ack from the server, so tx.end() resolves silently as success even though the bytes never reached the device. The server's logger (and txn_timeout_action: 'state'/'both') is the way to detect this server-side.
  • IPC transport is single-endpoint: one MessagePort pair represents one client connection, so server.of(label) / server.mux(label) both return endpoints backed by the same single transport regardless of label. To host many remote ports over one IPC pair, use mux mode (server.mux() + client.mux()). Multi-endpoint IPC demux (envelope-level ns routing) is not implemented in P4.

Production monitoring

This library's leak / cleanup paths are covered by R4 cleanup-probe (20 scenarios × 4 byte-stream transports) and R7 soak (400 cycles × 4 byte-stream transports — heap grew <1 MB, internal Maps peaked at 1 and returned to 0). What was NOT exercised in CI: a continuous multi-hour soak with sustained traffic. Recommended monitoring for any 24/7 server deployment:

  • RSS / heap growthprocess.memoryUsage().rss and .heapUsed sampled periodically. Any monotonic growth over hours is a flag; restart policy + ops dashboard.
  • File descriptor count (lsof -p $PID | wc -l on Linux, or Get-Process handles on Windows). The library cleans connections (D7 / D8 / M7) but the underlying socket library may not release FDs immediately under high churn.
  • Per-process listener accumulation — turn on process.env.NODE_NO_WARNINGS= empty and watch for MaxListenersExceededWarning in stderr.
  • unhandled rejection + uncaught exception — install process.on(...) handlers and log; the library wraps listener invocations in try/catch but external app code may not.
  • For MQTT-based deployments, broker-side metrics matter more than process-side: queue depth, in-flight count, KeepAlive failures.

Recommended pre-release: run your application against this library for at least 24 hours of representative traffic before declaring a deployment stable. None of the existing CI-friendly tests are long-duration; that's a release-engineering job.

中文

⚠ Node.js 限定。 這個 library 用了 Node 的 Bufferworker_threadsnode:http2、native serialport binding 等 Node 專屬 API。瀏覽器跑不起來 — 用 webpack / Vite / Rollup 打包會在 Buffer/stream/net import 直接炸。雖然 socket.io 跟 ws 自己有 browser build,但這個 library 是 Node↔Node 部署裡的 Node 端。要做瀏覽器前端,請自己寫一層薄薄的 app-layer adapter(搭配你自己的 WebSocket / WebRTC)把 bytes 轉過去 — 不要試圖直接 bundle 這個 package 進 browser。

把主機的實體序列埠透過網路代理出去:server 守住真實的序列埠;client 連上來,拿到一個本地 虛擬 序列埠(用 mock-binding 撐起來的 SerialPortStream)對應某個遠端埠。對虛擬埠的讀寫會轉發到實體埠,反之亦然 — 既有的 Node serialport 生態(serialportmodbus-serial …)幾乎零修改就能跨網路使用。

專案拆三個 package:

| Package | npm | 角色 | |---|---|---| | remote-serialport-types | remote-serialport-types | 共用型別 + abstract 介面(被另兩個 repo 以 git submodule 內嵌引用)。 | | remote-serialport-server | node-serialport-server | 守實體序列埠;透過 socket.io 對外。 | | remote-serialport-client | node-serialport-client | 連 server;把遠端埠暴露成本地虛擬埠。 |

從原始碼開發? types package 以 submodule 內嵌在 src/types/remote-serialport-typesgit submodule update --init也要進那個目錄跑一次 npm install(它有自己的依賴),tsc 才能解析所有型別。

兩種模式

  • Namespace mode — 一個 socket.io namespace = 一個遠端序列埠。client.connect("/dev/ttyUSB0", …)。simple、socket.io 慣用;多個 connect() 共用同一條傳輸通道(socket.io namespace 多工),所以單一連線仍可承載多埠。
  • Mux mode — 一條 mux namespace 上的連線可承載任意多個遠端埠,由訊息 payload 內的 path 動態定址。給「不想 namespace 一對一」的場景(IoT mesh、動態定址)。client.mux("/site-A").open("/dev/ttyUSB0", …)

兩種模式 wire format 結構相同;差別只是「socket 本身就是 routing key(namespace mode,payload 是裸 bytes)」還是「每個 payload 帶 path(mux mode)」。

安裝

npm install node-serialport-server   # server
npm install node-serialport-client   # client

Server

Namespace mode(中文範例對應英文段同樣的 code,請看 Server § Namespace mode

RemoteSerialServerOptions 重點:

| 選項 | 預設 | 說明 | |---|---|---| | serialport_namespace_regexp | /^(\/dev\/tty(USB\|AMA\|ACM)\|\/COM)[0-9]+$/ | 接受的 namespace 必須 match 這條 regex 才會被當 serial-port path。要用自訂 path(例如 /dev/ttyMODBUS/dev/ttyUSB100 帶非純數字 suffix)就要覆寫。 | | strict_path | true | false 時 namespace 純當 routing label,真實 path 從 client 的 options.path 來(仍走 regex 驗證)。 | | auto_pipe | false | true 則每條接受的連線自動 pipe() 讀方向。純 relay 場景建議直接開 trueconnection handler body 可以空白 — 但 server.of(...).on("connection", ...) 這行還是必須寫(沒寫的話 socket.io namespace 的 connection handler 不會被掛上,整個 server 會靜默吃掉所有 client,library 會在 listen() 後 1.5 秒發 warn)。 | | serialport_factory | (opts) => new SerialPort(opts) | 測試時注入 mock factory。 | | port_list_provider | () => SerialPort.list() | 覆寫 serialport_list RPC 回傳。測試時跟 serialport_factory 是一對:真實的 SerialPort.list() 看不到 SerialPortMock,所以 mock factory 通常要配 mock list。 | | logger | warn/error → console、debug/info 靜默 | 注入 pino / winston / 自寫 Logger。 | | multi_access | 'reject' | 'shared' 讓多 client 共用同一條 path(refcount)。 | | shared_mode | 'fifo' | 僅 multi_access='shared' 時有效;7 種詳見下方共享模式表。 | | cow_snapshot_buffer_bytes | 65536 | shared_mode='cow-snapshot' 的 ring buffer 大小。 | | txn_timeout_ms | 5000 | 每筆 txn 的 timeout;每 _chunk reset;超時 drop 已 buffer 的 chunks。 | | txn_timeout_action | 'log' | 'log' / 'state'(也發 serialport_state: ERROR)/ 'both'。 | | auth_validator | — | 認證 validator;參見「認證與 ACL」。 | | acl | — | 三組 hook:can_open / can_write / can_read。 | | transport_server | new SocketIoServer(...) | 注入自訂 transport(例如 NodeIpcServer)。 |

Mux mode

server.mux().on("connection", socket => socket.pipe()) — 一條連線接所有遠端埠;用 socket.port(path) 拿到對應的 port proxy。

server.mux("/site-A")(或 regex)可指定 mux namespace;預設 /

Client

Namespace mode — 自動 open

new RemoteSerialportClient(host, options).connect(namespace, open_options) 一行接上就 ready。連線完成(handshake 後)自動 send serialport_open。範例 code 請看 Client 英文段。

Namespace mode — 手動 open

不傳 open_options:先 connect(namespace) 拿 socket,之後再 socket.open(open_options)

modbus-serial 整合

create_port(local_path)get_port({ baudRate, autoOpen: true }) 拿到的 stream 結構上是個正常 SerialPortStream。其它套件(如 modbus-serial)直接吃這個 stream 不用改 code。

Mux mode

rsc.mux().open(remote_path, options)rsc.mux().create_port(remote_path, local_path) 拿到本地虛擬埠。

Remote port 控制 / 探索(RPC)

  • set_remote(SetOptions)update_remote(UpdateOptions)flush_remote() — fire-and-forget
  • get_remote_status()Promise<PortStatus>list_ports()Promise<PortInfo[]> — 走 socket.io ack 包成 Promise

Multi-server registry

RemoteSerialportServerManager 把多個 server 用 key 註冊起來方便管。

Disconnect

rsc.disconnect(namespace) 斷單條;rsc.disconnect() 全斷並 reset MockBinding registry。

多 chunk transactions(txn)

const tx = portInstance.txn();
tx.write(Buffer.from("HEAD_"));
tx.write(Buffer.from("BODY_"));
tx.write(Buffer.from("TAIL"));
await tx.end();   // 在 server 端被當成 ONE atomic write 到 device
// 或:tx.abort() — server drop 全部 buffered chunks

便利包裝 portInstance.with_txn(async (handle) => { … }):自動 end(),throw 時自動 abort()

語意:

  • _begin → 0..N 個 _chunk_end(送 device)或 _abort(drop)
  • 只有 _end 跟單發 serialport_send_packet 消耗背壓窗口
  • Server-side timeout(每 chunk reset):drop buffered chunks(行為由 txn_timeout_action 決定)

Logger

interface Logger {
    debug(message: string, ...meta: unknown[]): void;
    info(message: string, ...meta: unknown[]): void;
    warn(message: string, ...meta: unknown[]): void;
    error(message: string, ...meta: unknown[]): void;
}

預設:warn/error → console,debug/info 靜默。可注入 pino / winston / bunyan,或寫個 prefix wrapper。

Reconnect 行為(client,僅 socket.io transport)

  • Transport 斷線:socket.io 自動重連(指數退避)
  • 重連完成、handshake 後:client 重發所有 _active_opensserialport_mux_open / 重新自動 serialport_open
  • In-flight RPC:option rpc.replay_on_reconnect(預設 true)— connect 事件 fire 時把 _pending_rpcs 全部用同一個 ack callback 重發;timeout 是從第一次呼叫起算的 wall-clock(不重置)。若 falsedisconnect 事件當下直接 reject 全部 pending
  • IPC transport:無 reconnect。on_lifecycle("reconnect") 永不 fire

Shared mode(multi_access: 'shared'

多個 client 連到同一條 path;server refcount 共用一個 SharedPortSession。namespace mode + mux mode 都支援;同 path 的 ns + mux client 共用同一個底層 session。

7 種 shared_mode

| shared_mode | 寫排程 | 讀 | |---|---|---| | 'fifo' (預設) | 以 _begin 抵達順序嚴格排,HOL(B 的 _end 早到不會插到 A 之前) | 全 fanout | | 'fifo-strict' | 同 'fifo' + server 一次只允許一筆 in-flight write | 全 fanout | | 'batch' | _end / 單發抵達即 flush — wire 上是 _end-order,無 HOL | 全 fanout | | 'pipe' | 第一連的當 writer,其它 read-only(writes 丟棄但 drain 仍 ack);writer 斷 → 下一個最早 live subscriber 自動升級 | 全 fanout | | 'cow-write-isolate' | 立即 flush + 每 subscriber 記下最近寫入;device echo prefix-match 該 subscriber → 只回給 writer | echo-filter fanout(match 的只給 writer;剩下 fanout) | | 'cow-snapshot' | 立即 flush | fanout + 新 subscriber 加入時先 replay cow_snapshot_buffer_bytes 大小的 ring buffer(預設 64 KB) | | 'cow-virtual-port' | 立即 flush + 每 subscriber 的 set/update state 各自 cache(物理 last-wins,library 記 warn log conflict) | 全 fanout |

排程與 writer policy 都是 per-path。每筆 write 的 drain ack 只路由給該 subscriber,不會跨 client 卡背壓窗口。

cow-write-isolate 注意事項:echo filter 是 byte-level prefix match。device 在 echo 前改 byte(加 \r\n、轉碼…)會打敗 filter — 那些 unmatched bytes 落回全 fanout,全部 subscriber 看得到。適合 line-echo 或 full-duplex 原樣 echo 的 device;不要用它做真正的 byte-level 隔離。

Shared 模式下 hook socket.port 的陷阱connection handler 拿到的 socket.port共享的物理埠 EventEmitter,不是 per-subscriber 的視圖。所以 socket.port.on("data", …) / socket.port.on("write-command", …)每個 subscriber 各裝一個 listener,每筆事件依 listener 數量觸發;listener closure 抓到的 socket.identity 是當下裝 listener 的那個 socket,不是寫的 client。

// WRONG — N 個 subscriber 就裝 N 個 listener,且 id.user tag 錯誤對應
server.of().on("connection", (socket) => {
  const id = socket.identity;
  socket.port.on("write-command", (chunk) => log(`${id.user} wrote ${chunk}`));
});

// Right — 只裝一次,把 chunk 當匿名處理
let installed = false;
server.of().on("connection", (socket) => {
  if (installed) return; installed = true;
  socket.port.on("write-command", (chunk) => log(`someone wrote ${chunk}`));
});

要 per-write attribution 請走 acl.can_write(per-subscriber 帶 identity)並在那邊紀錄。Listener 加 subscriber_id 參數是未來 minor version 可能加的 API。

Shared 模式用 SerialPortMock 測試的注意事項:最後一個 subscriber 離開時 SharedPortSession 會把底層物理埠關掉。如果測試 harness 透過 serialport_factory 抓住 mock 來推 synthetic device output,那個 captured 變數現在指向已關閉的 mock,emitData(...) 會丟 Port must be open to pretend to receive data。下一個 subscriber 來時 factory 會給一個全新的 mock — 所以不要只 capture 一次,每次 factory 被呼叫都要重 capture,並用 mock.isOpen 加 try/catch 守 emitData

let captured = null;
new RemoteSerialportServer({}, 0, {
  multi_access: "shared", shared_mode: "fifo",
  serialport_factory: (opts) => { const m = new SerialPortMock(opts); captured = m; return m; }
});
setInterval(() => {
  if (captured === null || captured.isOpen === false) return;
  try { captured.port.emitData(buf); } catch { /* port flipped to closed between check + emit */ }
}, 400);

認證與 ACL(auth_validator + acl

const server = new RemoteSerialportServer({ cors: { origin: "*" } }, 17991, {
  auth_validator: async (credential, meta) => {
    // credential 就是 client 放在 `auth` 欄位的東西(JWT / API key / 任何 app 自訂格式)
    // meta = { transport_id, endpoint_label }
    const user = await my_verify(credential);
    if (user === null) return { ok: false, message: "bad token" };
    return { ok: true, identity: user };  // attach 到 socket.identity,後續 ACL 用
  },
  acl: {
    can_open: (identity, path) => identity.role === "admin" || allowed_paths(identity).includes(path),
    can_write: (identity, path) => identity.role !== "viewer",
    can_read:  (identity, path) => true
  }
});

auth_validator 每條接受的 transport 跑一次(socket.io 重連時會再跑,因為 Manager({auth}) 自動 replay)。拒絕:發 serialport_state: ERROR { message } 再 close transport — user 的 connection handler 永遠不會 fire 這個 socket。

acl 三個 hook per-operation 決定:

| Hook | 觸發 | 拒絕效果 | |---|---|---| | can_open(identity, path) | serialport_open / serialport_mux_open | serialport_state: ERROR { message: "open denied: ..." };port 停在 IDLE | | can_write(identity, path) | 所有寫方向訊息:_send_packet / _send_begin / _send_end / _set / _update / _flush | serialport_state: ERROR { message: "write denied: ..." } + serialport_drain(讓 client 的背壓窗口繼續流動),bytes 在送達 device 前丟棄 | | can_read(identity, path) | open 後一次 | 一次性 serialport_state: ERROR { message: "read denied: ..." } + 後續 device→client packets 對該 client 靜默過濾 |

Client 透過本地虛擬 stream 的 'error' 事件就能拿到 ERROR state — 既有的 serialport code 不用改就能反應 deny。

加密 / TLS

Library 本身不包 TLS。各 transport 的 TLS 設定:

  • socket.io:自己起 https.Server,再把 library 內部的 socket.io 用公開的 srv.io accessor 接上去 — 接完不要呼叫 srv.listen()(會多開一個明文 listener):

    const httpsServer = require("https").createServer({ key, cert });
    const srv = new RemoteSerialportServer({ cors: { origin: "*" } }, 17991, { /* ... */ });
    srv.io.attach(httpsServer);                       // socket.io 接到 https
    srv.of("/dev/ttyUSB0").on("connection", () => {});  // friction#9 還是要 register
    httpsServer.listen(17991);                        // https 自己 listen
    // (這個模式下不要再 srv.listen())
  • Raw TCP / Raw WebSocket / HTTP/2:transport ctor 內建 { tls: ... } option。

  • gRPC:內建 credentials option,傳 grpc.ServerCredentials.createSsl(...)

  • MQTT:用 mqtts:// broker URL;mqtt_options 可以丟任何 mqtt lib 支援的 TLS 選項。

  • WebRTC:規範本身就是 SCTP-over-DTLS-over-ICE,沒有 app 層憑證設定。

  • IPC:靠 OS-level sandbox / process isolation。

socket.io 的 mTLS:把 {requestCert: true, ca, ...} 傳給 https.createServer(...)

Wire backpressure(device→client)

socket.pipe() forward 物理 bytes 比 client drain 還快時,server 每次 emit 後採樣底層 socket.io transport 的 bufferedAmount;超過 WIRE_BACKPRESSURE_HIGH_WATER(1 MB)就 pause() 物理埠;定期檢查(WIRE_BACKPRESSURE_POLL_MS,50 ms)— 一旦掉到 WIRE_BACKPRESSURE_LOW_WATER(256 KB)以下就 resume()。只在 multi_access: 'reject' 模式下生效 — shared 模式 pause/resume 會餓死其它 subscriber 所以停用。IPC transport 的 get_buffered_amount()null,wire backpressure 自動跳過。

Transports(AbsTransport

Wire protocol 跟 transport 無關。預設用 socket.io(TCP/WebSocket);另一個內建 transport 是 Node IPCworker_threads.MessagePort / Electron utility process MessagePortMain)。透過 ctor option transport_server 注入切換 — 詳見 Transports 英文段範例。

IPC 的 label 就是 serial port pathnew NodeIpcServer(port, label) 第二參數是 endpoint_label,會直接變成這條 IPC pair 上 socket 的隱式 serialport_path。namespace 模式下要傳真實裝置路徑("/dev/ttyUSB0""COM3"),不是除錯用名稱。(server.of(label) 對 IPC server 而言 不用 label 路由 — IPC pair 只有一條連線 — 所以 constructor 的 label 是路徑的唯一來源。)mux 模式下 label 不參與路由,傳什麼都可以(路徑從各 open() payload 取)。

IPC transport 跟 socket.io 的差異:

| 行為 | socket.io | Node IPC | |---|---|---| | Reconnect | 自動重連、client RPC replay | 不重連 — port pair 是 single-shot | | Wire backpressure | 採樣 bufferedAmount | get_buffered_amount()null,跳過 | | Endpoint 多工 | 每 of(label) 一個 namespace | 單 endpoint per IPC pair;用 mux mode 接多埠 | | Connection model | 多 client → server | 1-to-1 process pair |

何時用 IPC:兩端都在同一個 Node process tree(worker threads、Electron utility process)。要跨 host / 跨網路就用 socket.io。

Raw TCP / Unix Domain Socket / Named Pipe(RawTcpServer

長度前綴 framing + JSON envelope,跑在 net.Socket 上。適合:依賴最小化、封閉 LAN 不需要 socket.io 的 HTTP polling fallback、或想透過 OS 級的 pipe(UDS / Named Pipe)而不是 TCP。

const { RemoteSerialportServer, RawTcpServer } = require("node-serialport-server");

// Plain TCP,mux 模式(預設)
new RemoteSerialportServer(undefined, 0, {
    transport_server: new RawTcpServer({ port: 17993 }),
    auto_pipe: true
});

// TLS over TCP
new RawTcpServer({ port: 17994 }, { tls: { key, cert } });

// 1-to-1(一條連線一個 namespace,IPC-like)
new RawTcpServer({ port: 17995 }, { mode: "single-namespace" });

// UDS(Linux/Mac)/ Named Pipe(Windows)— `path` 取代 `port`
new RawTcpServer({ path: "/tmp/rsp.sock" });
new RawTcpServer({ path: "\\\\.\\pipe\\rsp" });

RawTcpServerOptions

| 選項 | 預設 | 說明 | |---|---|---| | mode | "mux" | "mux":一條 TCP 連線多工承載多個 namespace(envelope ns 欄位區分,socket.io-like)。"single-namespace":一條 TCP 連線 = 一個 namespace,hello 時鎖死(IPC-like)。 | | tls | — | tls.TlsOptions。要開 TLS 就傳;plain TCP 就不傳。path: target(UDS / Named Pipe)會被忽略 — UDS/Pipe 沒 TLS 概念,安全邊界是檔案 permission。 | | keep_alive | true (30 秒) | TCP keepalive。false 關掉;{ initial_delay_ms } 自訂初始延遲。 | | permissions | — | UDS path listen() 後 chmod(例如 0o660)。T