MainSDK
Search⌘K
Interfaces

Platform, OS and network

TCP, UDP and unix sockets

net

Open TCP connections, run TCP servers, send UDP datagrams and use unix domain sockets, each checked against the protocols, ports and directions main.yaml declares.

Building for 0.4ships as a beta APIPhase 1 · Core, public betaSpec net-socketsFree in your app, Apache-2.0

Status 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.
Stability levels
Updated 2026-10-28

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 }

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.

main.yamlBuilt in (default)
net:
  driver: builtin

Change this one line to swap providers; your calls stay the same.Built-in and local drivers integration

Codes

CodeWhere it appearsMeaning
MAIN-NET-0001Checkmainsdk check, before deployRaw 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-0007Checkmainsdk check, before deployThe code connects to a host that network.allow doesn't list; the finding names the call (http, websocket, event-stream, database, git)
MAIN-NET-0020Checkmainsdk check, before deployA 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-0025Checkmainsdk check, before deployTLS verification turned off outside dev and test, or a minimum version below TLS 1.2 anywhere
MAIN-NET-0028Checkmainsdk check, before deployA 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-0029Checkmainsdk check, before deployA net.listen port or UDP bind found in the code that network.listen doesn't declare
MAIN-NET-0030Checkmainsdk check, before deployA network.listen entry binds beyond loopback with no from (a warning, not a failure)
MAIN-NET-0002RuntimeError responses and logsCall to a host not in network.allow (or not declared by a driver)
MAIN-NET-0009RuntimeError responses and logsAn outbound call timed out: the connect or total timeout of its last attempt, or its deadline, passed (504; the error names which)
MAIN-NET-0010RuntimeError responses and logsThe 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-0011RuntimeError responses and logsAn outbound call failed with a network error (refused, reset, DNS failure) on its last attempt (502; states the number of attempts)
MAIN-NET-0021RuntimeError responses and logsSee the spec
MAIN-NET-0022RuntimeError responses and logsSee the spec
MAIN-NET-0023RuntimeError responses and logsSee the spec
MAIN-NET-0024RuntimeError responses and logsSee the spec
MAIN-NET-0026RuntimeError responses and logsSee the spec
MAIN-NET-0027RuntimeError responses and logsSee the spec
MAIN-NET-0031RuntimeError responses and logsSee the spec
MAIN-NET-0032RuntimeError responses and logsSee the spec
MAIN-NET-0033RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
tcp:// and udp:// entries, network.listen and network.udpPhase 10.4net-sockets#grammar
Unix domain sockets with unix:// entries, a private directory and a 0600 socket filePhase 10.4net-sockets#unix
net.connect over Web streams to the checked addressPhase 10.4net-sockets#connect
Connect timeout, connect retries and a breaker per host and portPhase 10.4net-sockets#connect-reliability
Backpressure and half-open handlingPhase 10.4net-sockets#streams
TLS verified by default, mTLS and startTlsPhase 10.4net-sockets#tls
net.listen with bind and from limits, shedding and the drainPhase 10.4net-sockets#listen
net.udp with send, receive and bindPhase 10.4net-sockets#udp
Declared broadcast and multicastPhase 10.4net-sockets#udp-groups
Sockets discovered and checkedPhase 10.4net-sockets#discover-check
Serverless and edge targets reported in checkPhase 10.4net-sockets#runtime-check
Socket rules exported as platform firewall rulesPhase 10.4net-sockets#export
In-process loopback network for testsPhase 10.4net-sockets#loopback-fake

Quick start

main.yaml
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 }
app/example.ts
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

EntryMeaning
host, 10.0.0.0/8, "*.acme.dev"Unchanged: HTTP, HTTPS and WebSocket through http.*, on any port
tcp://db.acme.dev:5432net.connect to this host and port
tcp://smtp.acme.dev:25,465,587A 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:53net.udp send to this peer and port, and take its replies
unix:///run/app/redis.socknet.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.

KeyWhat it does
timeout: 3sOne connect attempt: DNS, handshake and TLS
idle: 5mClose a connection with no bytes either way (MAIN-NET-0022); off for none
keepalive: 30sThe 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: 1000Per net.listen server
tls: { min-version: TLSv1.2 }TLSv1.2 or TLSv1.3; a lower version fails with MAIN-NET-0025

net.connect

app/db.ts
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.alpn

readable 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

OptionWhat it does
tls: trueVerify against the system roots
ca: "secret:NAME"Add a private CA to the roots
cert, keyA client certificate for mutual TLS, both secret references
servernameThe SNI and the name checked; defaults to the host
minVersionTLSv1.2 or TLSv1.3
verify: falseOnly 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

app/server.ts
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

app/ctl.ts
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

app/dns.ts
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

CallNode, Bun, Deno (long-running)Serverless Node (Vercel functions, Netlify, AWS Lambda)workerd, Vercel Edge
net.connect with TLSyesyesno (MAIN-PORT-0002)
net.listenyesno (MAIN-NET-0028)no
net.udpyesno (MAIN-NET-0028)no
net.connectUnixLinux and macOSyesno (MAIN-PORT-0002)
net.listenUnixLinux and macOSno (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 discover lists each net.connectUnix and net.listenUnix with a literal path as unix:///path, and each net.connect and UDP send with a literal host and port as tcp://host:port or udp://host:port, each net.listen and UDP bind as tcp:<port> or udp:<port>, with the file and line, and lists a call with a variable host or port as dynamic.
  • npx mainsdk check fails on an undeclared destination (MAIN-NET-0007) or listen (MAIN-NET-0029), warns on a listen entry beyond loopback with no from (MAIN-NET-0030), and fails on verify: false when checked with --env prod (MAIN-NET-0025). App code that imports node:net, node:tls or node:dgram fails with MAIN-NET-0001, and the fix names net.connect, net.listen or net.udp.
  • npx mainsdk network export --to k8s and --to aws-sg write tcp:// and udp:// entries as egress rules with protocol and port, and listen entries beyond loopback as ingress rules with their from blocks. A loopback-only listen produces no rule. --to vercel and --to cloudflare list socket entries as skipped.

Calls

CallWhat 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

CallWhat 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-0001
  • MAIN-NET-0002
  • MAIN-NET-0007
  • MAIN-NET-0009
  • MAIN-NET-0010
  • MAIN-NET-0011
  • MAIN-NET-0020
  • MAIN-NET-0021
  • MAIN-NET-0022
  • MAIN-NET-0023
  • MAIN-NET-0024
  • MAIN-NET-0025
  • MAIN-NET-0026
  • MAIN-NET-0027
  • MAIN-NET-0028
  • MAIN-NET-0029
  • MAIN-NET-0030
  • MAIN-NET-0031
  • MAIN-NET-0032
  • MAIN-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.

DriverProvider
builtinBuilt indefault

Testing

createTestApp runs net on an in-process loopback, so no real socket is opened:

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);

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

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet