Networking APIs
Cross-platform TCP, UDP, TLS, DTLS, ENet, and WebSocket sockets plus DNS, exposed as a layered set of zym-flavored namespaces.
Overview
The networking natives form a layered stack. Each namespace covers one transport:
IP— DNS resolution and local-interface enumerationTCP— reliable byte streams; clientconnectplus a listening serverUDP— unreliable datagrams; bound socket withsend/recv, plusUDP.listenfor per-source server-side demultiplexingSockets—Sockets.waitAny(handles, mode, timeoutMs)for multi-socket readiness (TCP, UDP, TLS, DTLS, WebSocket, and server handles)TLS— encrypted TCP client and server overX509Certificate/CryptoKeyfrom CryptoDTLS— encrypted UDP (datagram TLS) client and server, layered overUDPand the Crypto typesENet— UDP with reliable + ordered + channels, optionally wrapped in DTLSWebSocket— RFC 6455 WebSocket client and server, optionally over TLS (wss://)
Conventions
The networking natives share a small set of cross-cutting rules:
- Status strings. Anywhere an operation can succeed or be in a recoverable not-ready state, the result is one of the standard status strings:
"ok","busy","timeout","eof","closed","error". Bad argument types still raise a Zym runtime error of the formIP.method(args) .... - Buffer is the byte currency. All byte payloads exchanged with TCP/UDP/TLS are Buffer instances.
nullon lookup miss. Resolution failure (NXDOMAIN, garbage hostname, no such handle) isnull; an empty result that is not a failure (no addresses returned for a host that resolved cleanly, no local interfaces) is an empty list.- Synchronous everywhere. No async runtime, promises, callbacks, or event loop. Operations that can take time take an optional
timeoutMsargument; non-blocking variants have explicit*Somenames.
IP
DNS lookups and local interface enumeration. The IP global is a namespace of static functions; there is no instance type.
Performs a synchronous DNS lookup. Returns the first address (IPv4 or IPv6) the system resolver yields, or null on NXDOMAIN or lookup error.
host(string) — hostname or literal IP address
Returns all addresses for host as a list of strings. Returns an empty list on NXDOMAIN or lookup error. Order is whatever the resolver returned.
host(string) — hostname or literal IP address
Returns the textual IPs assigned to local interfaces as a list of strings. Includes loopback (127.0.0.1 / ::1) and any virtual interface (docker0, etc.).
Address format
- IPv4. Dotted-quad:
"192.168.1.10". - IPv6. Colon-hex:
"fe80::1"for shortened forms,"0:0:0:0:0:0:0:1"for the fully-expanded form. The exact textual representation is whatever the engine's IPAddress-to-String formatter produces; scripts should treat the value as opaque and feed it back intoTCP/UDPrather than parsing it. localhost. On dual-stack hosts,IP.resolve("localhost")may return either"127.0.0.1"or"::1"depending on the resolver configuration. UseIP.resolveAll("localhost")to see all entries.
print(IP.resolve("example.com")) // "93.184.216.34" (or null on a network without DNS) for addr in IP.resolveAll("dual-stack.example") { print(addr) } // "203.0.113.10" // "2001:db8::10" for ip in IP.localAddresses() { print(ip) } // "127.0.0.1" // "::1" // "192.168.1.42" // "172.17.0.1" // ...
/etc/resolv.conf. A server-style script that wants to keep handling traffic during DNS should resolve in advance or lean on the OS resolver's caching.
Literal addresses ("127.0.0.1", "::1") round-trip through IP.resolve cleanly — the engine treats them as already-resolved and returns them unchanged. localAddresses() enumerates all interfaces, including link-local (fe80::) and virtual ones; filter the result list yourself if you only want, say, the first non-loopback IPv4 address.
TCP
Reliable byte streams. Two globals (TCP.connect for clients, TCP.listen for servers); both return instance handles whose methods are documented below.
The connection model is non-blocking under the hood, with timeoutMs-driven helpers layered on top:
timeoutMs == -1— block forever (default)timeoutMs == 0— return immediately; do not waittimeoutMs > 0— wait up to that many milliseconds
TCP statics
Resolves host via IP and opens a TCP connection. Without timeoutMs, blocks forever until connected, refused, or errored. With timeoutMs, the wait is bounded; timeoutMs == 0 returns immediately with the partially-connected sock (status() == "connecting") and the caller drives poll() until ready.
host(string) — hostname or literal IP addressport(number) — remote porttimeoutMs(number, optional) — connection deadline in milliseconds
Returns: a sock handle, or null on failure.
Binds and listens on host:port. host == "" or "*" binds all interfaces. port == 0 lets the OS assign one (read it back via server.localPort()). Returns null on EADDRINUSE or permission errors.
host(string) — interface to bind;""or"*"for allport(number) — port to bind;0for OS-assigned
Returns: a server handle, or null on failure.
Sock instance methods
Returns one of "none", "connecting", "connected", "error". Does not advance state.
Drives one round of state advancement (handshake progress, EOF detection) and returns the new status. Useful when you want to react to disconnects without reading.
Returns the number of bytes immediately readable. Polls before reporting.
Reads exactly n bytes. Without timeoutMs, blocks forever. With a deadline, returns "timeout" on partial arrival before the deadline — the bytes that did arrive are lost, so use readSome for partial-friendly reads. Returns "eof" on peer close.
n(number) — exact number of bytes to readtimeoutMs(number, optional) — read deadline in milliseconds
Returns: a Buffer, or a status string.
Non-blocking read. Returns whatever is immediately available, up to n bytes; the result may be empty (length 0). Returns null on error or when not connected.
n(number) — maximum number of bytes to read
Reads up to and including the next \n, returning the line without the terminator. Strips both \r\n and \n. Returns "eof" if the connection closes before a newline arrives. Any bytes that arrive after the newline in the same available() window are discarded.
Returns: a string, or a status string.
Reads until peer EOF (or the deadline). Returns the accumulated payload as a Buffer; "timeout" if the deadline hits before EOF; "error" on connection error.
Sends the entire payload, or fails. Returns "ok", "timeout", "closed", or "error". Empty buffers always return "ok".
buf(Buffer) — the payload to sendtimeoutMs(number, optional) — write deadline in milliseconds
Non-blocking write. Returns a map { sent: number, status: "ok" | "closed" | "error" }. Use this for back-pressure-aware sending.
buf(Buffer) — the payload to send
Toggles TCP_NODELAY (Nagle's algorithm). Returns null.
b(boolean) —trueto enableTCP_NODELAY
Returns the local end of the connection as { host, port }, or null. host is reported as the wildcard form (engine-side limitation); port is the actual local port.
Returns the remote end of the connection as { host, port }, or null.
Disconnects. Idempotent. After close, status() returns "none" and read/write return "closed".
Server instance methods
Blocks until a client connects, then takes the connection. Without timeoutMs, blocks forever and returns null only if the server has been stopped. With a deadline, returns null on timeout or on shutdown.
timeoutMs(number, optional) — accept deadline in milliseconds
Returns: a sock handle, or null.
Returns the actually-bound port. Useful when listen was passed 0.
Stops listening. Already-accepted client sockets are unaffected. Idempotent.
// Client: connect, exchange one line, close. var c = TCP.connect("example.com", 80, 5000) c.write(Buffer.fromString("GET / HTTP/1.0\r\nHost: example.com\r\n\r\n"), 5000) var status_line = c.readLine(5000) print(status_line) c.close() // Server: accept one connection, echo a line. var srv = TCP.listen("127.0.0.1", 0) print("listening on", srv.localPort()) var s = srv.accept() var line = s.readLine(5000) s.write(Buffer.fromString("got: " + line + "\n"), 5000) s.close() srv.close()
UDP
Unreliable datagrams. One static (UDP.bind) returns an instance with send / recv plus the usual lifecycle; UDP.listen adds per-source server-side demultiplexing.
UDP statics
Binds a UDP socket. host == "" or "*" binds all interfaces; port == 0 lets the OS assign one. Returns null on bind failure. The returned handle is non-blocking.
host(string) — interface to bind;""or"*"for allport(number) — port to bind;0for OS-assigned
Binds a UDP socket and demultiplexes incoming datagrams per source (host, port). Each new source becomes a pending peer that you accept(...) to get a fresh udp handle bound to that one source. Used for stateful per-client UDP servers and as the entry point for DTLS.accept. Returns null on bind failure.
host(string) — interface to bind;""or"*"for allport(number) — port to bind;0for OS-assigned
Returns: a udp-server handle, or null.
UDP instance methods
Sends buf as one datagram to host:port. host may be a literal IP or a hostname (resolved via IP). Returns "ok", "busy" (kernel send buffer full), or "error".
buf(Buffer) — the datagram payloadhost(string) — destination hostport(number) — destination port
Without timeoutMs, blocks forever until a datagram arrives. Returns { data: Buffer, host, port } with the source address, or "error" if the socket is unbound. With a deadline, returns "timeout" if no datagram arrives in time.
timeoutMs(number, optional) — receive deadline in milliseconds
Returns the actually-bound local port.
Enables or disables SO_BROADCAST. Required to send to 255.255.255.255 or directed-broadcast addresses. Returns null.
b(boolean) —trueto enable broadcast
Closes the socket. Idempotent.
UDP server instance methods
The handle returned by UDP.listen exposes a small surface, focused on accepting per-source UDP peers. The accepted peer is a regular udp handle with the same instance methods as those returned by UDP.bind.
Blocks until a new source sends a datagram, then returns a fresh udp peer bound to that source. Subsequent datagrams from the same source flow into that peer's queue. With a deadline: 0 returns immediately (null if no pending source); -1 is the same as no argument.
timeoutMs(number, optional) — accept deadline in milliseconds
Returns: a udp handle, or null.
Returns the actually-bound port, or 0 if not listening.
Stops listening. Already-accepted peers remain usable until they themselves are closed.
// Send-and-forget var u = UDP.bind("0.0.0.0", 0) u.send(Buffer.fromString("ping"), "192.168.1.1", 9000) u.close() // Receive with source address var s = UDP.bind("0.0.0.0", 9000) var r = s.recv(5000) if r != "timeout" { print("from", r["host"], r["port"], "got", r["data"].size(), "bytes") } s.close()
Sockets
Multi-handle readiness primitive — the closest zym has to a select(2) or poll(2) for scripts.
Waits for any of handles to become ready in the given mode. Returns once at least one handle is ready, or when timeoutMs expires.
handles(list) — sock, server, and udp instances to watchmode(string) —"read","write", or"any"timeoutMs(number) — deadline in milliseconds;-1blocks forever
Returns: a map { ready, timedOut } — ready is the subset of handles that became ready; timedOut is true when nothing fired before the deadline.
Mode semantics
"read"— handle is ready when bytes are immediately available (sock), a connection is pending (server), or a datagram has arrived (udp). A closed/errored socket also counts as readable so scripts can detect EOF in the same loop."write"— handle is ready whensock.status() == "connected"or the UDP socket is bound. Useful for "wait until my outbound socket finishes its handshake"."any"— readable OR writable.
waitAny is a poll-with-quantum loop over the existing readiness primitives, not a single multiplexed syscall. For a handful of handles (the typical CLI workload) this is invisible; for hundreds of fds, expect a quantum of latency (~20 ms) on the slowest spin.
var clients = [] var srv = TCP.listen("127.0.0.1", 0) while true { var watch = [srv] for c in clients { append(watch, c) } var w = Sockets.waitAny(watch, "read", -1) if w["timedOut"] { continue } for h in w["ready"] { if h == srv { append(clients, srv.accept(0)) } else { var line = h.readLine(0) // ... handle line, including "eof"/"closed"/"error" ... } } }
TLS
Encrypted TCP. Client connections (TLS.connect) verify the peer's certificate against the system trust store by default; server-side acceptance (TLS.accept) wraps an already-accepted TCP socket with the caller's CryptoKey and X509Certificate from Crypto. The instance methods on a TLS sock are identical to TCP — status / poll / read / readSome / readLine / readAll / write / writeSome / setNoDelay / localAddress / peerAddress / close — and obey the same status vocabulary, so script code that talks TCP can be retargeted at TLS by swapping the factory call.
TLS statics
Opens a TLS client connection. Without timeoutMs, blocks until the handshake finishes. Verifies the peer certificate against the system CA bundle (loaded at startup); returns null on DNS failure, TCP refusal, handshake failure, or hostname mismatch. With timeoutMs == 0, kicks off the connection and returns the sock immediately while the handshake is still in flight (drive it with sock.poll()); the underlying TCP is given a brief grace (~250 ms) to settle before the handshake call. Pass null for opts to get the defaults.
host(string) — hostname or literal IP addressport(number) — remote porttimeoutMs(number, optional) — handshake deadline in millisecondsopts(map, optional) — explicit TLS options (see below)
Returns: a sock handle, or null.
Wraps an already-accepted TCP socket as the server side of a TLS handshake. tcp must be a sock returned by srv.accept(...) (i.e. its __tcp__ tag is recognized). Without timeoutMs, blocks until the handshake completes. With timeoutMs == 0, returns the TLS sock immediately in "connecting" state (drive both sides with poll()).
tcp(sock) — an accepted TCP sockopts(map) — must be{ key: CryptoKey, cert: X509Certificate }timeoutMs(number, optional) — handshake deadline in milliseconds
Returns: a sock handle, or null.
Client opts
| Key | Default | Notes |
|---|---|---|
verify | true | When true, the client verifies the server's certificate against trustedRoots (or the system trust store if trustedRoots is omitted) and the server's hostname against the SAN/CN. When false, the connection skips chain & hostname validation entirely (an "unsafe client"). Useful for self-signed peers and tests; do not use against untrusted networks. |
trustedRoots | null | Optional list of X509Certificate instances to use as the trust anchor for this connection. When provided, replaces the system trust store; the listed certs are concatenated and parsed as a single chain. |
commonName | null | Override the hostname used for SAN/CN matching. Defaults to the host argument of TLS.connect. Has no effect when verify is false. |
Server opts (for TLS.accept)
| Key | Required | Notes |
|---|---|---|
key | yes | A CryptoKey containing the server's private key (e.g. from Crypto.generateRsa(2048) or CryptoKey().load(path)). |
cert | yes | An X509Certificate containing the server's public certificate (e.g. from Crypto.generateSelfSignedCertificate(...) or X509Certificate().load(path)). |
TLS instance methods
Identical surface to TCP (see the sock instance methods above); the only behavioural difference is the meaning of "connecting" — on a TLS sock, "connecting" covers both the underlying TCP handshake and the TLS handshake, and a sock stays in "connecting" until the TLS handshake completes (or fails into "error").
The four-state TCP vocabulary collapses onto TLS as:
| TLS state | Status string |
|---|---|
TLS DISCONNECTED | "closed" |
TLS HANDSHAKING | "connecting" |
TLS CONNECTED | "connected" |
TLS ERROR / ERROR_HOSTNAME_MISMATCH | "error" |
localAddress / peerAddress / setNoDelay delegate to the underlying TCP transport — TLS itself doesn't introduce its own concept of those.
// HTTPS GET against a public host, full system-CA verification. var s = TLS.connect("example.com", 443, 8000) s.write(Buffer.fromString("GET / HTTP/1.0\r\nHost: example.com\r\n\r\n")) var status_line = s.readLine(5000) print(status_line) s.close()
// Server + client driven concurrently from the same script via // non-waiting handshakes. var c = Crypto.create() var key = c.generateRsa(2048) var cert = c.generateSelfSignedCertificate(key, "CN=localhost", "20240101000000", "20440101000000") var srv = TCP.listen("127.0.0.1", 0) var port = srv.localPort() var cli = TLS.connect("127.0.0.1", port, 0, { verify: false }) var rawSrv = srv.accept(2000) var srvTls = TLS.accept(rawSrv, { key: key, cert: cert }, 0) // Drive both handshakes to completion. while cli.status() == "connecting" || srvTls.status() == "connecting" { cli.poll() srvTls.poll() System.sleep(20) } cli.write(Buffer.fromString("hello\n")) print(srvTls.readLine(2000)) // "hello" srvTls.write(Buffer.fromString("hi back\n")) print(cli.readLine(2000)) // "hi back" cli.close() srvTls.close() srv.close()
Sockets.waitAny and TLS
TLS sockets are valid handles for Sockets.waitAny — the readiness primitives apply the same way. A TLS sock counts as readable when its available() is positive or the connection has terminated ("closed" / "error"), and as writable when its status is "connected". Mixing TCP, TLS, UDP, and TCP-server handles in one waitAny call is supported.
Notes
- The system trust store is loaded once at zym startup and reused for every
TLS.connectthat doesn't pass an explicittrustedRoots. On Linux this is whatever path the platform exposes as the system CA bundle (e.g./etc/ssl/cert.pemon most distros). - A self-signed peer will fail the default verification with
status() == "error"(both chain-validation failures and hostname mismatches collapse onto the shared"error"status). Pass{ verify: false }or a matchingtrustedRootsto allow it. TLS.acceptdrives the server-side handshake the same wayTLS.connectdrives the client side: withtimeoutMs > 0it blocks until handshake completion; withtimeoutMs == 0it returns the TLS sock immediately in"connecting"and the caller drives the handshake withpoll()(typically alongside the client side, since zym is single-threaded).- Server-side TLS doesn't currently have a
TLS.serve(host, port, opts)shorthand; clients useTCP.listen+srv.accept+TLS.acceptexplicitly. This is deliberate — it keeps the TCP and TLS server surfaces composable.
DTLS
Datagram TLS (DTLS) is TLS over UDP. It provides confidentiality and integrity over a UDP association, with the same status and lifecycle shape as TLS, but a datagram-shaped data API (send / recv) instead of TLS's stream-shaped one (read / write). Each dtls.send(buf) call corresponds to exactly one DTLS record on the wire.
send(...) payloads; it only retransmits its own handshake messages. Use it where you need encryption + authentication on top of UDP, not where you need reliability.
DTLS statics
Binds an ephemeral local UDP and runs the client-side DTLS handshake to host:port with default options (system-CA verify). Without timeoutMs, blocks forever; returns null on connect or handshake failure. With a deadline: 0 returns immediately in "connecting" so the caller can drive the handshake via dtls.poll(); -1 blocks. opts supplies explicit client options (see below).
host(string) — hostname or literal IP addressport(number) — remote porttimeoutMs(number, optional) — handshake deadline in millisecondsopts(map, optional) — explicit client options
Returns: a dtls handle, or null.
Like DTLS.connect, but the caller supplies the underlying udp (e.g. for source-port pinning). The udp must come from UDP.bind. The destination is set on the udp if it isn't already connected; further plain-udp use is undefined while DTLS is using it.
udp(udp) — a handle fromUDP.bindto carry the DTLS sessionhost(string) — hostname or literal IP addressport(number) — remote porttimeoutMs(number, optional) — handshake deadline in millisecondsopts(map, optional) — explicit client options
Server-side handshake. udpServer is a handle from UDP.listen(host, port). This call drives the entire DTLS-server flow internally — including the cookie exchange (HelloVerifyRequest) — and returns a fully-handshaken DTLS peer when one is ready, or null on timeout. Calling it repeatedly on the same udpServer accepts more peers; the server-side DTLSServer state (cookies, in-flight handshakes) is persisted on the udpServer handle for as long as it lives.
udpServer(udp-server) — a handle fromUDP.listenopts(map) —{ key, cert }server credentials (see below)timeoutMs(number, optional) — accept deadline in milliseconds
Returns: a dtls handle, or null.
Client opts
Same shape as TLS.connect's client options:
| Key | Default | Notes |
|---|---|---|
verify | true | When false, disables certificate verification entirely. Use only for self-signed test scenarios. |
trustedRoots | null | An X509Certificate, a list of them, or null for the system trust store. |
commonName | "" | Override the SNI / CN check. |
Server opts (for DTLS.accept)
| Key | Notes |
|---|---|
key | CryptoKey from Crypto.generateRsa or a loaded PEM. |
cert | X509Certificate matching the key. |
DTLS instance methods
DTLS instances expose a UDP-shaped surface (send / recv), not a stream-shaped one. The status vocabulary matches TLS.
Returns "connecting" (handshake in progress), "connected", "closed", or "error". Hostname-mismatch and other handshake failures collapse onto "error".
Advances the DTLS state machine once and returns the new status. Required during handshake on the client side when DTLS.connect(... 0) was used; safe to call any time.
Returns the number of pending DTLS records (decrypted datagrams ready to read).
Sends buf as one DTLS record. Returns "ok", "busy" if the handshake hasn't completed yet (caller should poll() and retry), "closed" if the peer is gone, or "error".
buf(Buffer) — the record payload
Without timeoutMs, blocks forever until a record arrives, then returns the decrypted payload as a Buffer. No source address — DTLS is point-to-point post-handshake. With a deadline, returns "timeout" on deadline, and "closed" / "error" otherwise.
timeoutMs(number, optional) — receive deadline in milliseconds
Returns { host, port } of the remote peer — the host string supplied at connect time on the client; the source address of the first ClientHello on the server.
Sends a close-notify alert and tears down the underlying UDP. Idempotent.
var c = DTLS.connect("dtls.example.com", 5684, 8000) if (c == null) { print("connect / handshake failed") return } c.send(Buffer.fromString("hello")) var reply = c.recv(2000) if (typeof(reply) == "map") { print("got", reply.size(), "bytes back") } c.close()
// Mint a key + self-signed certificate via the Crypto native. var crypto = Crypto.create() var key = crypto.generateRsa(2048) var cert = crypto.generateSelfSignedCertificate( key, "CN=localhost,O=zym,C=US", "20230101000000", "20330101000000") // Server: bind a UDP listener for per-source demux. var udps = UDP.listen("127.0.0.1", 0) var port = udps.localPort() // Client: kick off connect (non-waiting), then drive both sides in a // shared loop until the handshake completes. var dc = DTLS.connect("127.0.0.1", port, 0, { verify: false }) var sd = null while (sd == null) { dc.poll() sd = DTLS.accept(udps, { key: key, cert: cert }, 0) System.sleep(5) } while (dc.status() != "connected") { dc.poll() sd.poll() System.sleep(5) } // Now we have an encrypted datagram channel. dc.send(Buffer.fromString("hello dtls")) sd.poll() print(sd.recv(1000).toString()) // -> "hello dtls" dc.close() sd.close() udps.close()
Sockets.waitAny and DTLS / UDP server
DTLS handles and UDP-server handles are valid Sockets.waitAny arguments. A DTLS sock counts as readable when a record is decrypted-and-ready or the connection terminated, and as writable when its status is "connected". A UDP.listen server counts as readable when at least one new source has arrived. Mixing TCP, TLS, UDP, DTLS, TCP-server and UDP-server handles in one waitAny call is supported.
Notes
- The cookie exchange (RFC 6347 §4.2.1) is handled inside
DTLS.accept. Each call drives whatever progress the underlying state machine can make in the availabletimeoutMs; the server's cookie key and any in-flight client handshakes are persisted on theudpServerhandle itself, so callingDTLS.accept(udpServer, ...)repeatedly with small timeouts is safe and is in fact the normal usage pattern. - The first call to
DTLS.accept(udpServer, opts, ...)locks in the server-side{ key, cert }for thatudpServer. Subsequent calls ignore theoptsargument's key/cert pair — to switch credentials, close theudpServerand start a new one. DTLS.connect's underlying UDP grace settles automatically; you do not need to bind the source side yourself unless you want a specific source port (useDTLS.connectFrom).- DTLS handshake timeouts: mbedTLS's internal retransmit timer starts at ~1 s and doubles on each retransmit, capped near 60 s. Pick a
timeoutMsof at least 5–10 seconds for real-world peers; loopback testing typically completes in well under 100 ms. dtls.send/dtls.recvpayloads are bounded by the path MTU. The engine doesn't fragment application data — try to keep individual records under ~1200 bytes for IPv4 internet paths.- DTLS does not retransmit application data. If reliability is needed, either wrap a higher-level acknowledgement protocol on top, or use
TLSoverTCPinstead.
ENet
ENet sits on top of UDP and adds reliability (per-packet, optional), packet ordering, multiplexed channels, and connection liveness — without giving up the datagram model. It is the right tool whenever you want game-style messaging from a script: multi-channel custom protocols, peer-to-peer relay, distributed CLI sync, or anything where TCP's single-stream byte semantics would force you to invent your own framing on top.
ENet against another zym ENet, or any C/C++ application using the upstream enet library.
ENet statics
Opens a connection to an ENet host. Non-blocking: the peer comes back in "connecting" state and the caller must drive host.service(timeoutMs) until the first connect event arrives. If opts.tls is provided, the host is wrapped with a DTLS client (see ENet over DTLS).
host(string) — hostname or literal IP addressport(number) — remote portchannels(number, optional) — channel count, default8(range 1–255)opts(map, optional) —{ tls: {...} }to run the host over DTLS
Returns: a { host, peer } map, or null.
Binds and starts a host that accepts incoming peers, delivered as connect events from service(). If opts.tls = { key, cert } is provided, every inbound peer must complete a DTLS handshake before it is delivered as a connect event.
host(string) — local address to bindport(number) — local port;0lets the OS assign onemaxPeers(number, optional) — default32channels(number, optional) — default8opts(map, optional) —{ tls: { key, cert } }to require DTLS
Returns: an ENet host, or null.
Host instance methods
Pumps the host and returns the next event, or null if none arrived within timeoutMs (clamped to 0). The event map is { type, peer, data, channel }, where type is one of "connect", "disconnect", "receive", or "error". For "receive", data is a Buffer; otherwise it is the application-defined integer code.
Force-pushes outbound queues onto the wire without waiting for the next service().
Returns the bound port. Useful with ENet.listen("...", 0) to discover the OS-assigned port.
Sends buf to every connected peer on channel. mode is "reliable", "unreliable", or "unsequenced".
buf(buffer) — payloadchannel(number) — channel indexmode(string) — delivery mode
Returns: "ok", "error", or "closed".
When refuse is true, stops accepting new inbound connections; existing peers are unaffected. Useful for draining a server before shutdown — especially with DTLS, where the cookie exchange would otherwise still be served.
Tears down the host. Idempotent.
Peer instance methods
Returns "connecting", "connected", "closed", or "error", following the shared status vocabulary described under Conventions.
Sends buf to this peer. channel must lie in [0, channelsAtConnect); an out-of-range index raises a runtime error. mode is as in broadcast.
Returns: "ok", "closed", or "error".
Returns the remote address as { host, port }.
Forces a ping packet immediately.
Returns the most recent round-trip-time sample in milliseconds, or 0 until a measurement exists.
Graceful disconnect: queues a disconnect packet that flushes after pending sends. The optional integer data is delivered to the peer's disconnect event.
Immediate disconnect: drops everything pending and notifies the peer in one shot.
Local-only reset. Does not notify the remote — use disconnect or disconnectNow for that.
Service event shapes
| Type | Other fields |
|---|---|
"connect" | peer (handle), data (integer code), channel (always 0) |
"disconnect" | peer (handle), data (integer code), channel (always 0) |
"receive" | peer (handle), data (Buffer), channel (the channel the sender used) |
"error" | (no other fields) |
ENet over DTLS
Both ENet.connect and ENet.listen accept an optional trailing opts map whose tls field opts the host into DTLS. When set, every datagram the host sends or receives is wrapped in a DTLS record — the channel, ordering, reliability, and service() semantics are unchanged. Once the handshake completes, scripts use the same host and peer instance methods as for plain ENet.
Client opts.tls accepts the same shape as TLS.connect:
| Field | Type | Default | Notes |
|---|---|---|---|
verify | bool | true | When false, the server certificate is accepted without verification (useful for self-signed peers). |
trustedRoots | X509Certificate or list thereof | system trust store | Roots used to validate the server certificate. Ignored when verify is false. |
commonName | string | the host argument | Hostname used for SNI and certificate verification. Override when connecting by IP literal. |
Server opts.tls requires { key, cert }:
| Field | Type | Notes |
|---|---|---|
key | CryptoKey | Private key matching cert. Required. |
cert | X509Certificate | Server certificate. Required. |
A missing key or cert raises a runtime error. Both come from Crypto.
Notes
- Channel count is fixed at handshake. Both sides agree on the channel count at
connect/listentime. Sending on a channel index outside[0, channels)raises a runtime error. - Unreliable vs unsequenced.
"unreliable"packets are sequenced — newer packets supersede older ones on the same channel — but may be dropped."unsequenced"packets are dropped and may be reordered; use them only when order does not matter at all. - No retransmit of unreliable packets. Reliability is opt-in per packet. ENet does not promise delivery for
"unreliable"or"unsequenced"modes. service(timeoutMs)is the heartbeat. ENet has no background thread; all progress — handshake, retransmits, ack delivery, disconnect notifications — happens during aservice()call. Long stretches withoutservice()will trigger peer timeouts.ENet.connectis non-blocking on purpose. Both ends must pumpservice()for the handshake to advance — useful for in-process loopback (one process drives both sides) and required for any multi-peer client (one host serves many concurrent connections).- ENet does not currently appear in
Sockets.waitAny. Usehost.service(timeoutMs)directly; the service call is itself a multi-peer poll across every peer attached to that host. - DTLS adds one round trip. The cookie exchange (
HelloVerifyRequest) costs an extra round trip versus plain ENet. Plan on a few hundred milliseconds ofservice()driving on real networks before the first application data flows. - Self-signed certificates require
verify: false, exactly as withTLS.connect; without it the handshake fails with"error". refuseNewConnections(true)is the graceful-drain knob. With DTLS this also stops the cookie machinery from responding to fresh ClientHellos, which is usually what you want during shutdown.- TLS bring-up is shared with the
Crypto/TLS/DTLSnatives. No extra initialization is required — the TLS layer is already brought up at startup.
WebSocket
An RFC 6455 WebSocket client and server exposed as a single namespace. WebSocket.connect opens a client to a ws:// or wss:// URL; WebSocket.accept wraps an already-accepted TCP (or TLS) socket as the server side of the handshake. The instance returned by either factory exposes a frame-shaped API that follows the same status-string vocabulary and Buffer byte-currency as the rest of these networking natives.
Frame model
- One frame per call. Each
send(buf)orsendText(text)produces exactly one WebSocket frame; eachrecv(...)returns exactly one received frame. There is no implicit fragmentation — continuation frames are reassembled before the frame surfaces to script. - Text vs binary. Binary frames are exchanged as Buffer instances; text frames are sent and received as strings, UTF-8 on the wire. After a successful
recv(...),sock.wasStringPacket()reports which kind it was. - The handshake is poll-driven. Both
connectandacceptreturn immediately in"connecting"state. The caller must drivesock.poll(), or include the sock inSockets.waitAny, untilsock.status()reports"connected".
WebSocket statics
Opens a client to url and returns the sock immediately in "connecting" state; drive the handshake with sock.poll(). Returns null on outright failure, such as a malformed URL or a build without WebSocket support. Pass null for opts to get the defaults.
url(string) —ws://host:port/pathorwss://host:port/pathopts(map, optional) — see the options table below
Returns: a sock handle, or null.
Wraps an already-accepted TCP sock — a handle returned by TCP.listen(...).accept(...) — as the server side of a WebSocket handshake. The TCP sock must remain live as long as the returned WebSocket handle is in use. Returns the sock in "connecting" state; drive the handshake with sock.poll(). For wss:// servers, wrap the accepted TCP with TLS.accept(...) first and pass the resulting TLS sock here.
tcp(sock) — an accepted TCP or TLS sockopts(map, optional) — see the options table below
Returns: a sock handle, or null.
Options
All keys are optional; unknown keys are ignored.
| Key | Default | Notes |
|---|---|---|
tls | null | Client-side only. Controls TLS verification when url is wss://. Same shape as TLS.connect's options: verify, trustedRoots, commonName. When verify is false, the handshake skips chain and hostname validation entirely. Ignored on WebSocket.accept, where the underlying stream already carries the TLS layer. |
protocols | [] | Subprotocol names to advertise. On a client these go out as Sec-WebSocket-Protocol; on a server they constrain the set the peer may pick. The negotiated protocol is available after the handshake via sock.selectedProtocol(). |
headers | [] | Additional handshake header lines as "Name: value" strings. Appended to the HTTP GET upgrade request on the client, or to the HTTP 101 upgrade response on the server. |
inboundBufferSize | 65535 | Maximum size in bytes of the inbound packet buffer. Frames larger than this are rejected. |
outboundBufferSize | 65535 | Maximum size in bytes of the outbound packet buffer. |
maxQueuedPackets | 4096 | Maximum number of frames that may sit in the receive queue between polls before the peer is forced closed. |
heartbeatInterval | 0 | Seconds between automatic ping frames. 0 disables heartbeats. |
Address forms
- Client URLs.
ws://host:port/pathfor plain,wss://host:port/pathfor TLS. Thetlsoption only matters forwss://URLs; passed on a plainws://URL it is ignored. - Server side.
WebSocket.accepttakes an already-bound TCP or TLS socket —WebSocketitself does not bind a listening port. Pair it withTCP.listen(...)plussrv.accept(...)for plainws://, or addTLS.accept(...)forwss://.
Sock instance methods
Returns "connecting", "connected", "closing", or "closed". Does not advance state.
Drives one round of state advancement — handshake progress, frame parsing, heartbeats — and returns the new status. Required during the handshake when the caller used the non-waiting form of connect or accept. Safe to call at any time.
Returns the number of decoded frames ready to read. Polls before reporting.
Sends buf as one binary WebSocket frame. An empty buffer sends an empty binary frame.
Returns: "ok", "closed" when the peer is no longer open, or "error".
Sends text as one text WebSocket frame, UTF-8 on the wire. Same status vocabulary as send.
Returns the next frame's payload as a Buffer. Call sock.wasStringPacket() immediately afterwards to find out whether it was a text frame. Without an argument, blocks until a frame arrives. 0 is a non-blocking peek that returns "busy" when no frame is queued; a positive value waits that many milliseconds and returns "timeout" if nothing arrives; -1 blocks, and is the default. Returns "eof" if the peer closes before any frame arrives, or "error" if the connection drops.
timeoutMs(number, optional) —0to peek,> 0to bound the wait,-1to block
Returns true if the most recently received frame was a text frame, false for a binary one. Reflects the last successful recv(...); the value is undefined before the first frame arrives.
Returns the negotiated subprotocol name, or "" if none was selected. Valid once the status is "connected".
Server-side, the URL the client used in its GET upgrade; client-side, the URL passed to WebSocket.connect. Valid once the status is "connected".
Returns the 16-bit WebSocket close code reported by the peer, or sent locally, once the close handshake has happened. -1 if no close has been observed yet.
Returns the UTF-8 close reason that accompanied closeCode(), or "" if none was provided.
Returns the remote address of the underlying TCP or TLS stream as { host, port }, or null before the handshake completes.
Toggles TCP_NODELAY (Nagle's algorithm) on the underlying transport.
Sends a close frame and tears down the underlying stream. With no arguments, sends a normal closure (1000, no reason). Idempotent.
code(number, optional) — explicit close codereason(string, optional) — sent as UTF-8; the WebSocket spec limits it to 123 bytes, and over-long reasons are truncated
Sockets.waitAny and WebSocket
WebSocket socks are valid handles for Sockets.waitAny, and the readiness primitives apply the same way:
"read"— ready when a frame has been decoded and is sitting in the queue (available() > 0), or when the peer has terminated ("closing"/"closed") so EOF can be observed in the same loop."write"— ready when the status is"connected", and also when terminated, so a send returns its own"closed"status instead of spinning forever."any"— readable or writable.
Mixing WebSocket socks with TCP, TLS, UDP, DTLS, and server handles in one waitAny call is supported.
Notes
- Peer-level API only.
WebSocketexposes a single peer per handle. Game-style multi-peer messaging, where many clients are addressed through one object, is whatENetcovers above. - The underlying TCP or TLS sock must outlive the WebSocket. On the server side the sock returned by
srv.accept(...)(orTLS.accept(...)) is what carries the bytes — keep it in scope until the WebSocket handle is closed. Closing the WebSocket does not close the underlying sock. - Heartbeats are off by default. Pass
heartbeatIntervalin seconds to send ping frames on an interval. The default leaves the connection idle unless data flows, matching the RFC 6455 baseline. - Buffer sizes are hard caps.
inboundBufferSizeandoutboundBufferSizeare fixed at handshake time. Frames larger than the inbound cap are rejected and force the connection into"error"; sends larger than the outbound cap fail with"error". Raise them in the options before connecting if you need multi-megabyte payloads. - Headers are unvalidated. Lines passed via
headersare written verbatim into the HTTP upgrade exchange. Do not pass attacker-controlled strings without filtering for CR and LF.
Examples
ENet echo server
func main(argv) { var srv = ENet.listen("0.0.0.0", 9000, 32, 4) while (true) { var ev = srv.service(100) if (ev == null) continue if (ev["type"] == "receive") { ev["peer"].send(ev["data"], ev["channel"], "reliable") } } }
ENet client with a handshake loop
func main(argv) { var r = ENet.connect("127.0.0.1", 9000, 4) if (r == null) { print("connect failed\n"); return 1 } var host = r["host"] var peer = r["peer"] while (peer.status() == "connecting") { host.service(50) } if (peer.status() != "connected") { print("handshake failed\n"); return 1 } peer.send(Buffer.fromString("hello"), 0, "reliable") host.flush() var ev = host.service(2000) if (ev != null && ev["type"] == "receive") { print("got: %s\n", ev["data"].toUtf8()) } peer.disconnect() host.flush() host.service(50) host.close() return 0 }
ENet over DTLS, in-process
func main(argv) { var c = Crypto.create() var key = c.generateRsa(2048) var cert = c.generateSelfSignedCertificate(key, "CN=zym-test", "20240101000000", "20340101000000") var srv = ENet.listen("127.0.0.1", 0, 4, 4, { tls: { key: key, cert: cert } }) var port = srv.localPort() var pair = ENet.connect("127.0.0.1", port, 4, { tls: { verify: false } }) var host = pair["host"] var peer = pair["peer"] var serverPeer = null while (peer.status() == "connecting" || serverPeer == null) { var ev = srv.service(20) if (ev != null && ev["type"] == "connect") { serverPeer = ev["peer"] } host.service(20) } peer.send(Buffer.fromString("hello dtls"), 0, "reliable") host.flush() var ev = srv.service(500) print("server got: %s\n", ev["data"].toUtf8()) peer.disconnect() host.flush(); host.service(50) host.close(); srv.close() return 0 }
WebSocket client
var s = WebSocket.connect("ws://echo.websocket.events/") while s.status() == "connecting" { s.poll() System.sleep(20) } if s.status() != "connected" { print("handshake failed:", s.status()) return } s.sendText("hello") var frame = s.recv(5000) if typeof(frame) == "string" { print("error/timeout:", frame) } else { if s.wasStringPacket() { print("text reply:", frame.toString()) } else { print("binary reply:", frame.size(), "bytes") } } s.close()
WebSocket server: accept one connection and echo
var srv = TCP.listen("127.0.0.1", 0) print("listening on", srv.localPort()) var raw = srv.accept() // TCP sock var ws = WebSocket.accept(raw) // upgrade to WebSocket while ws.status() == "connecting" { ws.poll() System.sleep(20) } var frame = ws.recv(5000) if typeof(frame) != "string" { ws.send(frame) // echo back } ws.close() raw.close() srv.close()
WebSocket server: wss:// with a self-signed certificate
var c = Crypto.create() var key = c.generateRsa(2048) var cert = c.generateSelfSignedCertificate(key, "CN=localhost", "20240101000000", "20440101000000") var srv = TCP.listen("127.0.0.1", 0) var port = srv.localPort() // Client side runs concurrently; since zym is single-threaded we drive // both sides from one loop. var cli = WebSocket.connect("wss://127.0.0.1:" + port + "/", { tls: { verify: false } }) var rawSrv = srv.accept(2000) var tlsSrv = TLS.accept(rawSrv, { key: key, cert: cert }, 0) var wsSrv = WebSocket.accept(tlsSrv) while cli.status() == "connecting" || wsSrv.status() == "connecting" { cli.poll() tlsSrv.poll() wsSrv.poll() System.sleep(20) } cli.sendText("hello wss") wsSrv.poll() print(wsSrv.recv(2000).toString()) // "hello wss" cli.close() wsSrv.close() tlsSrv.close() srv.close()
Selecting a WebSocket subprotocol
var s = WebSocket.connect("ws://chat.example/", { protocols: ["chat.v2", "chat.v1"] }) while s.status() == "connecting" { s.poll(); System.sleep(20) } print("server picked:", s.selectedProtocol())