TCP, UDP and unix sockets
netOpen TCP connections, run TCP servers, send UDP datagrams and use unix domain sockets, each checked against the protocols, ports and directions main.yaml declares.
net-socketsFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
net opens TCP connections, runs TCP servers and sends UDP datagrams, each checked against the protocols, ports and directions main.yaml declares.
At a glance
network:
allow:
- api.partner.com # HTTP, HTTPS and WebSocket only
- tcp://db.acme.dev:5432 # raw TCP to this host and port
- udp://8.8.8.8:53 # UDP to this peer and port
- unix:///run/app/redis.sock # a unix domain socket path
listen:
- tcp:7000 # loopback only
- unix:///run/app/ctl.sock # serve this unix socket path
- { protocol: tcp, port: 9000-9010, bind: 0.0.0.0, from: [10.0.0.0/8] }
udp:
multicast: ["239.255.0.1:5000"]
tcp:
timeout: 3s
idle: 5m
tls: { min-version: TLSv1.2 }import { net } from "mainsdk";
// allowed only if network.allow has tcp://db.acme.dev:5432
const conn = await net.connect("db.acme.dev", 5432, { tls: true });
const writer = conn.writable.getWriter();
await writer.write(new TextEncoder().encode("PING\r\n"));test/db.test.ts
import { createTestApp } from "@mainsdk/testing";
const app = createTestApp({
yaml: "schema: 1\nnetwork:\n allow: [tcp://db.acme.dev:5432]\n",
dns: { "db.acme.dev": ["203.0.113.5"] },
});
app.loop!.server("203.0.113.5", 5432, (peer) => {
peer.attach({ data: (c) => void peer.write(c), end() {}, close() {}, error() {}, drain() {} });
});
const conn = await app.net.connect("db.acme.dev", 5432);Calls
net.connect(host, port, opts)net.listen(port, handler, opts)net.udp(opts)net.connectUnix(path, opts)net.listenUnix(path, handler, opts)createNet(get)
Guarantees
Whatever the driver.
- Plain network.allow hosts never open raw sockets; TCP and UDP need an entry with protocol and port
- Listening needs a network.listen entry and binds loopback unless the entry says otherwise
- The socket connects to the address that passed the DNS-rebinding check
- TLS verifies by default, TLS 1.2 minimum, certificates only from secret references
- Connects retry only before the first byte; open streams and UDP are never retried
- Every queue is bounded, with backpressure through Web streams
- Broadcast and multicast are off unless declared
- A unix socket needs a unix:// entry and is never opened by network.default; serving one needs a private 0700 directory and makes the file 0600
- Free on every plan
Drivers
1 drivers. Select one to see its line in main.yaml.
net:
driver: builtinChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-NET-0001 | Checkmainsdk check, before deploy | Raw socket, another HTTP client or direct fetch outside the SDK: new WebSocket, EventSource, node:http, node:https, node:dgram, ws, or git, curl or wget run as a child process |
MAIN-NET-0007 | Checkmainsdk check, before deploy | The code connects to a host that network.allow doesn't list; the finding names the call (http, websocket, event-stream, database, git) |
MAIN-NET-0020 | Checkmainsdk check, before deploy | A tcp://, udp:// or unix:// entry, a network.listen entry or a network.udp entry doesn't parse: no port, a port outside 1 to 65535, a range written high to low, an unknown protocol, IPv6 without brackets, a bind address that isn't an IP, or a unix path that is relative, not normalized, holds .. or * (other than a final /* in allow) or is over 104 bytes (fails at load; names the entry) |
MAIN-NET-0025 | Checkmainsdk check, before deploy | TLS verification turned off outside dev and test, or a minimum version below TLS 1.2 anywhere |
MAIN-NET-0028 | Checkmainsdk check, before deploy | A raw socket on a target that can't run it: net.listen, net.listenUnix or net.udp on a serverless or edge target, net.connect or net.connectUnix on an edge target, or a unix socket call on Windows; names the target, the runtime and the fallback |
MAIN-NET-0029 | Checkmainsdk check, before deploy | A net.listen port or UDP bind found in the code that network.listen doesn't declare |
MAIN-NET-0030 | Checkmainsdk check, before deploy | A network.listen entry binds beyond loopback with no from (a warning, not a failure) |
MAIN-NET-0002 | RuntimeError responses and logs | Call to a host not in network.allow (or not declared by a driver) |
MAIN-NET-0009 | RuntimeError responses and logs | An outbound call timed out: the connect or total timeout of its last attempt, or its deadline, passed (504; the error names which) |
MAIN-NET-0010 | RuntimeError responses and logs | The circuit breaker for the host is open, or half-open with its probe in flight; no request was sent (503, retryable, retryAfter is the rest of the cooldown) |
MAIN-NET-0011 | RuntimeError responses and logs | An outbound call failed with a network error (refused, reset, DNS failure) on its last attempt (502; states the number of attempts) |
MAIN-NET-0021 | RuntimeError responses and logs | See the spec |
MAIN-NET-0022 | RuntimeError responses and logs | See the spec |
MAIN-NET-0023 | RuntimeError responses and logs | See the spec |
MAIN-NET-0024 | RuntimeError responses and logs | See the spec |
MAIN-NET-0026 | RuntimeError responses and logs | See the spec |
MAIN-NET-0027 | RuntimeError responses and logs | See the spec |
MAIN-NET-0031 | RuntimeError responses and logs | See the spec |
MAIN-NET-0032 | RuntimeError responses and logs | See the spec |
MAIN-NET-0033 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| tcp:// and udp:// entries, network.listen and network.udp | Phase 1 | 0.4 | net-sockets#grammar |
| Unix domain sockets with unix:// entries, a private directory and a 0600 socket file | Phase 1 | 0.4 | net-sockets#unix |
| net.connect over Web streams to the checked address | Phase 1 | 0.4 | net-sockets#connect |
| Connect timeout, connect retries and a breaker per host and port | Phase 1 | 0.4 | net-sockets#connect-reliability |
| Backpressure and half-open handling | Phase 1 | 0.4 | net-sockets#streams |
| TLS verified by default, mTLS and startTls | Phase 1 | 0.4 | net-sockets#tls |
| net.listen with bind and from limits, shedding and the drain | Phase 1 | 0.4 | net-sockets#listen |
| net.udp with send, receive and bind | Phase 1 | 0.4 | net-sockets#udp |
| Declared broadcast and multicast | Phase 1 | 0.4 | net-sockets#udp-groups |
| Sockets discovered and checked | Phase 1 | 0.4 | net-sockets#discover-check |
| Serverless and edge targets reported in check | Phase 1 | 0.4 | net-sockets#runtime-check |
| Socket rules exported as platform firewall rules | Phase 1 | 0.4 | net-sockets#export |
| In-process loopback network for tests | Phase 1 | 0.4 | net-sockets#loopback-fake |
Quick start
network:
allow:
- api.partner.com # HTTP, HTTPS and WebSocket only
- tcp://db.acme.dev:5432 # raw TCP to this host and port
- udp://8.8.8.8:53 # UDP to this peer and port
listen:
- tcp:7000 # loopback only
- { protocol: tcp, port: 9000-9010, bind: 0.0.0.0, from: [10.0.0.0/8] }
udp:
multicast: ["239.255.0.1:5000"]
tcp:
timeout: 3s
idle: 5m
tls: { min-version: TLSv1.2 }import { net } from "mainsdk";
// allowed only if network.allow has tcp://db.acme.dev:5432
const conn = await net.connect("db.acme.dev", 5432, { tls: true });
const writer = conn.writable.getWriter();
await writer.write(new TextEncoder().encode("PING\r\n"));A plain entry such as api.partner.com still allows only http.*. A raw socket to the same host is refused with MAIN-NET-0002 until a tcp:// or udp:// entry names the port.
Configuration
| Entry | Meaning |
|---|---|
host, 10.0.0.0/8, "*.acme.dev" | Unchanged: HTTP, HTTPS and WebSocket through http.*, on any port |
tcp://db.acme.dev:5432 | net.connect to this host and port |
tcp://smtp.acme.dev:25,465,587 | A list of ports |
"tcp://*.cache.acme.dev:7000-7010" | Subdomains and a port range |
"tcp://[2001:db8::/32]:443" | IPv6 goes in brackets |
udp://8.8.8.8:53 | net.udp send to this peer and port, and take its replies |
unix:///run/app/redis.sock | net.connectUnix to this socket path |
"unix:///run/pool/*" | Every socket directly inside this directory |
listen: ["unix:///run/app/ctl.sock"] | net.listenUnix at exactly this path |
listen: [tcp:7000] | net.listen on loopback only |
listen: ["tcp:9000-9010@0.0.0.0"] | Bind every IPv4 interface |
listen: [{ protocol: tcp, port: 7100, bind: 0.0.0.0, from: [10.0.0.0/8] }] | Only these peers get through |
udp: { broadcast: [9] } | Ports the app may broadcast to |
udp: { multicast: ["239.255.0.1:5000"] } | Groups the app may join and send to |
A port is 1 to 65535, * means every port and is written on purpose, and 0 in a listen entry asks the system for a free port. An entry with no port, an unknown protocol, IPv6 without brackets or a range written high to low fails at load with MAIN-NET-0020. There is no default for listen: even with network.default: allow, nothing listens unless an entry says so.
A name is resolved once, and the socket connects to the address that passed the check. If a name answers with a private address, only a tcp:// or udp:// range that covers it lets the call through. Link-local, unspecified and multicast answers from DNS are refused whatever the rules say.
Defaults under network.tcp
The values shown are the defaults.
| Key | What it does |
|---|---|
timeout: 3s | One connect attempt: DNS, handshake and TLS |
idle: 5m | Close a connection with no bytes either way (MAIN-NET-0022); off for none |
keepalive: 30s | The keepalive delay; off for none |
retries: { attempts: 3, backoff: 200ms, max: 5s } | Connect only, before the first byte; or off |
breaker: { failures: 50%, window: 30s, min: 10, cooldown: 15s } | One breaker per host and port; or off |
max-connections: 1000 | Per net.listen server |
tls: { min-version: TLSv1.2 } | TLSv1.2 or TLSv1.3; a lower version fails with MAIN-NET-0025 |
net.connect
import { net } from "mainsdk";
const conn = await net.connect("db.acme.dev", 5432, {
tls: { ca: "secret:INTERNAL_CA", alpn: ["postgresql"] },
timeout: { connect: "2s", idle: "1m" },
});
const { remoteAddress, tls } = await conn.opened; // tls.version, tls.alpnreadable and writable are Web streams. Both hold at most 64 KiB, so await writer.ready is real backpressure and a slow reader slows the peer down. The peer's end closes both sides unless you pass halfOpen: true. closed rejects with the MainError that closed the connection, and signal aborts when it closes or the shutdown drain cuts it.
A connect is retried only on a timeout, a refusal or a reset, never after the first byte. When the last attempt fails you get MAIN-NET-0009 (timeout, 504) or MAIN-NET-0011 (refused, 502), and an open breaker fails at once with MAIN-NET-0010. A connection that fails once it is open fails with MAIN-NET-0023 and is never retried.
TLS
| Option | What it does |
|---|---|
tls: true | Verify against the system roots |
ca: "secret:NAME" | Add a private CA to the roots |
cert, key | A client certificate for mutual TLS, both secret references |
servername | The SNI and the name checked; defaults to the host |
minVersion | TLSv1.2 or TLSv1.3 |
verify: false | Only when the env is dev or test, with one warning; elsewhere MAIN-NET-0025 |
A handshake that fails, whether for the certificate, the name, the version or a client certificate, is MAIN-NET-0024 and names which. Certificates and keys come only from secret references; a PEM string passed in code is refused with MAIN-NET-0032, and no log, event or error ever holds their text.
conn.startTls(opts) upgrades a plaintext connection in place, for SMTP, IMAP and Postgres. The old connection is retired and refuses writes.
net.listen
import { net, tenant } from "mainsdk";
const server = await net.listen(7000, async (conn) => {
// Each connection starts a new context: its own trace id and no tenant.
await tenant.run("t1", async () => {
// read conn.readable, write conn.writable
});
}, { maxConnections: 100 });
console.log(server.address());net.listen binds 127.0.0.1 unless bind is given and the network.listen entry allows it. A port or address no entry covers fails with MAIN-NET-0021. With from, a peer outside the list is closed before your handler runs and reported once a minute as MAIN-NET-0026. Past maxConnections a new peer is closed and reported as MAIN-NET-0031. While the app sheds load (see Alerts and health) a new peer is closed too, unless the server was opened with critical: true.
Servers join the shutdown drain: on app.shutdown() the server stops accepting at once, and a connection still open near the deadline is cut with its signal aborting and counted under cut.streams. Each start and stop writes an audit record with the port, bind address, TLS and the calling file and line.
Serve TLS with tls: { cert: "secret:CERT", key: "secret:KEY", clients: "require" }.
Unix domain sockets
import { net } from "mainsdk";
// Serve a control socket, and talk to a local Redis through its socket file.
await net.listenUnix("/run/app/ctl.sock", (conn) => void conn.close());
const redis = await net.connectUnix("/run/app/redis.sock", { timeout: "1s" });A unix socket reaches this machine, so it is stricter than TCP: network.default: allow never opens one and a missing listen key serves none. net.connectUnix needs a unix:// entry in network.allow (a path, or dir/* for the sockets directly inside a directory) and fails with MAIN-NET-0002 otherwise. net.listenUnix needs the exact path in network.listen and fails with MAIN-NET-0021 otherwise. A path is absolute, normalized, with no .., and at most 104 bytes; the entry is checked at load (MAIN-NET-0020) and the call's path at run time (MAIN-NET-0032).
net.listenUnix keeps the socket private to your user. It makes a missing directory with mode 0700, refuses a directory that is a symlink, belongs to another user or is open to group or others (MAIN-NET-0033), and sets the socket file to 0600. It replaces a stale socket file only when nothing answers on it, never replaces a file that is not a socket, and removes the file when the server closes. The connection, its timeout (unix-connect), retries, breaker, maxConnections, shedding and shutdown drain behave as for TCP; tls, nagle and keepalive don't apply. server.address() is { address: <path>, port: 0 }.
Unix sockets work on Linux and macOS. On Windows both calls fail with MAIN-NET-0028; use a loopback TCP port there.
net.udp
import { net } from "mainsdk";
declare const query: Uint8Array; // a DNS query you built
const sock = await net.udp(); // an ephemeral loopback port that takes replies only
await sock.send(query, 53, "8.8.8.8");
for await (const { data, address } of sock.receive({ signal: AbortSignal.timeout(2000) })) {
console.log(address, data);
break;
}
await sock.close();send resolves when the datagram is handed to the system. UDP has no delivery promise and is never retried. A datagram over 65,507 bytes fails with MAIN-NET-0027. A datagram from a peer no udp:// entry covers is dropped, not delivered. Datagrams wait in a queue of 1,024 by default (queue), and the rest are dropped and counted as MAIN-NET-0026 once a minute.
To bind a port, name it under network.listen (udp:5353) and pass bind: { port: 5353 }. Broadcast needs network.udp.broadcast to name the port. join(group) and sending to a group need network.udp.multicast and a udp listen entry for the port.
Serverless and edge
| Call | Node, Bun, Deno (long-running) | Serverless Node (Vercel functions, Netlify, AWS Lambda) | workerd, Vercel Edge |
|---|---|---|---|
net.connect with TLS | yes | yes | no (MAIN-PORT-0002) |
net.listen | yes | no (MAIN-NET-0028) | no |
net.udp | yes | no (MAIN-NET-0028) | no |
net.connectUnix | Linux and macOS | yes | no (MAIN-PORT-0002) |
net.listenUnix | Linux and macOS | no (MAIN-NET-0028) | no |
On serverless and edge, send outbound work through http.* or a provider's HTTP API, take inbound traffic through your framework's HTTP routes, and use realtime for live updates. mainsdk check reports a socket entry or call on a target that can't run it as MAIN-NET-0028.
Checks, discovery and export
npx mainsdk discoverlists eachnet.connectUnixandnet.listenUnixwith a literal path asunix:///path, and eachnet.connectand UDPsendwith a literal host and port astcp://host:portorudp://host:port, eachnet.listenand UDP bind astcp:<port>orudp:<port>, with the file and line, and lists a call with a variable host or port as dynamic.npx mainsdk checkfails on an undeclared destination (MAIN-NET-0007) or listen (MAIN-NET-0029), warns on a listen entry beyond loopback with nofrom(MAIN-NET-0030), and fails onverify: falsewhen checked with--env prod(MAIN-NET-0025). App code that importsnode:net,node:tlsornode:dgramfails withMAIN-NET-0001, and the fix namesnet.connect,net.listenornet.udp.npx mainsdk network export --to k8sand--to aws-sgwritetcp://andudp://entries as egress rules with protocol and port, and listen entries beyond loopback as ingress rules with theirfromblocks. A loopback-only listen produces no rule.--to verceland--to cloudflarelist socket entries as skipped.
Calls
| Call | What it does |
|---|---|
net.connect(host, port, opts) | Open a TCP connection as Web streams, to an address the rules allow |
net.listen(port, handler, opts) | Run a TCP server; each connection gets a fresh context |
net.udp(opts) | Open a UDP socket: send, receive, bind, broadcast and multicast |
net.connectUnix(path, opts) | Open a connection to a unix domain socket as Web streams, to a path the rules allow |
net.listenUnix(path, handler, opts) | Serve a unix domain socket in a private directory with a 0600 socket file |
Helpers for wiring and tests
| Call | What it does |
|---|---|
createNet(get) | Wrap a transport in the net interface, for tests and custom wiring. |
What you get
- It is for a database wire protocol, an SMTP relay, a syslog collector, DNS, a game or IoT protocol, or a small TCP service of your own.
- Every connection, listener and datagram is checked against
main.yaml, protocol, port and direction included, so a host you declared for HTTPS can't carry data out over port 25. - It runs inside your app and is free on every plan.
- Plain network.allow hosts never open raw sockets; TCP and UDP need an entry with protocol and port
- Listening needs a network.listen entry and binds loopback unless the entry says otherwise
- The socket connects to the address that passed the DNS-rebinding check
- TLS verifies by default, TLS 1.2 minimum, certificates only from secret references
- Connects retry only before the first byte; open streams and UDP are never retried
- Every queue is bounded, with backpressure through Web streams
- Broadcast and multicast are off unless declared
- A unix socket needs a unix:// entry and is never opened by network.default; serving one needs a private 0700 directory and makes the file 0600
- Free on every plan
Errors and checks
MAIN-NET-0001MAIN-NET-0002MAIN-NET-0007MAIN-NET-0009MAIN-NET-0010MAIN-NET-0011MAIN-NET-0020MAIN-NET-0021MAIN-NET-0022MAIN-NET-0023MAIN-NET-0024MAIN-NET-0025MAIN-NET-0026MAIN-NET-0027MAIN-NET-0028MAIN-NET-0029MAIN-NET-0030MAIN-NET-0031MAIN-NET-0032MAIN-NET-0033
Each code's cause and fix is in check codes.
Swapping drivers
net has one driver, builtin, so there is nothing to swap. What it may reach is set by the rules in main.yaml.
| Driver | Provider | |
|---|---|---|
builtin | Built in | default |
Testing
createTestApp runs net on an in-process loopback, so no real socket is opened:
import { createTestApp } from "@mainsdk/testing";
const app = createTestApp({
yaml: "schema: 1\nnetwork:\n allow: [tcp://db.acme.dev:5432]\n",
dns: { "db.acme.dev": ["203.0.113.5"] },
});
app.loop!.server("203.0.113.5", 5432, (peer) => {
peer.attach({ data: (c) => void peer.write(c), end() {}, close() {}, error() {}, drain() {} });
});
const conn = await app.net.connect("db.acme.dev", 5432);app.loop is the loopback: server adds a fake TCP peer, udpPeer a fake UDP peer, dial connects to your listener as a remote peer, unixServer and dialUnix do the same for unix sockets (unixDir makes a directory look open, foreign or a symlink, and unixMode reads the socket file mode), reset cuts open connections, and stats() counts connects, accepted connections, datagrams, drops and bytes. Pass createLoopbackNet({ latency: "5ms", loss: 0.1 }) as net to add latency (on the test clock) or datagram loss, and net: "real" to use real sockets on 127.0.0.1.
Next
- Network permissions: the allow list
netentries live in. - Realtime: live updates where sockets are not available.
- Testing kit:
createTestAppand the loopback. - Check codes: each code's cause and fix.
Was this page helpful?