Sockets
runtime:net is raw TCP following the WinterTC Sockets API: connect() for outbound connections, listen() for a server. Bytes move over web streams — nothing blocks. bind() is UDP, and has its own guide.
Client
Opening an outbound connection needs the Net capability (the esrun CLI grants it). Closing the writable half-closes (sends FIN) while reads continue; pass allowHalfOpen: true to keep writing after the peer's FIN.
import { connect } from "runtime:net"; // connect() returns a Socket synchronously; .opened settles once connected. const sock = connect({ hostname: "example.com", port: 80 }); await sock.opened; const w = sock.writable.getWriter(); await w.write(new TextEncoder().encode("GET / HTTP/1.0\r\n\r\n")); await w.close(); // half-close: send FIN, keep reading // Decode through TextDecoderStream so a multi-byte character split across two // chunks is still decoded correctly. let body = ""; for await (const chunk of sock.readable.pipeThrough(new TextDecoderStream())) { body += chunk; }
TLS
secureTransport: "on" negotiates TLS with certificate verification on. sni sets the server name (used for both the SNI extension and hostname verification), and alpn offers protocols — the negotiated one comes back as opened.alpn.
import { connect } from "runtime:net"; // secureTransport: "on" — TLS with certificate verification, offering ALPN. const sock = connect({ hostname: "example.com", port: 443 }, { secureTransport: "on", sni: "example.com", // optional; defaults to the host alpn: ["h2", "http/1.1"], }); const { alpn } = await sock.opened; // negotiated protocol, e.g. "h2" (or null)
STARTTLS
secureTransport: "starttls" opens plaintext and upgrades the same connection in place via startTls(), which returns a new encrypted Socket (the original is consumed).
import { connect } from "runtime:net"; // "starttls" opens plaintext, then upgrades the SAME connection in place // (the SMTP/IMAP/XMPP pattern). const sock = connect({ hostname: "mail.example.com", port: 143 }, { secureTransport: "starttls", }); // ... exchange the plaintext go-ahead, then: const tls = sock.startTls(); // a new, encrypted Socket console.log(tls.upgraded); // true
Server
listen() binds a server (needs NetListen) and yields each accepted Socket as an async iterable. port: 0 picks an ephemeral port — read it from addr.
import { listen } from "runtime:net"; const server = listen({ hostname: "127.0.0.1", port: 8080 }); const { port } = await server.addr; // resolves once listening for await (const conn of server) { // each accepted Socket, already open conn.readable.pipeTo(conn.writable); // echo }
TLS termination
secureTransport: "on" on the server terminates TLS on every accept — pass a PEM cert + key (and optional alpn). The cert/key are supplied inline, so server TLS needs no capability beyond the NetListen the bind already requires.
import { listen } from "runtime:net"; import { file } from "runtime:fs"; // Terminate TLS on accept. cert/key are inline PEM (string or bytes), so the // guest loads them itself — server TLS needs no capability beyond NetListen. const server = listen({ hostname: "127.0.0.1", port: 8443, secureTransport: "on", cert: await file("cert.pem").text(), // PEM chain, leaf first key: await file("key.pem").text(), // PKCS#8 / PKCS#1 / SEC1 alpn: ["h2", "http/1.1"], }); for await (const conn of server) { const { alpn } = await conn.opened; // negotiated protocol conn.readable.pipeTo(conn.writable); // every byte is encrypted }
Unix domain sockets
A local service that listens on a path — the Docker Engine API, PostgreSQL or Redis on the same host, a sidecar — is reached with path in place of the host and port. The rest of the Socket is the same.
import { connect } from "runtime:net"; const docker = connect({ path: "/var/run/docker.sock" }); const writer = docker.writable.getWriter(); await writer.write(new TextEncoder().encode( "GET /version HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n", ));
esrun --allow-net=unix:/var/run/docker.sock app.js
A server listens on a path the same way:
import { listen } from "runtime:net"; const server = listen({ path: "/run/app/app.sock" }); for await (const conn of server) conn.readable.pipeTo(conn.writable);
esrun --allow-listen=unix:/run/app/app.sock server.js
The path must be absolute, and it must be named in the flag: a bare --allow-net reaches the network, not the sockets on the machine. Closing the listener removes its socket file. A file left by a server that was killed makes the next listen fail with ERR_ADDRESS_IN_USE; remove it before starting again. Unix domain sockets are not available on Windows.
UDP
bind() is a datagram socket: send/receive instead of streams, and two capabilities instead of one. It has its own guide.
See also
runtime:netAPI reference — signatures, options, errorsUDP guide — datagrams, multicast, and what UDP does not promise
Internals: sockets — the task-per-socket model, TLS, and what is unbounded