Guide list | Previous: Java | Next: Python
Node.js Binding Guide (@zlink-systems/zlink)¶
Contract-owning document for this chapter — the Node.js bindings spec covers it. This chapter shows that contract as working sample code.
Explains how to use zlink in Node.js through working sample code. See the core guide for messaging concepts.
Installation¶
- Node.js 22 or later.
- The native core is bundled as a per-platform prebuild.
const zlink = require('@zlink-systems/zlink');
// or ESM / TypeScript
import * as zlink from '@zlink-systems/zlink';
5-Minute Example¶
const zlink = require('@zlink-systems/zlink');
// Server
const ctx = zlink.createContext();
const server = zlink.createPairSocket(ctx);
server.bind('tcp://127.0.0.1:5555');
const received = new zlink.Received();
server.recv(received);
console.log(received.parts[0].data().toString()); // PING
received.close();
await server.send().message(Buffer.from('ACK')).submit().admitted;
server.close();
ctx.close();
// Client
const ctx = zlink.createContext();
const client = zlink.createPairSocket(ctx);
client.connect('tcp://127.0.0.1:5555');
await client.send().message(Buffer.from('PING')).submit().admitted;
const received = new zlink.Received();
client.recv(received);
console.log(received.parts[0].data().toString()); // ACK
received.close();
client.close();
ctx.close();
Core Types¶
Context¶
const ctx = zlink.createContext();
// always close after use — this interrupts blocking operations on child sockets
ctx.close();
Message¶
The Node binding uses Buffer directly as a message. message() makes a copy,
so you're free to reuse the original Buffer.
await socket.send().message(Buffer.from('hello')).submit().admitted;
await socket.send().message(Buffer.from([0x01, 0x02])).submit().admitted;
// access the payload after receiving
const received = new zlink.Received();
socket.recv(received);
const data = received.parts[0].data(); // Buffer
const text = data.toString('utf8');
received.close();
HWM-managed sends provide asynchronous submit() and synchronous
submit_sync() terminals. On Node's event loop, use the result-object-returning
submit() by default; it returns a SendSubmission (result: OK|BACKPRESSURED
as a synchronous field, admitted: Promise<void>), uses DONTWAIT, and settles
admission from the socket completion queue. submit_sync() blocks in Core until
local admission.
const send = socket.send().message(Buffer.from('data')).submit(); // result object
if (send.result === SubmitResult.BACKPRESSURED) await send.admitted; // wait only at HWM
socket.send().message(Buffer.from('data')).submit_sync(); // synchronous Core admission
Request provides submit_sync() to block until the reply and submit() to return a
RequestSubmission (result, admitted, plus reply: Promise<Message[]>). When
result is OK you can await reply directly; the reply is that terminal result, not
DATA received separately.
Core owns retry after accepting a pre-admission operation; do not add a caller
retry queue or resubmit its payload. The shared native
ZLINK_OPT_PENDING_MAX_MSGS/BYTES limits cover pending SEND and REQUEST, with
no send-only pending names. Completion confirms local admission, not peer
delivery or an application acknowledgement.
Before submit, cancellation means omitting the call. Node exposes no public Core
cancel after a successful submit; abandoning a Promise only stops caller
observation, while the socket owner drains the late completion. Set
stream.options.recvMode to zlink.StreamRecvMode.Raw or .Packet before
bind/connect, then use recv or recvPacket respectively.
If a public poller owns zlink.PollEventFlag.PollCompletion for a socket, keep
another thread calling wait() while a blocking request or Promise is pending.
wait() drains native completions and settles or cleans Node state; calling a
blocking terminal between waits on the same thread can stall it.
Received — the receive envelope¶
const received = new zlink.Received();
socket.recv(received); // synchronous, blocking
try {
const parts = received.parts; // Message[]
const rid = received.routingId; // RoutingId or null
const token = received.replyToken; // ReplyToken or null on ROUTER request
} finally {
received.close();
}
Routing ID¶
Ownership And Lifetime¶
| Situation | Rule |
|---|---|
submit() succeeds |
the passed Buffer is copied internally, so you can reuse the original |
recv() succeeds |
received.close() is required (prefer a finally block) |
submit() (Promise) completes |
close each reply part with part.close() |
ctx.close() |
interrupts blocking operations on child sockets |
const received = new zlink.Received();
socket.recv(received);
try {
// process parts
} finally {
received.close();
}
Error Handling¶
The Node binding throws per-operation error classes.
try {
await socket.send().message(Buffer.from('data')).submit().admitted;
} catch (error) {
if (error instanceof zlink.SubmitError) {
if (error.result === zlink.SubmitResult.Backpressured) {
// retry
} else {
throw error;
}
}
}
Error classes: SubmitError, RequestError, RecvError, BindError,
ConnectError, ConfigError, CloseError, HandlerError.
Each exposes the result code via a .result property.
Share / Move / Clone (copy / move / clone)¶
Three explicit Message payload operations, with the same name and meaning across every
binding, mapping 1:1 to the Core C API (zlink_msg_copy/zlink_msg_move).
| Operation | Signature | Meaning | When |
|---|---|---|---|
copy() |
copy(): Message |
ref-count share — new Message on the same buffer, original stays valid |
keep the same payload while still using the original |
move(dest) |
move(dest: Message): void |
ownership transfer — hands off to dest, caller left empty |
re-send a received message with no copy (relay/echo) |
clone() |
clone(): Message |
deep copy — independent buffer | mutate the duplicate independently |
const shared = msg.copy();
await socket.send().message(shared).submit().admitted; // shared is consumed
// msg is still valid
const out = new zlink.Message();
receivedPart.move(out); // receivedPart becomes empty
await socket.send(routingId).message(out).submit().admitted;
const dup = msg.clone();
⚠ Breaking change: the former
copy()was a deep copy; nowcopy()is a ref-count share and the deep copy moved toclone(). JS cannot host the same signature with two return meanings, so no alias is possible — this is a major-version break. Replace deep-copy-intentcopy()calls withclone().Note (refcount timing): Node exposes payload as a
Buffer. While an exposedBufferis alive, the native storage is reclaimed when thatBufferis GC'd. So aftercopy()shares handles and youclose()one,refCount()does not drop to 1 immediately — it reflects after the buffer is GC'd (diagnostic only; no effect on behavior, safety, or ownership independence).
C API Mapping¶
| C API | Node API |
|---|---|
zlink_ctx_new() |
zlink.createContext() |
zlink_ctx_term() |
ctx.close() |
zlink_socket(ctx, type) |
zlink.createPairSocket(ctx), etc. |
zlink_bind(s, ep) |
socket.bind(ep) |
zlink_connect(s, ep) |
socket.connect(ep) |
zlink_send(..., parts, count, ...) / zlink_send_rid(..., parts, count, ...) + NONE |
socket.send().message(buf).submit_sync() |
| DONTWAIT send + completion pull | await socket.send().message(buf).submit().admitted |
zlink_recv(..., parts_out, capacity, count_out, ...) |
socket.recv(received) |
zlink_msg_data(msg) |
part.data() (Buffer) |
zlink_routing_id_t |
zlink.RoutingId |
zlink_socket_monitor_open(...) |
socket.monitorOpen([...]) |
zlink_poller_new() |
zlink.createPoller() |
zlink_timer_new() |
zlink.createTimer() |
Native Library / Deployment¶
The native core ships in the package as a per-platform prebuild. Works with
just npm install, no separate build step.
const [major, minor, patch] = zlink.version(); // [number, number, number]
console.log(`zlink ${major}.${minor}.${patch}`);
Threading notes. Node uses a single-threaded event-loop model.
| Item | Rule |
|---|---|
Context / sockets |
used on the main event loop |
Blocking recv() |
blocks the event loop, so keep it short or prefer non-blocking + poller |
Synchronous submit_sync() |
stops the event loop while waiting for HWM admission — do not use it on the loop |
Async submit() |
Promise-based — doesn't block the event loop |
Do not run a blocking send on Node's event loop. await the asynchronous
submit() terminal; use submit_sync() only on a suitable worker thread.
Samples¶
Verified samples live under bindings/node/samples/.
| File | Description |
|---|---|
pair_recv_sample.ts |
PAIR send/receive |
dealer_router_recv_sample.ts |
DEALER/ROUTER send/receive |
request_reply_sample.ts |
Request/reply |
pubsub_recv_sample.ts |
PUB/SUB publish/subscribe |
stream_recv_sample.ts |
STREAM raw TCP |
stream_packet_sample.ts |
STREAM PACKET pull |
monitor_recv_sample.ts |
Monitor event receive |
SPOT/Actor examples are covered by the framework samples, not the core binding — see the service links under See Also below.
JavaScript¶
JavaScript uses the Node binding (@zlink-systems/zlink) as-is, with no
separate native binding. The installation, core types, ownership, errors, and
mapping table above all apply identically, minus the TypeScript type
annotations.
- Dependency:
@zlink-systems/zlink(same as above). No TypeScript build step needed —requireit directly as plain.js.
const zlink = require('@zlink-systems/zlink');
const ctx = zlink.createContext();
const socket = zlink.createPairSocket(ctx);
// ... after use: socket.close(); ctx.close();
- Ownership: clean up with explicit
close()calls (same as Node — doesn't rely on GC). - Samples:
bindings/javascript/samples/(.js) has the same canonical set as the Node samples. Build the Node binding, then run directly withnode.
cd bindings/node && npm run build # build the shared runtime
cd ../javascript/samples
node pair_recv_sample.js # or ./run_samples.sh
The core guide's language tabs have a dedicated JavaScript column, so you can see messaging/service usage directly in JavaScript code.
See Also¶
Socket patterns - Socket pattern overview — PAIR · PUB/SUB · DEALER · ROUTER · STREAM · Proxy
Services - Framework service overview
Operations - Socket options - TLS security - Monitoring - Thread safety - Message API - Routing ID