MainSDK
Search⌘K
How-to

Use tcp, udp and unix sockets in your app

Declare TCP, UDP and unix socket destinations and listeners in main.yaml, open sockets with TLS through net, test them on the in-process loopback, and check that nothing goes outside the rules.

Building for 0.4

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

You will let your app talk to a TCP service (a database, a mail relay, a collector), run a small TCP server, send a UDP datagram, and prove the rules hold in a test with no real socket. Every call is checked against main.yaml, protocol and port included. It takes about 15 minutes.

You need

  • A MainSDK app with a main.yaml (see Thirty-minute path).
  • The hosts and ports your app talks to, and the ports it listens on.

1. Declare what the app may reach

main.yaml
network:
  allow:
    - tcp://db.acme.dev:5432    # net.connect to this host and port
    - udp://8.8.8.8:53          # net.udp send to this peer and port
  listen:
    - tcp:7000                  # net.listen, loopback only

A plain entry such as db.acme.dev allows only http.* calls. A raw socket needs a tcp:// or udp:// entry with the port, so a host you declared for HTTPS can't be used to carry data out on another port.

2. Open a TCP connection

app/db.ts
import { net } from "mainsdk";

const conn = await net.connect("db.acme.dev", 5432, { tls: { ca: "secret:INTERNAL_CA" } });
const writer = conn.writable.getWriter();
await writer.ready;
await writer.write(new TextEncoder().encode("PING\r\n"));
for await (const chunk of conn.readable) console.log(chunk.length);
await conn.close();

tls: true verifies against the system roots, and ca adds a private CA from a secret. Await writer.ready before each write: it waits while the peer can't keep up, which is how a slow reader slows your app down instead of filling its memory.

3. Listen for TCP

app/server.ts
import { net } from "mainsdk";

await net.listen(7000, async (conn) => {
  const writer = conn.writable.getWriter();
  for await (const chunk of conn.readable) await writer.write(chunk);
});

The server binds loopback. To accept peers from outside, add the bind address and limit who may connect:

main.yaml
network:
  listen:
    - { protocol: tcp, port: 7100, bind: 0.0.0.0, from: [10.0.0.0/8] }

4. Use a unix socket

main.yaml
network:
  allow:
    - unix:///run/app/redis.sock     # net.connectUnix to this path
  listen:
    - unix:///run/app/ctl.sock       # net.listenUnix at exactly this path
app/ctl.ts
import { net } from "mainsdk";

const redis = await net.connectUnix("/run/app/redis.sock");
const ctl = await net.listenUnix("/run/app/ctl.sock", (conn) => void conn.close());

A unix socket reaches this machine, so network.default: allow never opens one. net.listenUnix makes the directory with mode 0700, refuses one that is a symlink, another user's or open to group or others (MAIN-NET-0033), and gives the socket file mode 0600. Unix sockets work on Linux and macOS; on Windows use a loopback TCP port. In tests, app.loop.unixServer adds a fake peer and app.loop.dialUnix connects to your listener.

5. Send UDP

app/dns.ts
import { net } from "mainsdk";

declare const query: Uint8Array;   // a DNS query you built

const sock = await net.udp();
await sock.send(query, 53, "8.8.8.8");
for await (const reply of sock.receive({ signal: AbortSignal.timeout(2000) })) {
  console.log(reply.data.length);
  break;
}
await sock.close();

UDP has no delivery promise: a lost datagram is not retried. Replies are accepted only from the peers your udp:// entries name.

6. Test it with no real socket

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 an in-process network with fake DNS, latency and datagram loss. app.loop.reset(ip, port) cuts every open connection to a peer and app.loop.stats() counts what happened. A call to db.acme.dev:5433 fails with MAIN-NET-0002 in the same test.

7. Find the sockets your code uses

bash
npx mainsdk discover

It lists each net.connect and UDP send as tcp://host:port or udp://host:port, each net.listen and bind as tcp:<port> or udp:<port>, with the file and line, and ends with the lines to add to main.yaml. A call with a variable host or port is listed as dynamic and checked when it runs.

Check it worked

Run npx mainsdk check. It passes when every socket the code opens is declared, no listen entry is open to every peer without from (a warning, MAIN-NET-0030), and no code imports node:net, node:tls or node:dgram directly (MAIN-NET-0001). On a Vercel, Netlify or Lambda target it also reports net.listen and net.udp (MAIN-NET-0028): use http.* or a provider's HTTP API there.

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet