---
title: "TCP, UDP and unix sockets"
description: "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."
section: "interfaces"
status: "Building for 0.4 · ships as a beta API"
spec: "net-sockets"
canonical: "https://mainsdk.dev/docs/interfaces/net"
updated: "2026-10-28"
---

# TCP, UDP and unix sockets

> 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.

Status: Building for 0.4 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/net · 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

- Status: Building for 0.4 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.4 Hardening
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/net-sockets.md
- Category: Platform, OS and network

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

In main.yaml:

```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
    - 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 }
```

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

Pick one in main.yaml with `net.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in | `builtin` | yes |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-NET-0001` | check | 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` | check | 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` | check | 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` | check | TLS verification turned off outside `dev` and `test`, or a minimum version below TLS 1.2 anywhere |
| `MAIN-NET-0028` | check | 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` | check | A `net.listen` port or UDP bind found in the code that `network.listen` doesn't declare |
| `MAIN-NET-0030` | check | A `network.listen` entry binds beyond loopback with no `from` (a warning, not a failure) |
| `MAIN-NET-0002` | runtime | Call to a host not in `network.allow` (or not declared by a driver) |
| `MAIN-NET-0009` | runtime | 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` | runtime | 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` | runtime | 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` | runtime |  |
| `MAIN-NET-0022` | runtime |  |
| `MAIN-NET-0023` | runtime |  |
| `MAIN-NET-0024` | runtime |  |
| `MAIN-NET-0026` | runtime |  |
| `MAIN-NET-0027` | runtime |  |
| `MAIN-NET-0031` | runtime |  |
| `MAIN-NET-0032` | runtime |  |
| `MAIN-NET-0033` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| tcp:// and udp:// entries, network.listen and network.udp | 1 | 0.4 | `net-sockets#grammar` |
| Unix domain sockets with unix:// entries, a private directory and a 0600 socket file | 1 | 0.4 | `net-sockets#unix` |
| net.connect over Web streams to the checked address | 1 | 0.4 | `net-sockets#connect` |
| Connect timeout, connect retries and a breaker per host and port | 1 | 0.4 | `net-sockets#connect-reliability` |
| Backpressure and half-open handling | 1 | 0.4 | `net-sockets#streams` |
| TLS verified by default, mTLS and startTls | 1 | 0.4 | `net-sockets#tls` |
| net.listen with bind and from limits, shedding and the drain | 1 | 0.4 | `net-sockets#listen` |
| net.udp with send, receive and bind | 1 | 0.4 | `net-sockets#udp` |
| Declared broadcast and multicast | 1 | 0.4 | `net-sockets#udp-groups` |
| Sockets discovered and checked | 1 | 0.4 | `net-sockets#discover-check` |
| Serverless and edge targets reported in check | 1 | 0.4 | `net-sockets#runtime-check` |
| Socket rules exported as platform firewall rules | 1 | 0.4 | `net-sockets#export` |
| In-process loopback network for tests | 1 | 0.4 | `net-sockets#loopback-fake` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="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 }
```

```ts title="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

| 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`

```ts title="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

| 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`

```ts title="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](https://mainsdk.dev/docs/guides/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

```ts title="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`

```ts title="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

| 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](https://mainsdk.dev/docs/interfaces/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

| 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-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](https://mainsdk.dev/docs/reference/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:

```ts title="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

- [Network permissions](https://mainsdk.dev/docs/interfaces/network): the allow list `net` entries live in.
- [Realtime](https://mainsdk.dev/docs/interfaces/realtime): live updates where sockets are not available.
- [Testing kit](https://mainsdk.dev/docs/interfaces/testing): `createTestApp` and the loopback.
- [Check codes](https://mainsdk.dev/docs/reference/check-codes): each code's cause and fix.
