diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aae0a0a..c368bfc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -84,6 +84,26 @@ jobs: if: ${{ steps.release.outputs.releases_created == 'true' }} run: pnpm build + - name: Publish @tcpip/transport + if: ${{ steps.release.outputs['packages/transport--release_created'] == 'true' }} + working-directory: packages/transport + run: | + set -euo pipefail + VERSION=$(node -p "require('./package.json').version") + NPM_VIEW_STDERR=$(mktemp) + EXISTING=$(npm view "@tcpip/transport@$VERSION" version 2>"$NPM_VIEW_STDERR") || STATUS=$? + if [ -n "$EXISTING" ]; then + echo "@tcpip/transport@$VERSION is already published, skipping." + rm -f "$NPM_VIEW_STDERR" + exit 0 + elif [ "${STATUS:-0}" -ne 0 ] && ! grep -qiE 'E404|not found' "$NPM_VIEW_STDERR"; then + cat "$NPM_VIEW_STDERR" + rm -f "$NPM_VIEW_STDERR" + exit 1 + fi + rm -f "$NPM_VIEW_STDERR" + pnpm publish --access public --no-git-checks --ignore-scripts --provenance + - name: Publish @tcpip/wire if: ${{ steps.release.outputs['packages/wire--release_created'] == 'true' }} working-directory: packages/wire diff --git a/.release-please-manifest.json b/.release-please-manifest.json index fce2a06..298145e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,5 +1,6 @@ { "packages/tcpip": "0.3.6", + "packages/transport": "0.0.0", "packages/wire": "0.1.4", "packages/http": "0.1.0", "packages/dns": "0.2.2", diff --git a/README.md b/README.md index f72d881..825175f 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ - **Portable:** User-space network stack built on [`lwIP` + WASM](#why-lwip) - **Tun/Tap:** L3 and L2 hooks using virtual [`TunInterface`](#tun-interface) and [`TapInterface`](#tap-interface) - **Bridge:** Create a virtual switch/LAN by [`bridging`](#bridge-interface) multiple interfaces together -- **TCP API:** Establish TCP connections over the virtual network stack using [clients](#connecttcp) and [servers](#listentcp) -- **UDP API:** Send and receive UDP datagrams over the virtual network stack using [sockets](#openudp) +- **TCP API:** Establish TCP connections over the virtual network stack using [clients](#stacktcpconnect) and [servers](#stacktcplisten) +- **UDP API:** Send and receive UDP datagrams over the virtual network stack using [sockets](#stackudpopen) - **Application APIs:** Higher level protocols are available on top of TCP/UDP ([`@tcpip/http`](packages/http), [`@tcpip/dns`](packages/dns), [`@tcpip/dhcp`](packages/dhcp)) - **Cross platform**: Built on web standard APIs (`ReadableStream`, `WritableStream`, etc) - **Lightweight:** Less than 100KB @@ -77,7 +77,7 @@ const stack = await createStack(); Then add a virtual network interface: ```ts -const tapInterface = await stack.createTapInterface({ +const tapInterface = await stack.interfaces.createTap({ mac: '01:23:45:67:89:ab', ip: '192.168.1.1/24', }); @@ -109,7 +109,7 @@ Now that the plumbing is in place, we can start sending TCP packets between our From our `NetworkStack`, establish an outbound TCP connection destined to the TCP server running in the VM: ```ts -const connection = await stack.connectTcp({ +const connection = await stack.tcp.connect({ host: '192.168.1.2', port: 80, }); @@ -135,7 +135,7 @@ for await (const chunk of connection) { You can also create a TCP server that listens for incoming connections: ```ts -const listener = await stack.listenTcp({ +const listener = await stack.tcp.listen({ port: 80, }); ``` @@ -178,7 +178,7 @@ These interfaces are designed to resemble their counterparts in a real network s A loopback interface simply forwards packets back on to itself. It's akin to 127.0.0.1 (`localhost`) on a typical network stack. ```ts -const loopbackInterface = await stack.createLoopbackInterface({ +const loopbackInterface = await stack.interfaces.createLoopback({ ip: '127.0.0.1/8', }); ``` @@ -194,11 +194,11 @@ const stack = await createStack({ Loopback interfaces are useful when you want to both listen for and establish TCP connections on the same virtual stack without needing to forward packets to a real network interface. ```ts -const listener = await stack.listenTcp({ +const listener = await stack.tcp.listen({ port: 80, }); -const connection = await stack.connectTcp({ +const connection = await stack.tcp.connect({ host: '127.0.0.1', port: 80, }); @@ -207,7 +207,7 @@ const connection = await stack.connectTcp({ The interface's IP address and subnet mask can be retrieved using the `ip` and `netmask` properties: ```ts -const loopbackInterface = await stack.createLoopbackInterface({ +const loopbackInterface = await stack.interfaces.createLoopback({ ip: '127.0.0.1/8', }); @@ -220,7 +220,7 @@ console.log(loopbackInterface.netmask); // 255.0.0.0 A tun interface hooks into inbound and outbound IP packets (L3). ```ts -const tunInterface = await stack.createTunInterface({ +const tunInterface = await stack.interfaces.createTun({ ip: '192.168.1.1/24', }); ``` @@ -258,7 +258,7 @@ someTransport.readable.pipeTo(tunInterface.writable); The reason for this is that, unlike TCP, raw IP packets have no form of flow control (back pressure) and buffering packets without a reader will result in memory exhaustion. If you plan to hook into IP packets, be sure to lock the stream before sending data on the stack, otherwise packets will be dropped. Then once listening begins, be sure to regularly read packets to avoid memory exhaustion. ```ts -const tunInterface = await stack.createTunInterface({ +const tunInterface = await stack.interfaces.createTun({ ip: '192.168.1.1/24', }); @@ -267,7 +267,7 @@ tunInterface.readable.pipeTo(vmNic.writable); vmNic.readable.pipeTo(tunInterface.writable); // Then send data through the stack (like TCP) -const connection = await stack.connectTcp({ +const connection = await stack.tcp.connect({ host: '192.168.1.2', port: 80, }); @@ -278,7 +278,7 @@ const connection = await stack.connectTcp({ The interface's IP address and subnet mask can be retrieved using the `ip` and `netmask` properties: ```ts -const tunInterface = await stack.createTunInterface({ +const tunInterface = await stack.interfaces.createTun({ ip: '192.168.1.1/24', }); @@ -291,7 +291,7 @@ console.log(tunInterface.netmask); // 255.255.255.0 A tap interface hooks into inbound and outbound ethernet frames (L2). ```ts -const tapInterface = await stack.createTapInterface({ +const tapInterface = await stack.interfaces.createTap({ ip: '196.168.1.1/24', }); ``` @@ -336,7 +336,7 @@ vmNic.readable.pipeTo(tapInterface.writable); The reason for this is that, unlike TCP, raw ethernet frames have no form of flow control (back pressure) and buffering frames without a reader will result in memory exhaustion. If you plan to hook into ethernet frames, be sure to lock the stream before sending data on the stack, otherwise frames will be dropped. Then once listening begins, be sure to regularly read frames to avoid memory exhaustion. ```ts -const tapInterface = await stack.createTapInterface({ +const tapInterface = await stack.interfaces.createTap({ mac: '01:23:45:67:89:ab', ip: '196.168.1.1/24', }); @@ -346,7 +346,7 @@ tapInterface.readable.pipeTo(vmNic.writable); vmNic.readable.pipeTo(tapInterface.writable); // Then send data through the stack (like TCP) -const connection = await stack.connectTcp({ +const connection = await stack.tcp.connect({ host: '192.168.1.2', port: 80, }); @@ -354,12 +354,12 @@ const connection = await stack.connectTcp({ ... ``` -Note that `mac` and `ip` are optional parameters for `createTapInterface()`. If you don't provide a MAC address, a random one will be generated. If you don't provide an IP address, the interface will not respond to ARP requests or send ARP requests for unknown IP addresses. Typically you would only omit the IP address if you are using the tap interface as part of a [bridge](#bridge-interface). +Note that `mac` and `ip` are optional parameters for `stack.interfaces.createTap()`. If you don't provide a MAC address, a random one will be generated. If you don't provide an IP address, the interface will not respond to ARP requests or send ARP requests for unknown IP addresses. Typically you would only omit the IP address if you are using the tap interface as part of a [bridge](#bridge-interface). The interface's MAC address, IP address, and subnet mask can be retrieved using the `mac`, `ip` and `netmask` properties: ```ts -const tapInterface = await stack.createTapInterface({ +const tapInterface = await stack.interfaces.createTap({ mac: '02:00:00:00:00:01', ip: '196.168.1.1/24', }); @@ -376,10 +376,10 @@ This is particularly useful when you let the tap interface generate its own rand A bridge interface bridges two or more tap interfaces together into a single logical interface with its own MAC and IP address. It operates at the ethernet level (L2) and will automatically forward frames between the interfaces based on the destination MAC address. ```ts -const port1 = await stack.createTapInterface(); -const port2 = await stack.createTapInterface(); +const port1 = await stack.interfaces.createTap(); +const port2 = await stack.interfaces.createTap(); -const bridge = await stack.createBridgeInterface({ +const bridge = await stack.interfaces.createBridge({ ports: [port1, port2], ip: '192.168.1.1/24', }); @@ -397,8 +397,8 @@ const vm2 = new V86(); const vm1Nic = createV86NetworkStream(vm1); const vm2Nic = createV86NetworkStream(vm2); -const port1 = await stack.createTapInterface(); -const port2 = await stack.createTapInterface(); +const port1 = await stack.interfaces.createTap(); +const port2 = await stack.interfaces.createTap(); // Connect port1 to vm1 port1.readable.pipeTo(vm1Nic.writable); @@ -409,7 +409,7 @@ port2.readable.pipeTo(vm2Nic.writable); vm2Nic.readable.pipeTo(port2.writable); // Bridge the two ports together -const bridge = await stack.createBridgeInterface({ +const bridge = await stack.interfaces.createBridge({ ports: [port1, port2], ip: '192.168.1.1/24', }); @@ -422,7 +422,7 @@ Notice that we intentionally don't set IP addresses on the tap interfaces - they This allows you to, for example, host a TCP server on the router itself in order to communicate with the VMs from JavaScript. You would simply create a TCP server on the stack like so: ```ts -const listener = await stack.listenTcp({ +const listener = await stack.tcp.listen({ port: 80, }); ``` @@ -434,7 +434,7 @@ Just like a `TapInterface`, specifying `mac` and `ip` addresses are optional for The interface's MAC address, IP address, and subnet mask can be retrieved using the `mac`, `ip` and `netmask` properties: ```ts -const bridgeInterface = await stack.createBridgeInterface({ +const bridgeInterface = await stack.interfaces.createBridge({ ports: [port1, port2], mac: '02:00:00:00:00:01', ip: '192.168.1.1/24', @@ -455,10 +455,10 @@ Looking for another type of network interface? See [Future plans](#future-plans) ### Removing interfaces -You can remove any network interface from the stack by calling `removeInterface()`: +You can remove any network interface from the stack by calling `stack.interfaces.remove()`: ```ts -await stack.removeInterface(tapInterface); +await stack.interfaces.remove(tapInterface); ``` ### Listing interfaces @@ -473,32 +473,32 @@ const allInterfaces = stack.interfaces; The TCP API allows you to establish TCP connections over the virtual network stack using clients and servers. -### `connectTcp()` +### `stack.tcp.connect()` -To establish an outbound TCP connection, call `connectTcp()`: +To establish an outbound TCP connection, call `stack.tcp.connect()`: ```ts -const connection = await stack.connectTcp({ +const connection = await stack.tcp.connect({ host: '192.168.1.2', port: 80, }); ``` -`connectTcp()` accepts a `host` and `port` and returns a `Promise` that resolves once the connection is established. See [`TcpConnection`](#tcpconnection). +`stack.tcp.connect()` accepts a `host` and `port` and returns a `Promise` that resolves once the connection is established. See [`TcpConnection`](#tcpconnection). The `host` property can be an IP address or hostname. If it's a hostname, the stack will attempt to resolve it to an IP address using the [embedded DNS resolver](#embedded-resolver). -### `listenTcp()` +### `stack.tcp.listen()` -To create a TCP server that listens for incoming connections, call `listenTcp()`: +To create a TCP server that listens for incoming connections, call `stack.tcp.listen()`: ```ts -const listener = await stack.listenTcp({ +const listener = await stack.tcp.listen({ port: 80, }); ``` -`listenTcp()` returns a `Promise` that resolves once the server is listening. See [`TcpListener`](#tcplistener). +`stack.tcp.listen()` returns a `Promise` that resolves once the server is listening. See [`TcpListener`](#tcplistener). ### `TcpListener` @@ -591,20 +591,20 @@ await connection.close(); The UDP API allows you to send and receive UDP datagrams over the virtual network stack. -### `openUdp()` +### `stack.udp.open()` -To open a UDP socket, call `openUdp()`: +To open a UDP socket, call `stack.udp.open()`: ```ts -const udpSocket = await stack.openUdp(); +const udpSocket = await stack.udp.open(); ``` -Since UDP is connectionless, `openUdp()` is used to create a socket that can both listen for UDP datagrams and send UDP datagrams. It returns a [`UdpSocket`](#udpsocket) that you can use to send and receive data. +Since UDP is connectionless, `stack.udp.open()` is used to create a socket that can both listen for UDP datagrams and send UDP datagrams. It returns a [`UdpSocket`](#udpsocket) that you can use to send and receive data. -Passing no arguments to `openUdp()` will create a socket that sends and receives datagrams on any interface (ie. `0.0.0.0`) and on a random port. If you want to bind to a specific IP address or port, you can pass an options object: +Passing no arguments to `stack.udp.open()` will create a socket that sends and receives datagrams on any interface (ie. `0.0.0.0`) and on a random port. If you want to bind to a specific IP address or port, you can pass an options object: ```ts -const udpSocket = await stack.openUdp({ +const udpSocket = await stack.udp.open({ ip: '10.0.0.1', port: 1234, }); @@ -613,7 +613,7 @@ const udpSocket = await stack.openUdp({ If you are creating a UDP server, you would typically just bind to a port: ```ts -const udpSocket = await stack.openUdp({ +const udpSocket = await stack.udp.open({ port: 1234, }); ``` @@ -621,7 +621,7 @@ const udpSocket = await stack.openUdp({ If you are creating a UDP client, you would typically let the stack choose a random port: ```ts -const udpSocket = await stack.openUdp(); +const udpSocket = await stack.udp.open(); ``` ### `UdpSocket` @@ -681,18 +681,18 @@ await writer.write({ Outbound datagrams follow the same format as inbound datagrams: an object with `host`, `port`, and `data` properties indicating the destination host, port, and data. The `host` property can be an IP address or hostname. If it's a hostname, the stack will attempt to resolve it to an IP address using the [embedded DNS resolver](#embedded-resolver). -Unlike Tun and Tap interfaces which are also connectionless, UDP sockets do not require you to lock the stream before receiving data - simply calling `stack.openUdp()` will begin listening for datagrams. +Unlike Tun and Tap interfaces which are also connectionless, UDP sockets do not require you to lock the stream before receiving data - simply calling `stack.udp.open()` will begin listening for datagrams. -## ICMP API +## Ping API -The ICMP API allows you to ping hosts over the virtual network stack. +The ping API allows you to send ICMP echo requests over the virtual network stack. -### `createPingSession()` +### `stack.ping.createSession()` -To ping a host, first create a ping session using `createPingSession()`: +To ping a host, first create a ping session using `stack.ping.createSession()`: ```ts -const pingSession = await stack.createPingSession({ +const pingSession = await stack.ping.createSession({ host: '192.168.1.2', }); @@ -706,7 +706,7 @@ console.log(new TextDecoder().decode(reply.payload)); await pingSession.close(); ``` -`createPingSession()` accepts a `host` and returns a `Promise`. The `host` can be an IP address or hostname. If it's a hostname, the stack will attempt to resolve the IP using the [embedded DNS resolver](#embedded-resolver). +`stack.ping.createSession()` accepts a `host` and returns a `Promise`. The `host` can be an IP address or hostname. If it's a hostname, the stack will attempt to resolve the IP using the [embedded DNS resolver](#embedded-resolver). Each ping session has a stable ICMP identifier and an automatically incrementing sequence number. Each call to `pingSession.ping()` sends an ICMP echo request and returns a `Promise` that resolves when the matching echo reply is received. The sequence number is incremented with each call to `ping()` while the identifier remains constant over a session. @@ -752,10 +752,10 @@ If you wish to resolve external hostnames, you will need a way to route packets ### Embedded resolver -Each `NetworkStack` has an embedded DNS resolver that can lookup an IP address by hostname when using the TCP, UDP, and ICMP APIs. For example: +Each `NetworkStack` has an embedded DNS resolver that can lookup an IP address by hostname when using the TCP, UDP, and ping APIs. For example: ```ts -const connection = await stack.connectTcp({ +const connection = await stack.tcp.connect({ host: 'mydomain.internal', port: 80, }); @@ -764,7 +764,7 @@ const connection = await stack.connectTcp({ or (for UDP): ```ts -const udpSocket = await stack.openUdp(); +const udpSocket = await stack.udp.open(); const writer = udpSocket.writable.getWriter(); await writer.write({ host: 'mydomain.internal', @@ -803,7 +803,7 @@ import { createStack } from 'tcpip'; import { createDns } from '@tcpip/dns'; const stack = await createStack(); -const { lookup, serve } = await createDns(stack); +const { lookup, serve } = await createDns(stack.udp); ``` #### `lookup()` @@ -817,7 +817,7 @@ const ip = await lookup('mydomain.internal'); By default its name server is set to the local loopback address `127.0.0.1` on port `53` (ie. the stack itself, assuming you will run your own DNS server on it). If you wish to point to a different name server, pass the `nameServer` option to `createDns()`: ```ts -const { lookup } = await createDns(stack, { +const { lookup } = await createDns(stack.udp, { client: { nameServer: { ip: '10.0.0.1', diff --git a/packages/dhcp/README.md b/packages/dhcp/README.md index cc2a17a..803a25d 100644 --- a/packages/dhcp/README.md +++ b/packages/dhcp/README.md @@ -22,11 +22,11 @@ import { createStack } from 'tcpip'; const stack = await createStack(); -const tapInterface = await stack.createTapInterface({ +const tapInterface = await stack.interfaces.createTap({ ip: '192.168.1.1/24', }); -const dhcp = await createDhcp(stack); +const dhcp = await createDhcp(stack.udp); const server = await dhcp.serve({ leaseRange: { start: '192.168.1.100', @@ -43,7 +43,7 @@ Then connect `tapInterface` to another virtual device. For example, with `@tcpip ```ts import { createV86NetworkStream } from '@tcpip/v86'; -import { connectStreams } from 'tcpip'; +import { connectStreams } from '@tcpip/transport'; const vmNic = createV86NetworkStream(emulator); diff --git a/packages/dhcp/package.json b/packages/dhcp/package.json index e5c22da..98feacb 100644 --- a/packages/dhcp/package.json +++ b/packages/dhcp/package.json @@ -25,11 +25,9 @@ } }, "dependencies": { + "@tcpip/transport": "workspace:*", "@tcpip/wire": "workspace:*" }, - "peerDependencies": { - "tcpip": "workspace:*" - }, "devDependencies": { "@total-typescript/tsconfig": "catalog:", "tcpip": "workspace:*", diff --git a/packages/dhcp/src/dhcp-server.test.ts b/packages/dhcp/src/dhcp-server.test.ts index 3b0973d..908056e 100644 --- a/packages/dhcp/src/dhcp-server.test.ts +++ b/packages/dhcp/src/dhcp-server.test.ts @@ -1,4 +1,8 @@ -import type { NetworkStack, UdpDatagram, UdpSocket } from 'tcpip/types'; +import type { + Datagram, + DatagramSocket, + DatagramTransport, +} from '@tcpip/transport'; import { describe, expect, it } from 'vitest'; import { DHCP_SERVER_PORT, @@ -55,19 +59,23 @@ class AsyncQueue implements AsyncIterable { } } -class TestUdpSocket implements UdpSocket, AsyncIterable { - #incoming = new AsyncQueue(); - #outgoing = new AsyncQueue(); +class TestUdpSocket implements DatagramSocket { + #outgoing = new AsyncQueue(); + #readableController?: ReadableStreamDefaultController; - readable = new ReadableStream(); - writable = new WritableStream({ + readable = new ReadableStream({ + start: (controller) => { + this.#readableController = controller; + }, + }); + writable = new WritableStream({ write: async (datagram) => { this.#outgoing.push(datagram); }, }); receive(data: Uint8Array) { - this.#incoming.push({ + this.#readableController?.enqueue({ host: '0.0.0.0', port: 68, data, @@ -96,19 +104,15 @@ class TestUdpSocket implements UdpSocket, AsyncIterable { } async close() { - this.#incoming.close(); this.#outgoing.close(); - } - - [Symbol.asyncIterator](): AsyncIterator { - return this.#incoming[Symbol.asyncIterator](); + this.#readableController?.close(); } } -class TestNetworkStack implements Partial { +class TestDatagramTransport implements DatagramTransport { socket = new TestUdpSocket(); - async openUdp(options = {}) { + async open(options = {}) { expect(options).toEqual({ port: DHCP_SERVER_PORT }); return this.socket; } @@ -166,10 +170,10 @@ function createClientMessage({ } async function createTestServer(options = defaultOptions) { - const stack = new TestNetworkStack(); - const server = new DhcpServer(stack as unknown as NetworkStack, options); + const transport = new TestDatagramTransport(); + const server = new DhcpServer(transport, options); await server.listen(); - return { server, socket: stack.socket }; + return { server, socket: transport.socket }; } describe('DhcpServer', () => { diff --git a/packages/dhcp/src/dhcp-server.ts b/packages/dhcp/src/dhcp-server.ts index 32f27a3..4070902 100644 --- a/packages/dhcp/src/dhcp-server.ts +++ b/packages/dhcp/src/dhcp-server.ts @@ -1,4 +1,9 @@ -import type { NetworkStack, UdpDatagram, UdpSocket } from 'tcpip/types'; +import { fromReadable } from '@tcpip/transport'; +import type { + Datagram, + DatagramSocket, + DatagramTransport, +} from '@tcpip/transport'; import { DHCP_CLIENT_PORT, DHCP_SERVER_PORT, @@ -61,14 +66,14 @@ export type DhcpServerOptions = { }; export class DhcpServer { - #stack: NetworkStack; + #transport: DatagramTransport; #options: DhcpServerOptions; leases = new Map(); #offers = new Map(); - constructor(stack: NetworkStack, options: DhcpServerOptions) { - this.#stack = stack; + constructor(transport: DatagramTransport, options: DhcpServerOptions) { + this.#transport = transport; this.#options = { leaseDuration: 86400, ...options, @@ -76,24 +81,24 @@ export class DhcpServer { } async listen() { - const socket = await this.#stack.openUdp({ + const socket = await this.#transport.open({ port: DHCP_SERVER_PORT, }); this.#processDhcpMessages(socket); } - async #processDhcpMessages(socket: UdpSocket) { + async #processDhcpMessages(socket: DatagramSocket) { const writer = socket.writable.getWriter(); - for await (const datagram of socket) { + for await (const datagram of fromReadable(socket.readable)) { // Process each message without blocking this.#processDhcpMessage(datagram, writer); } } async #processDhcpMessage( - datagram: UdpDatagram, - writer: WritableStreamDefaultWriter + datagram: Datagram, + writer: WritableStreamDefaultWriter ) { try { const reply = this.#handleDhcpMessage(datagram.data); @@ -152,7 +157,7 @@ export class DhcpServer { } } - #handleDiscover(message: DhcpMessage): UdpDatagram | undefined { + #handleDiscover(message: DhcpMessage): Datagram | undefined { const ip = this.#findAvailableIP(message.mac); if (!ip) { return; @@ -182,7 +187,7 @@ export class DhcpServer { }; } - #handleRequest(message: DhcpMessage): UdpDatagram | undefined { + #handleRequest(message: DhcpMessage): Datagram | undefined { this.#deleteExpiredAllocations(); if ( @@ -236,7 +241,7 @@ export class DhcpServer { this.#offers.delete(message.mac); } - #createNak(message: DhcpMessage): UdpDatagram { + #createNak(message: DhcpMessage): Datagram { const nak = serializeDhcpMessage( { op: 2, diff --git a/packages/dhcp/src/dhcp-stack.test.ts b/packages/dhcp/src/dhcp-stack.test.ts index b15caa1..abd2a3e 100644 --- a/packages/dhcp/src/dhcp-stack.test.ts +++ b/packages/dhcp/src/dhcp-stack.test.ts @@ -140,11 +140,11 @@ async function waitForDhcpReply(iterator: AsyncIterator) { describe('DhcpServer with tcpip stack', () => { it('should complete discover/request over UDP broadcast', async () => { const stack = await createStack(); - const tapInterface = await stack.createTapInterface({ + const tapInterface = await stack.interfaces.createTap({ ip: '192.168.1.1/24', mac: '02:00:00:00:00:01', }); - const dhcp = await createDhcp(stack); + const dhcp = await createDhcp(stack.udp); const dhcpServer = await dhcp.serve({ leaseRange: { start: '192.168.1.100', end: '192.168.1.110' }, leaseDuration: 3600, @@ -186,11 +186,11 @@ describe('DhcpServer with tcpip stack', () => { it('should advertise configured DNS servers', async () => { const stack = await createStack(); - const tapInterface = await stack.createTapInterface({ + const tapInterface = await stack.interfaces.createTap({ ip: '192.168.1.1/24', mac: '02:00:00:00:00:01', }); - const dhcp = await createDhcp(stack); + const dhcp = await createDhcp(stack.udp); await dhcp.serve({ leaseRange: { start: '192.168.1.100', end: '192.168.1.110' }, leaseDuration: 3600, diff --git a/packages/dhcp/src/index.ts b/packages/dhcp/src/index.ts index f096f6a..19b9fd7 100644 --- a/packages/dhcp/src/index.ts +++ b/packages/dhcp/src/index.ts @@ -1,21 +1,21 @@ -import type { NetworkStack } from 'tcpip/types'; +import type { DatagramTransport } from '@tcpip/transport'; import { DhcpServer, type DhcpServerOptions } from './dhcp-server.js'; export * from './dhcp-server.js'; export type { DhcpLease } from './types.js'; /** - * Creates a DHCP server function on top of a `tcpip` network stack. + * Creates a DHCP server function on top of a datagram transport. * * @example * const stack = await createStack(); - * const { serve } = await createDhcp(stack); + * const { serve } = await createDhcp(stack.udp); * const dhcpServer = await serve({ ... }); */ -export async function createDhcp(stack: NetworkStack) { +export async function createDhcp(transport: DatagramTransport) { return { serve: async (options: DhcpServerOptions) => { - const server = new DhcpServer(stack, options); + const server = new DhcpServer(transport, options); await server.listen(); return server; }, diff --git a/packages/dhcp/tsconfig.json b/packages/dhcp/tsconfig.json index 4c3686b..3e99fd6 100644 --- a/packages/dhcp/tsconfig.json +++ b/packages/dhcp/tsconfig.json @@ -5,8 +5,8 @@ "lib": ["es2022", "dom", "dom.iterable", "esnext.disposable"], "paths": { "tcpip": ["../tcpip/src/index.js"], - "tcpip/types": ["../tcpip/src/types.js"], "@tcpip/dns": ["../dns/src/index.js"], + "@tcpip/transport": ["../transport/src/index.js"], "@tcpip/wire": ["../wire/src/index.js"] } } diff --git a/packages/dns/package.json b/packages/dns/package.json index 874e6d9..c0fa0fb 100644 --- a/packages/dns/package.json +++ b/packages/dns/package.json @@ -25,6 +25,7 @@ } }, "dependencies": { + "@tcpip/transport": "workspace:*", "@tcpip/wire": "workspace:*" }, "devDependencies": { diff --git a/packages/dns/src/dns-client.ts b/packages/dns/src/dns-client.ts index e55c2d6..b1babbb 100644 --- a/packages/dns/src/dns-client.ts +++ b/packages/dns/src/dns-client.ts @@ -1,4 +1,5 @@ -import type { NetworkStack } from 'tcpip/types'; +import { fromReadable } from '@tcpip/transport'; +import type { DatagramTransport } from '@tcpip/transport'; import type { DnsMessage, DnsQuery, DnsRecord, NameServer } from './types.js'; import { ipToPtrName } from './util.js'; import { parseDnsMessage, serializeDnsMessage } from './wire.js'; @@ -12,12 +13,12 @@ export type DnsClientOptions = { }; export class DnsClient { - #stack: NetworkStack; + #transport: DatagramTransport; #nameServer: NameServer; #messageId = 0; - constructor(stack: NetworkStack, options: DnsClientOptions = {}) { - this.#stack = stack; + constructor(transport: DatagramTransport, options: DnsClientOptions = {}) { + this.#transport = transport; this.#nameServer = options.nameServer ?? { ip: '127.0.0.1', port: 53 }; } @@ -50,7 +51,7 @@ export class DnsClient { ], }; - const socket = await this.#stack.openUdp(); + const socket = await this.#transport.open(); // Serialize and send the message const data = serializeDnsMessage(message); @@ -63,7 +64,7 @@ export class DnsClient { }); // Wait for and parse the response - for await (const datagram of socket) { + for await (const datagram of fromReadable(socket.readable)) { const response = parseDnsMessage(datagram.data); // Verify this is the response to our query diff --git a/packages/dns/src/dns-server.ts b/packages/dns/src/dns-server.ts index 030598d..0a63fa1 100644 --- a/packages/dns/src/dns-server.ts +++ b/packages/dns/src/dns-server.ts @@ -1,4 +1,9 @@ -import type { NetworkStack, UdpDatagram, UdpSocket } from 'tcpip/types'; +import { fromReadable } from '@tcpip/transport'; +import type { + Datagram, + DatagramSocket, + DatagramTransport, +} from '@tcpip/transport'; import type { DnsMessage, DnsRecord, DnsResponse, DnsType } from './types.js'; import { parseDnsMessage, serializeDnsMessage } from './wire.js'; @@ -29,34 +34,34 @@ export type DnsServerOptions = { }; export class DnsServer { - #stack: NetworkStack; + #transport: DatagramTransport; #options: DnsServerOptions; - constructor(stack: NetworkStack, options: DnsServerOptions) { - this.#stack = stack; + constructor(transport: DatagramTransport, options: DnsServerOptions) { + this.#transport = transport; this.#options = options; } async listen() { - const socket = await this.#stack.openUdp({ + const socket = await this.#transport.open({ host: this.#options.host, port: this.#options.port ?? 53, }); this.#processDnsMessages(socket); } - async #processDnsMessages(socket: UdpSocket) { + async #processDnsMessages(socket: DatagramSocket) { const writer = socket.writable.getWriter(); - for await (const datagram of socket) { + for await (const datagram of fromReadable(socket.readable)) { // Process each message without blocking this.#processDnsMessage(datagram, writer); } } async #processDnsMessage( - datagram: UdpDatagram, - writer: WritableStreamDefaultWriter + datagram: Datagram, + writer: WritableStreamDefaultWriter ) { try { const { host, port } = datagram; diff --git a/packages/dns/src/index.test.ts b/packages/dns/src/index.test.ts index 18d26d2..764c808 100644 --- a/packages/dns/src/index.test.ts +++ b/packages/dns/src/index.test.ts @@ -5,7 +5,7 @@ import { createDns, ptrNameToIP } from './index.js'; describe('createDns', () => { test('client and server can communicate', async () => { const stack = await createStack(); - const { lookup, serve } = await createDns(stack); + const { lookup, serve } = await createDns(stack.udp); await serve({ request: async ({ name, type }) => { @@ -26,7 +26,7 @@ describe('createDns', () => { test('throws if no records found', async () => { const stack = await createStack(); - const { lookup, serve } = await createDns(stack); + const { lookup, serve } = await createDns(stack.udp); await serve({ request: async () => undefined, @@ -39,7 +39,7 @@ describe('createDns', () => { test('reverse A lookup', async () => { const stack = await createStack(); - const { reverse, serve } = await createDns(stack); + const { reverse, serve } = await createDns(stack.udp); await serve({ request: async ({ name, type }) => { @@ -62,7 +62,7 @@ describe('createDns', () => { test('reverse AAAA lookup', async () => { const stack = await createStack(); - const { reverse, serve } = await createDns(stack); + const { reverse, serve } = await createDns(stack.udp); await serve({ request: async ({ name, type }) => { diff --git a/packages/dns/src/index.ts b/packages/dns/src/index.ts index 111862b..edc82e3 100644 --- a/packages/dns/src/index.ts +++ b/packages/dns/src/index.ts @@ -1,4 +1,4 @@ -import type { NetworkStack } from 'tcpip/types'; +import type { DatagramTransport } from '@tcpip/transport'; import { DnsClient, type DnsClientOptions } from './dns-client.js'; import { DnsServer, type DnsServerOptions } from './dns-server.js'; @@ -16,24 +16,23 @@ export type CreateDnsOptions = { }; /** - * Creates DNS server and client functions on top of a - * `tcpip` network stack. + * Creates DNS server and client functions on top of a datagram transport. * * @example * const stack = await createStack(); - * const { serve, lookup } = await createDns(stack); + * const { serve, lookup } = await createDns(stack.udp); * const server = await serve({ ... }); * const ip = await lookup('example.com'); */ export async function createDns( - stack: NetworkStack, + transport: DatagramTransport, options: CreateDnsOptions = {} ) { - const client = new DnsClient(stack, options.client); + const client = new DnsClient(transport, options.client); return { serve: async (options: DnsServerOptions) => { - const server = new DnsServer(stack, options); + const server = new DnsServer(transport, options); await server.listen(); return server; }, diff --git a/packages/dns/tsconfig.json b/packages/dns/tsconfig.json index 26cedf0..a6e95fd 100644 --- a/packages/dns/tsconfig.json +++ b/packages/dns/tsconfig.json @@ -5,7 +5,7 @@ "lib": ["es2022", "dom", "dom.iterable", "esnext.disposable"], "paths": { "tcpip": ["../tcpip/src/index.js"], - "tcpip/types": ["../tcpip/src/types.js"], + "@tcpip/transport": ["../transport/src/index.js"], "@tcpip/wire": ["../wire/src/index.js"], "@tcpip/dns": ["./src/index.js"] } diff --git a/packages/http/README.md b/packages/http/README.md index f34db65..15b38fc 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -21,7 +21,7 @@ import { createStack } from 'tcpip'; import { createHttp } from '@tcpip/http'; const stack = await createStack(); -const { fetch, serve } = await createHttp(stack); +const { fetch, serve } = await createHttp(stack.tcp); await serve(async (request) => { return new Response('hello from tcpip.js'); @@ -36,7 +36,7 @@ Practically, `@tcpip/http` is most useful as a tool to communicate with VMs like ## Custom fetch for SDKs -Many SDKs accept a custom `fetch` option so callers can choose their own transport. `createHttp(stack)` returns a fetch-compatible function for exactly that use case: it accepts the standard `fetch(input, init)` arguments, sends the request over the tcpip.js virtual network, and returns a standard `Response`. +Many SDKs accept a custom `fetch` option so callers can choose their own transport. `createHttp(stack.tcp)` returns a fetch-compatible function for exactly that use case: it accepts the standard `fetch(input, init)` arguments, sends the request over the tcpip.js virtual network, and returns a standard `Response`. ```ts const sdk = new SomeSdk({ @@ -49,7 +49,7 @@ This means you can use existing HTTP-based SDKs to interact with services runnin ## API ```ts -const { fetch, serve } = await createHttp(stack); +const { fetch, serve } = await createHttp(stack.tcp); ``` `fetch(input, init)` is compatible with `typeof globalThis.fetch` for plain `http:` URLs. diff --git a/packages/http/package.json b/packages/http/package.json index 520e86f..424dbea 100644 --- a/packages/http/package.json +++ b/packages/http/package.json @@ -27,6 +27,9 @@ "default": "./dist/index.cjs" } }, + "dependencies": { + "@tcpip/transport": "workspace:*" + }, "devDependencies": { "@bjorn3/browser_wasi_shim": "^0.3.0", "@playwright/test": "catalog:", diff --git a/packages/http/src/fetch.test.ts b/packages/http/src/fetch.test.ts index 4edd1d5..18b73fc 100644 --- a/packages/http/src/fetch.test.ts +++ b/packages/http/src/fetch.test.ts @@ -1,5 +1,5 @@ +import type { StreamConnection, StreamTransport } from '@tcpip/transport'; import { createStack } from 'tcpip'; -import type { NetworkStack, TcpConnection } from 'tcpip/types'; import { describe, expect, test } from 'vitest'; import { createHttp } from './index.js'; @@ -8,7 +8,7 @@ async function nextValue(iterator: AsyncIterable) { return value!; } -async function readUntil(connection: TcpConnection, expected: string) { +async function readUntil(connection: StreamConnection, expected: string) { const decoder = new TextDecoder(); const reader = connection.readable.getReader(); let text = ''; @@ -29,9 +29,9 @@ async function readUntil(connection: TcpConnection, expected: string) { } function trackConnectionClose( - stack: NetworkStack, - connection: TcpConnection -): NetworkStack { + transport: StreamTransport, + connection: StreamConnection +): StreamTransport & { readonly closeCount: number } { let closeCount = 0; const trackedConnection = { ...connection, @@ -39,22 +39,21 @@ function trackConnectionClose( closeCount++; await connection.close(); }, - [Symbol.asyncIterator]: () => connection[Symbol.asyncIterator](), - } satisfies TcpConnection; + } satisfies StreamConnection; return { - ...stack, - connectTcp: async () => trackedConnection, + ...transport, + connect: async () => trackedConnection, get closeCount() { return closeCount; }, - } as NetworkStack & { readonly closeCount: number }; + }; } describe('fetch', () => { test('fetches from an HTTP server on loopback', async () => { const stack = await createStack(); - const listener = await stack.listenTcp({ + const listener = await stack.tcp.listen({ host: '127.0.0.1', port: 8081, }); @@ -74,7 +73,7 @@ describe('fetch', () => { return connection; })(); - const { fetch } = await createHttp(stack); + const { fetch } = await createHttp(stack.tcp); const response = await fetch('http://127.0.0.1:8081/test'); expect(response.status).toBe(200); @@ -87,7 +86,7 @@ describe('fetch', () => { test('sends content length for known init bodies', async () => { const stack = await createStack(); - const listener = await stack.listenTcp({ + const listener = await stack.tcp.listen({ host: '127.0.0.1', port: 8087, }); @@ -105,7 +104,7 @@ describe('fetch', () => { return { connection, request }; })(); - const { fetch } = await createHttp(stack); + const { fetch } = await createHttp(stack.tcp); const response = await fetch('http://127.0.0.1:8087/form', { method: 'POST', body: 'name=tcpip', @@ -125,22 +124,17 @@ describe('fetch', () => { test('closes the TCP connection after the response body is consumed', async () => { const stack = await createStack(); - const listener = await stack.listenTcp({ + const listener = await stack.tcp.listen({ host: '127.0.0.1', port: 8086, }); const serverConnectionPromise = nextValue(listener); - const rawConnection = await stack.connectTcp({ + const rawConnection = await stack.tcp.connect({ host: '127.0.0.1', port: 8086, }); - const trackedStack = trackConnectionClose( - stack, - rawConnection - ) as NetworkStack & { - readonly closeCount: number; - }; + const trackedStack = trackConnectionClose(stack.tcp, rawConnection); const serverDone = (async () => { const connection = await serverConnectionPromise; @@ -170,7 +164,7 @@ describe('fetch', () => { test('rejects https URLs until TLS exists', async () => { const stack = await createStack(); - const { fetch } = await createHttp(stack); + const { fetch } = await createHttp(stack.tcp); await expect(fetch('https://example.com/')).rejects.toThrow( 'unsupported protocol: https:' @@ -179,7 +173,7 @@ describe('fetch', () => { test('fetch can call serve on the same stack', async () => { const stack = await createStack(); - const { fetch, serve } = await createHttp(stack); + const { fetch, serve } = await createHttp(stack.tcp); await serve({ host: '127.0.0.1', port: 8084 }, async (request) => { return Response.json({ diff --git a/packages/http/src/fetch.ts b/packages/http/src/fetch.ts index fd1ea68..f25422f 100644 --- a/packages/http/src/fetch.ts +++ b/packages/http/src/fetch.ts @@ -1,4 +1,4 @@ -import type { NetworkStack } from 'tcpip/types'; +import type { StreamTransport } from '@tcpip/transport'; import { unsupportedProtocol } from './errors.js'; import { HttpParser } from './parser.js'; import { @@ -125,7 +125,7 @@ async function feedParser( } export function createFetch( - stack: NetworkStack, + transport: StreamTransport, parserRuntime: HttpParserRuntime ): HttpFetch { return async (input, init) => { @@ -140,7 +140,7 @@ export function createFetch( throw unsupportedProtocol(url.protocol); } - const connection = await stack.connectTcp({ + const connection = await transport.connect({ host: url.hostname, port: Number(url.port || 80), }); diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts index 363834e..cf18d21 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -1,4 +1,4 @@ -import type { NetworkStack } from 'tcpip/types'; +import type { StreamTransport } from '@tcpip/transport'; import { createFetch } from './fetch.js'; import { serveHttp } from './serve.js'; import type { @@ -58,7 +58,7 @@ function normalizeServeArgs( } export async function createHttp( - stack: NetworkStack, + transport: StreamTransport, options: CreateHttpOptions = {} ): Promise { const parser = options.parser ?? new LlhttpBindings(); @@ -72,11 +72,11 @@ export async function createHttp( first, second ); - return serveHttp(stack, parser, serveOptions, handler); + return serveHttp(transport, parser, serveOptions, handler); }) satisfies HttpApi['serve']; return { - fetch: createFetch(stack, parser), + fetch: createFetch(transport, parser), serve, }; } diff --git a/packages/http/src/serve.test.ts b/packages/http/src/serve.test.ts index 58c57a5..0de42e5 100644 --- a/packages/http/src/serve.test.ts +++ b/packages/http/src/serve.test.ts @@ -159,14 +159,14 @@ function contentLengthFromHead(head: string) { describe('serve', () => { test('handler-only overload listens on the default HTTP port', async () => { const stack = await createStack(); - const { serve } = await createHttp(stack); + const { serve } = await createHttp(stack.tcp); await serve(async (request) => { expect(new URL(request.url).pathname).toBe('/default'); return new Response('ok'); }); - const connection = await stack.connectTcp({ + const connection = await stack.tcp.connect({ host: '127.0.0.1', port: 80, }); @@ -188,7 +188,7 @@ describe('serve', () => { test('responds to a raw TCP HTTP request', async () => { const stack = await createStack(); - const { serve } = await createHttp(stack); + const { serve } = await createHttp(stack.tcp); await serve({ host: '127.0.0.1', port: 8082 }, async (request) => { expect(request.method).toBe('GET'); @@ -200,7 +200,7 @@ describe('serve', () => { }); }); - const connection = await stack.connectTcp({ + const connection = await stack.tcp.connect({ host: '127.0.0.1', port: 8082, }); @@ -222,13 +222,13 @@ describe('serve', () => { test('closes the TCP readable after sending a response', async () => { const stack = await createStack(); - const { serve } = await createHttp(stack); + const { serve } = await createHttp(stack.tcp); await serve({ host: '127.0.0.1', port: 8086 }, async () => { return new Response('closed'); }); - const connection = await stack.connectTcp({ + const connection = await stack.tcp.connect({ host: '127.0.0.1', port: 8086, }); @@ -248,7 +248,7 @@ describe('serve', () => { test('options-object overload accepts an inline handler', async () => { const stack = await createStack(); - const { serve } = await createHttp(stack); + const { serve } = await createHttp(stack.tcp); await serve({ host: '127.0.0.1', @@ -256,7 +256,7 @@ describe('serve', () => { handler: async () => new Response('inline'), }); - const connection = await stack.connectTcp({ + const connection = await stack.tcp.connect({ host: '127.0.0.1', port: 8085, }); @@ -278,13 +278,13 @@ describe('serve', () => { test('streams request body to handler', async () => { const stack = await createStack(); - const { serve } = await createHttp(stack); + const { serve } = await createHttp(stack.tcp); await serve({ host: '127.0.0.1', port: 8083 }, async (request) => { return new Response(await request.text()); }); - const connection = await stack.connectTcp({ + const connection = await stack.tcp.connect({ host: '127.0.0.1', port: 8083, }); diff --git a/packages/http/src/serve.ts b/packages/http/src/serve.ts index 1d26263..88b78b8 100644 --- a/packages/http/src/serve.ts +++ b/packages/http/src/serve.ts @@ -1,4 +1,4 @@ -import type { NetworkStack, TcpConnection } from 'tcpip/types'; +import type { StreamConnection, StreamTransport } from '@tcpip/transport'; import { HttpParser } from './parser.js'; import { serializeHttpResponse } from './serialize.js'; import type { @@ -30,7 +30,7 @@ function detectStreamingRequestBodies() { } async function handleConnection( - connection: TcpConnection, + connection: StreamConnection, parserRuntime: HttpParserRuntime, handler: HttpRequestHandler ) { @@ -107,12 +107,16 @@ async function handleConnection( } export async function serveHttp( - stack: NetworkStack, + transport: StreamTransport, parserRuntime: HttpParserRuntime, options: ServeOptions, handler: HttpRequestHandler ): Promise { - const listener = await stack.listenTcp(options); + if (!transport.listen) { + throw new TypeError('http serve requires a listen-capable transport'); + } + + const listener = await transport.listen(options); let closed = false; const loop = (async () => { diff --git a/packages/http/src/types.ts b/packages/http/src/types.ts index efad0a0..ab581e6 100644 --- a/packages/http/src/types.ts +++ b/packages/http/src/types.ts @@ -1,4 +1,4 @@ -import type { NetworkStack } from 'tcpip/types'; +import type { StreamTransport } from '@tcpip/transport'; export type HttpFetch = typeof globalThis.fetch; @@ -42,7 +42,7 @@ export type HttpApi = { }; export type CreateHttp = ( - stack: NetworkStack, + transport: StreamTransport, options?: CreateHttpOptions ) => Promise; diff --git a/packages/http/tsconfig.json b/packages/http/tsconfig.json index 4f341b3..0419b0d 100644 --- a/packages/http/tsconfig.json +++ b/packages/http/tsconfig.json @@ -5,8 +5,8 @@ "lib": ["es2022", "dom", "dom.iterable", "esnext.disposable"], "paths": { "tcpip": ["../tcpip/src/index.js"], - "tcpip/types": ["../tcpip/src/types.js"], "@tcpip/dns": ["../dns/src/index.js"], + "@tcpip/transport": ["../transport/src/index.js"], "@tcpip/wire": ["../wire/src/index.js"], "@tcpip/http": ["./src/index.js"] } diff --git a/packages/tcpip/package.json b/packages/tcpip/package.json index 6d7db52..597893b 100644 --- a/packages/tcpip/package.json +++ b/packages/tcpip/package.json @@ -20,6 +20,7 @@ "test": "vitest", "test:node": "vitest --project node", "test:browser": "vitest --project browser", + "test:types": "vitest --project types", "clean": "rm -rf dist", "make": "CC='docker compose run --rm wasi-sdk /opt/wasi-sdk/bin/clang' AR='docker compose run --rm wasi-sdk /opt/wasi-sdk/bin/ar' make", "wasm-objdump": "docker compose run --rm wabt wasm-objdump", @@ -41,6 +42,7 @@ "dependencies": { "@bjorn3/browser_wasi_shim": "^0.3.0", "@tcpip/dns": "workspace:*", + "@tcpip/transport": "workspace:*", "@tcpip/wire": "workspace:*" }, "devDependencies": { diff --git a/packages/tcpip/src/bindings/tap-interface.ts b/packages/tcpip/src/bindings/tap-interface.ts index d3b946e..59e4813 100644 --- a/packages/tcpip/src/bindings/tap-interface.ts +++ b/packages/tcpip/src/bindings/tap-interface.ts @@ -1,3 +1,4 @@ +import { fromReadable } from '@tcpip/transport'; import { type IPv4Address, type MacAddress, @@ -9,12 +10,7 @@ import { } from '@tcpip/wire'; import { LwipError } from '../lwip/errors.js'; import type { TapInterface, TapInterfaceOptions } from '../types.js'; -import { - ExtendedReadableStream, - Hooks, - fromReadable, - nextMicrotask, -} from '../util.js'; +import { ExtendedReadableStream, Hooks, nextMicrotask } from '../util.js'; import { Bindings } from './base.js'; import type { Pointer } from './types.js'; diff --git a/packages/tcpip/src/bindings/tcp.ts b/packages/tcpip/src/bindings/tcp.ts index 9c7f5f4..6095c6a 100644 --- a/packages/tcpip/src/bindings/tcp.ts +++ b/packages/tcpip/src/bindings/tcp.ts @@ -1,4 +1,5 @@ import type { DnsClient } from '@tcpip/dns'; +import { fromReadable } from '@tcpip/transport'; import { serializeIPv4Address } from '@tcpip/wire'; import { LwipError } from '../lwip/errors.js'; import type { @@ -7,7 +8,7 @@ import type { TcpListener, TcpListenerOptions, } from '../types.js'; -import { EventMap, Hooks, fromReadable, nextMicrotask } from '../util.js'; +import { EventMap, Hooks, nextMicrotask } from '../util.js'; import { Bindings } from './base.js'; import type { Pointer } from './types.js'; diff --git a/packages/tcpip/src/bindings/tun-interface.ts b/packages/tcpip/src/bindings/tun-interface.ts index 7dcad56..52e8a78 100644 --- a/packages/tcpip/src/bindings/tun-interface.ts +++ b/packages/tcpip/src/bindings/tun-interface.ts @@ -1,15 +1,11 @@ +import { fromReadable } from '@tcpip/transport'; import { type IPv4Address, parseIPv4Address, serializeIPv4Cidr, } from '@tcpip/wire'; import type { TunInterface, TunInterfaceOptions } from '../types.js'; -import { - ExtendedReadableStream, - Hooks, - fromReadable, - nextMicrotask, -} from '../util.js'; +import { ExtendedReadableStream, Hooks, nextMicrotask } from '../util.js'; import { Bindings } from './base.js'; import type { Pointer } from './types.js'; diff --git a/packages/tcpip/src/bindings/udp.ts b/packages/tcpip/src/bindings/udp.ts index b776ec3..2fb1db9 100644 --- a/packages/tcpip/src/bindings/udp.ts +++ b/packages/tcpip/src/bindings/udp.ts @@ -1,8 +1,9 @@ import type { DnsClient } from '@tcpip/dns'; +import { fromReadable } from '@tcpip/transport'; import { parseIPv4Address, serializeIPv4Address } from '@tcpip/wire'; import { LwipError } from '../lwip/errors.js'; import type { UdpDatagram, UdpSocket, UdpSocketOptions } from '../types.js'; -import { EventMap, Hooks, fromReadable, nextMicrotask } from '../util.js'; +import { EventMap, Hooks, nextMicrotask } from '../util.js'; import { Bindings } from './base.js'; import type { Pointer } from './types.js'; diff --git a/packages/tcpip/src/index.ts b/packages/tcpip/src/index.ts index 446fc85..e1ea446 100644 --- a/packages/tcpip/src/index.ts +++ b/packages/tcpip/src/index.ts @@ -2,17 +2,19 @@ export { createStack } from './stack.js'; export type { BridgeInterface, BridgeInterfaceOptions, - DuplexStream, LoopbackInterface, LoopbackInterfaceOptions, NetworkInterface, + NetworkInterfaces, NetworkStack, PingProbeOptions, + PingApi, PingReply, PingSession, PingSessionOptions, TapInterface, TapInterfaceOptions, + TcpTransport, TcpConnection, TcpConnectionOptions, TcpListener, @@ -22,5 +24,5 @@ export type { UdpDatagram, UdpSocket, UdpSocketOptions, + UdpTransport, } from './types.js'; -export { connectStreams } from './util.js'; diff --git a/packages/tcpip/src/stack.test.ts b/packages/tcpip/src/stack.test.ts index fad0103..330bfb9 100644 --- a/packages/tcpip/src/stack.test.ts +++ b/packages/tcpip/src/stack.test.ts @@ -26,12 +26,41 @@ describe('general', () => { expect(Array.from(stack.interfaces)).toStrictEqual([]); }); - test('interface instances are available in interfaces property', async () => { + test('keeps flat transport and interface aliases for compatibility', async () => { const stack = await createStack({ initializeLoopback: false }); const loopbackInterface = await stack.createLoopbackInterface({ ip: '127.0.0.1/8', }); + const tunInterface = await stack.createTunInterface({ + ip: '192.168.1.1/24', + }); + const tapInterface = await stack.createTapInterface(); + const bridgeInterface = await stack.createBridgeInterface({ + ports: [tapInterface], + }); + + expect(Array.from(stack.interfaces)).toStrictEqual([ + loopbackInterface, + tunInterface, + tapInterface, + bridgeInterface, + ]); + + await stack.removeInterface(bridgeInterface); + await stack.removeInterface(tapInterface); + await stack.removeInterface(tunInterface); + await stack.removeInterface(loopbackInterface); + + expect(Array.from(stack.interfaces)).toStrictEqual([]); + }); + + test('interface instances are available in interfaces property', async () => { + const stack = await createStack({ initializeLoopback: false }); + + const loopbackInterface = await stack.interfaces.createLoopback({ + ip: '127.0.0.1/8', + }); const firstInterface = await nextValue(stack.interfaces); expect(firstInterface).toBe(loopbackInterface); @@ -40,15 +69,15 @@ describe('general', () => { test('add and remove interfaces', async () => { const stack = await createStack({ initializeLoopback: false }); - const loopbackInterface = await stack.createLoopbackInterface({ + const loopbackInterface = await stack.interfaces.createLoopback({ ip: '127.0.0.1/8', }); - const tunInterface = await stack.createTunInterface({ + const tunInterface = await stack.interfaces.createTun({ ip: '192.168.1.1/24', }); - const tapInterface = await stack.createTapInterface({ + const tapInterface = await stack.interfaces.createTap({ mac: '00:1a:2b:3c:4d:5e', ip: '192.168.2.1/24', }); @@ -59,18 +88,18 @@ describe('general', () => { tapInterface, ]); - await stack.removeInterface(loopbackInterface); + await stack.interfaces.remove(loopbackInterface); expect(Array.from(stack.interfaces)).toStrictEqual([ tunInterface, tapInterface, ]); - await stack.removeInterface(tunInterface); + await stack.interfaces.remove(tunInterface); expect(Array.from(stack.interfaces)).toStrictEqual([tapInterface]); - await stack.removeInterface(tapInterface); + await stack.interfaces.remove(tapInterface); expect(Array.from(stack.interfaces)).toStrictEqual([]); }); @@ -80,7 +109,7 @@ describe('loopback interface', () => { test('should create a LoopbackInterface with the given options', async () => { const stack = await createStack({ initializeLoopback: false }); - const loopbackInterface = await stack.createLoopbackInterface({ + const loopbackInterface = await stack.interfaces.createLoopback({ ip: '127.0.0.1/8', }); @@ -90,7 +119,7 @@ describe('loopback interface', () => { test('can get ip and netmask', async () => { const stack = await createStack({ initializeLoopback: false }); - const loopbackInterface = await stack.createLoopbackInterface({ + const loopbackInterface = await stack.interfaces.createLoopback({ ip: '127.0.0.1/8', }); @@ -103,7 +132,7 @@ describe('tun interface', () => { test('should create a TunInterface with the given options', async () => { const stack = await createStack(); - const tunInterface = await stack.createTunInterface({ + const tunInterface = await stack.interfaces.createTun({ ip: '192.168.1.1/24', }); @@ -113,7 +142,7 @@ describe('tun interface', () => { test('can send and receive packets', async () => { const stack = await createStack(); - const tunInterface = await stack.createTunInterface({ + const tunInterface = await stack.interfaces.createTun({ ip: '192.168.1.1/24', }); @@ -171,7 +200,7 @@ describe('tun interface', () => { test('can get ip and netmask', async () => { const stack = await createStack(); - const tunInterface = await stack.createTunInterface({ + const tunInterface = await stack.interfaces.createTun({ ip: '192.168.1.1/24', }); @@ -184,7 +213,7 @@ describe('tap interface', () => { test('should create a TapInterface with the given options', async () => { const stack = await createStack(); - const tapInterface = await stack.createTapInterface({ + const tapInterface = await stack.interfaces.createTap({ mac: '00:1a:2b:3c:4d:5e', ip: '192.168.1.1/24', }); @@ -195,7 +224,7 @@ describe('tap interface', () => { test('can send and receive frames', async () => { const stack = await createStack(); - const tapInterface = await stack.createTapInterface({ + const tapInterface = await stack.interfaces.createTap({ mac: '00:1a:2b:3c:4d:5e', ip: '192.168.1.1/24', }); @@ -249,7 +278,7 @@ describe('tap interface', () => { test('can get mac, ip, and netmask', async () => { const stack = await createStack(); - const tapInterface = await stack.createTapInterface({ + const tapInterface = await stack.interfaces.createTap({ mac: '00:1a:2b:3c:4d:5e', ip: '192.168.1.1/24', }); @@ -264,17 +293,17 @@ describe('bridge interface', () => { test('should create a BridgeInterface with the given options', async () => { const stack = await createStack(); - const port1 = await stack.createTapInterface({ + const port1 = await stack.interfaces.createTap({ mac: '02:00:00:00:00:01', ip: '192.168.1.2/24', }); - const port2 = await stack.createTapInterface({ + const port2 = await stack.interfaces.createTap({ mac: '02:00:00:00:00:02', ip: '192.168.1.3/24', }); - const bridgeInterface = await stack.createBridgeInterface({ + const bridgeInterface = await stack.interfaces.createBridge({ ports: [port1, port2], mac: '02:00:00:00:00:00', ip: '192.168.1.1/24', @@ -289,19 +318,19 @@ describe('bridge interface', () => { const device2 = await createStack(); const router = await createStack(); - const device1Tap = await device1.createTapInterface({ + const device1Tap = await device1.interfaces.createTap({ ip: '192.168.1.2/24', }); - const device2Tap = await device2.createTapInterface({ + const device2Tap = await device2.interfaces.createTap({ ip: '192.168.1.3/24', }); - const port1 = await router.createTapInterface(); - const port2 = await router.createTapInterface(); + const port1 = await router.interfaces.createTap(); + const port2 = await router.interfaces.createTap(); // Bridge the two router ports - await router.createBridgeInterface({ + await router.interfaces.createBridge({ ports: [port1, port2], ip: '192.168.1.1/24', }); @@ -315,12 +344,12 @@ describe('bridge interface', () => { port2.readable.pipeTo(device2Tap.writable); // Listen on device 2 - const listener = await device2.listenTcp({ + const listener = await device2.tcp.listen({ port: 8080, }); // Attempt to connect from device 1 to device 2 via bridge - const connection = await device1.connectTcp({ + const connection = await device1.tcp.connect({ host: '192.168.1.3', port: 8080, }); @@ -348,14 +377,14 @@ describe('bridge interface', () => { const device = await createStack(); const router = await createStack(); - const deviceTap = await device.createTapInterface({ + const deviceTap = await device.interfaces.createTap({ ip: '192.168.1.2/24', }); - const port = await router.createTapInterface(); + const port = await router.interfaces.createTap(); // Create bridge - await router.createBridgeInterface({ + await router.interfaces.createBridge({ ports: [port], ip: '192.168.1.1/24', }); @@ -365,12 +394,12 @@ describe('bridge interface', () => { port.readable.pipeTo(deviceTap.writable); // Listen on router - const listener = await router.listenTcp({ + const listener = await router.tcp.listen({ port: 8080, }); // Attempt to connect from device to bridge via port - const connection = await device.connectTcp({ + const connection = await device.tcp.connect({ host: '192.168.1.1', port: 8080, }); @@ -397,14 +426,14 @@ describe('bridge interface', () => { const device = await createStack(); const router = await createStack(); - const deviceTap = await device.createTapInterface({ + const deviceTap = await device.interfaces.createTap({ ip: '192.168.1.2/24', }); - const port = await router.createTapInterface(); + const port = await router.interfaces.createTap(); // Create bridge and connect device - await router.createBridgeInterface({ + await router.interfaces.createBridge({ ports: [port], ip: '192.168.1.1/24', }); @@ -414,12 +443,12 @@ describe('bridge interface', () => { port.readable.pipeTo(deviceTap.writable); // Listen on device - const listener = await device.listenTcp({ + const listener = await device.tcp.listen({ port: 8080, }); // Connect from router bridge to device - const connection = await router.connectTcp({ + const connection = await router.tcp.connect({ host: '192.168.1.2', port: 8080, }); @@ -446,14 +475,14 @@ describe('bridge interface', () => { const device = await createStack(); const router = await createStack(); - const deviceTap = await device.createTapInterface({ + const deviceTap = await device.interfaces.createTap({ ip: '192.168.1.2/24', }); - const port = await router.createTapInterface(); + const port = await router.interfaces.createTap(); // Create bridge - await router.createBridgeInterface({ + await router.interfaces.createBridge({ ports: [port], ip: '192.168.1.1/24', }); @@ -463,8 +492,8 @@ describe('bridge interface', () => { port.readable.pipeTo(deviceTap.writable); // Open UDP sockets - const deviceSocket = await device.openUdp({ port: 8080 }); - const routerSocket = await router.openUdp({ port: 8080 }); + const deviceSocket = await device.udp.open({ port: 8080 }); + const routerSocket = await router.udp.open({ port: 8080 }); // Test device to router { @@ -507,14 +536,14 @@ describe('bridge interface', () => { const device = await createStack(); const router = await createStack(); - const deviceTap = await device.createTapInterface({ + const deviceTap = await device.interfaces.createTap({ ip: '192.168.1.2/24', }); - const port = await router.createTapInterface(); + const port = await router.interfaces.createTap(); // Create bridge - await router.createBridgeInterface({ + await router.interfaces.createBridge({ ports: [port], ip: '192.168.1.1/24', }); @@ -524,8 +553,8 @@ describe('bridge interface', () => { port.readable.pipeTo(deviceTap.writable); // Open UDP sockets - const deviceSocket = await device.openUdp({ port: 8080 }); - const routerSocket = await router.openUdp({ port: 8081 }); + const deviceSocket = await device.udp.open({ port: 8080 }); + const routerSocket = await router.udp.open({ port: 8081 }); // Test router to device broadcast { @@ -575,7 +604,7 @@ describe('bridge interface', () => { test('can get mac, ip, and netmask', async () => { const stack = await createStack(); - const bridgeInterface = await stack.createBridgeInterface({ + const bridgeInterface = await stack.interfaces.createBridge({ ports: [], mac: '00:1a:2b:3c:4d:5e', ip: '192.168.1.1/24', @@ -654,13 +683,13 @@ describe('tcp', () => { test('can create a TCP server and client', async () => { const stack = await createStack(); - const listener = await stack.listenTcp({ + const listener = await stack.tcp.listen({ host: '127.0.0.1', port: 8080, }); const [outbound, inbound] = await Promise.all([ - stack.connectTcp({ + stack.tcp.connect({ host: '127.0.0.1', port: 8080, }), @@ -681,13 +710,13 @@ describe('tcp', () => { test('can close a TCP connection when reader/writer are unlocked', async () => { const stack = await createStack(); - const listener = await stack.listenTcp({ + const listener = await stack.tcp.listen({ host: '127.0.0.1', port: 8080, }); const [outbound, inbound] = await Promise.all([ - stack.connectTcp({ + stack.tcp.connect({ host: '127.0.0.1', port: 8080, }), @@ -701,13 +730,13 @@ describe('tcp', () => { test('delivers queued TCP data before peer close is observed', async () => { const stack = await createStack(); - const listener = await stack.listenTcp({ + const listener = await stack.tcp.listen({ host: '127.0.0.1', port: 8080, }); const [outbound, inbound] = await Promise.all([ - stack.connectTcp({ + stack.tcp.connect({ host: '127.0.0.1', port: 8080, }), @@ -732,13 +761,13 @@ describe('tcp', () => { test('closing a TCP writable delivers queued data before peer EOF', async () => { const stack = await createStack(); - const listener = await stack.listenTcp({ + const listener = await stack.tcp.listen({ host: '127.0.0.1', port: 8080, }); const [outbound, inbound] = await Promise.all([ - stack.connectTcp({ + stack.tcp.connect({ host: '127.0.0.1', port: 8080, }), @@ -762,13 +791,13 @@ describe('tcp', () => { test('can pipe a readable stream to a TCP writable', async () => { const stack = await createStack(); - const listener = await stack.listenTcp({ + const listener = await stack.tcp.listen({ host: '127.0.0.1', port: 8080, }); const [outbound, inbound] = await Promise.all([ - stack.connectTcp({ + stack.tcp.connect({ host: '127.0.0.1', port: 8080, }), @@ -795,13 +824,13 @@ describe('tcp', () => { test('closing a TCP writable after multiple writes delivers queued data before peer EOF', async () => { const stack = await createStack(); - const listener = await stack.listenTcp({ + const listener = await stack.tcp.listen({ host: '127.0.0.1', port: 8080, }); const [outbound, inbound] = await Promise.all([ - stack.connectTcp({ + stack.tcp.connect({ host: '127.0.0.1', port: 8080, }), @@ -831,13 +860,13 @@ describe('tcp', () => { test('TCP peer EOF is observable after releasing a reader that consumed queued data', async () => { const stack = await createStack(); - const listener = await stack.listenTcp({ + const listener = await stack.tcp.listen({ host: '127.0.0.1', port: 8080, }); const [outbound, inbound] = await Promise.all([ - stack.connectTcp({ + stack.tcp.connect({ host: '127.0.0.1', port: 8080, }), @@ -864,13 +893,13 @@ describe('tcp', () => { test('server can read a request then gracefully close its response stream', async () => { const stack = await createStack(); - const listener = await stack.listenTcp({ + const listener = await stack.tcp.listen({ host: '127.0.0.1', port: 8080, }); const [client, server] = await Promise.all([ - stack.connectTcp({ + stack.tcp.connect({ host: '127.0.0.1', port: 8080, }), @@ -904,13 +933,13 @@ describe('tcp', () => { test('server can gracefully close its response stream while a request read is pending', async () => { const stack = await createStack(); - const listener = await stack.listenTcp({ + const listener = await stack.tcp.listen({ host: '127.0.0.1', port: 8080, }); const [client, server] = await Promise.all([ - stack.connectTcp({ + stack.tcp.connect({ host: '127.0.0.1', port: 8080, }), @@ -944,13 +973,13 @@ describe('tcp', () => { test('server can gracefully close after a multi-chunk response while a request read is pending', async () => { const stack = await createStack(); - const listener = await stack.listenTcp({ + const listener = await stack.tcp.listen({ host: '127.0.0.1', port: 8080, }); const [client, server] = await Promise.all([ - stack.connectTcp({ + stack.tcp.connect({ host: '127.0.0.1', port: 8080, }), @@ -990,13 +1019,13 @@ describe('tcp', () => { test('can close a TCP connection when reader/writer are locked', async () => { const stack = await createStack(); - const listener = await stack.listenTcp({ + const listener = await stack.tcp.listen({ host: '127.0.0.1', port: 8080, }); const [outbound, inbound] = await Promise.all([ - stack.connectTcp({ + stack.tcp.connect({ host: '127.0.0.1', port: 8080, }), @@ -1021,13 +1050,13 @@ describe('tcp', () => { test('can close a TCP reader and writer', async () => { const stack = await createStack(); - const listener = await stack.listenTcp({ + const listener = await stack.tcp.listen({ host: '127.0.0.1', port: 8080, }); const [outbound, inbound] = await Promise.all([ - stack.connectTcp({ + stack.tcp.connect({ host: '127.0.0.1', port: 8080, }), @@ -1044,13 +1073,13 @@ describe('tcp', () => { test('throws when iterating over a locked readable stream', async () => { const stack = await createStack(); - const listener = await stack.listenTcp({ + const listener = await stack.tcp.listen({ host: '127.0.0.1', port: 8080, }); const [_, inbound] = await Promise.all([ - stack.connectTcp({ + stack.tcp.connect({ host: '127.0.0.1', port: 8080, }), @@ -1068,13 +1097,13 @@ describe('tcp', () => { test('tcp backpressure client to server', async () => { const stack = await createStack(); - const listener = await stack.listenTcp({ + const listener = await stack.tcp.listen({ host: '127.0.0.1', port: 8080, }); const [outbound, inbound] = await Promise.all([ - stack.connectTcp({ + stack.tcp.connect({ host: '127.0.0.1', port: 8080, }), @@ -1111,13 +1140,13 @@ describe('tcp', () => { test('tcp backpressure server to client', async () => { const stack = await createStack(); - const listener = await stack.listenTcp({ + const listener = await stack.tcp.listen({ host: '127.0.0.1', port: 8080, }); const [outbound, inbound] = await Promise.all([ - stack.connectTcp({ + stack.tcp.connect({ host: '127.0.0.1', port: 8080, }), @@ -1155,11 +1184,11 @@ describe('tcp', () => { const stack1 = await createStack(); const stack2 = await createStack(); - const tun1 = await stack1.createTunInterface({ + const tun1 = await stack1.interfaces.createTun({ ip: '192.168.1.1/24', }); - const tun2 = await stack2.createTunInterface({ + const tun2 = await stack2.interfaces.createTun({ ip: '192.168.1.2/24', }); @@ -1167,12 +1196,12 @@ describe('tcp', () => { tun1.readable.pipeTo(tun2.writable); tun2.readable.pipeTo(tun1.writable); - const listener = await stack2.listenTcp({ + const listener = await stack2.tcp.listen({ port: 8080, }); const [outbound, inbound] = await Promise.all([ - stack1.connectTcp({ + stack1.tcp.connect({ host: '192.168.1.2', port: 8080, }), @@ -1194,12 +1223,12 @@ describe('tcp', () => { const stack1 = await createStack(); const stack2 = await createStack(); - const tap1 = await stack1.createTapInterface({ + const tap1 = await stack1.interfaces.createTap({ mac: '00:1a:2b:3c:4d:5e', ip: '192.168.1.1/24', }); - const tap2 = await stack2.createTapInterface({ + const tap2 = await stack2.interfaces.createTap({ mac: '00:1a:2b:3c:4d:5f', ip: '192.168.1.2/24', }); @@ -1208,12 +1237,12 @@ describe('tcp', () => { tap1.readable.pipeTo(tap2.writable); tap2.readable.pipeTo(tap1.writable); - const listener = await stack2.listenTcp({ + const listener = await stack2.tcp.listen({ port: 8080, }); const [outbound, inbound] = await Promise.all([ - stack1.connectTcp({ + stack1.tcp.connect({ host: '192.168.1.2', port: 8080, }), @@ -1235,11 +1264,11 @@ describe('tcp', () => { const stack1 = await createStack(); const stack2 = await createStack(); - const tun1 = await stack1.createTunInterface({ + const tun1 = await stack1.interfaces.createTun({ ip: '192.168.1.1/24', }); - const tun2 = await stack2.createTunInterface({ + const tun2 = await stack2.interfaces.createTun({ ip: '192.168.1.2/24', }); @@ -1247,12 +1276,12 @@ describe('tcp', () => { tun1.readable.pipeTo(tun2.writable); tun2.readable.pipeTo(tun1.writable); - const listener = await stack2.listenTcp({ + const listener = await stack2.tcp.listen({ port: 8080, }); const [outbound, inbound] = await Promise.all([ - stack1.connectTcp({ + stack1.tcp.connect({ host: '192.168.1.2', port: 8080, }), @@ -1287,8 +1316,8 @@ describe('udp', () => { test('can send and receive a UDP datagram', async () => { const stack = await createStack(); - const socket1 = await stack.openUdp({ port: 8080 }); - const socket2 = await stack.openUdp({ port: 8081 }); + const socket1 = await stack.udp.open({ port: 8080 }); + const socket2 = await stack.udp.open({ port: 8081 }); const reader = socket1.readable.getReader(); const writer = socket2.writable.getWriter(); @@ -1310,11 +1339,11 @@ describe('udp', () => { test('can receive udp datagram via tun interface', async () => { const stack = await createStack(); - const tunInterface = await stack.createTunInterface({ + const tunInterface = await stack.interfaces.createTun({ ip: '10.0.0.1/24', }); - const socket = await stack.openUdp({ port: 8080 }); + const socket = await stack.udp.open({ port: 8080 }); const writer = tunInterface.writable.getWriter(); const reader = socket.readable.getReader(); @@ -1352,11 +1381,11 @@ describe('udp', () => { test('can send udp datagram via tun interface', async () => { const stack = await createStack(); - const tunInterface = await stack.createTunInterface({ + const tunInterface = await stack.interfaces.createTun({ ip: '10.0.0.1/24', }); - const socket = await stack.openUdp({ port: 8080 }); + const socket = await stack.udp.open({ port: 8080 }); const reader = tunInterface.readable.getReader(); const writer = socket.writable.getWriter(); @@ -1390,11 +1419,11 @@ describe('udp', () => { test('can receive broadcast udp datagram', async () => { const stack = await createStack(); - const tunInterface = await stack.createTunInterface({ + const tunInterface = await stack.interfaces.createTun({ ip: '10.0.0.1/24', }); - const socket = await stack.openUdp({ port: 8080 }); + const socket = await stack.udp.open({ port: 8080 }); const reader = socket.readable.getReader(); const writer = tunInterface.writable.getWriter(); @@ -1432,12 +1461,12 @@ describe('udp', () => { test('can send broadcast udp datagram', async () => { const stack = await createStack(); - const tapInterface = await stack.createTapInterface({ + const tapInterface = await stack.interfaces.createTap({ ip: '10.0.0.1/24', mac: '00:1a:2b:3c:4d:5e', }); - const socket = await stack.openUdp({ port: 8080 }); + const socket = await stack.udp.open({ port: 8080 }); const listener = tapInterface.listen(); const writer = socket.writable.getWriter(); @@ -1477,12 +1506,12 @@ describe('udp', () => { const stack = await createStack(); // Create two interfaces - const tap1 = await stack.createTapInterface({ + const tap1 = await stack.interfaces.createTap({ ip: '192.168.1.1/24', mac: '00:1a:2b:3c:4d:01', }); - const tap2 = await stack.createTapInterface({ + const tap2 = await stack.interfaces.createTap({ ip: '192.168.2.1/24', mac: '00:1a:2b:3c:4d:02', }); @@ -1492,7 +1521,7 @@ describe('udp', () => { const tap2Listener = tap2.listen(); // Create a socket for broadcasting - const socket = await stack.openUdp({ port: 8080 }); + const socket = await stack.udp.open({ port: 8080 }); const writer = socket.writable.getWriter(); // Send broadcast @@ -1549,7 +1578,7 @@ describe('udp', () => { describe('dns', () => { test('can resolve a hostname during udp bind and send', async () => { const stack = await createStack(); - const { serve } = await createDns(stack); + const { serve } = await createDns(stack.udp); await serve({ request: async ({ name, type }) => { @@ -1563,8 +1592,8 @@ describe('dns', () => { }, }); - const socket1 = await stack.openUdp({ host: 'example.com', port: 8080 }); - const socket2 = await stack.openUdp({ port: 8081 }); + const socket1 = await stack.udp.open({ host: 'example.com', port: 8080 }); + const socket2 = await stack.udp.open({ port: 8081 }); const reader = socket1.readable.getReader(); const writer = socket2.writable.getWriter(); @@ -1585,7 +1614,7 @@ describe('dns', () => { test('can resolve a hostname during tcp bind and connection', async () => { const stack = await createStack(); - const { serve } = await createDns(stack); + const { serve } = await createDns(stack.udp); await serve({ request: async ({ name, type }) => { @@ -1599,13 +1628,13 @@ describe('dns', () => { }, }); - const listener = await stack.listenTcp({ + const listener = await stack.tcp.listen({ host: 'example.com', port: 8080, }); const [outbound, inbound] = await Promise.all([ - stack.connectTcp({ + stack.tcp.connect({ host: 'example.com', port: 8080, }), @@ -1625,7 +1654,7 @@ describe('dns', () => { test('can resolve a hostname during ping session creation', async () => { const stack = await createStack(); - const { serve } = await createDns(stack); + const { serve } = await createDns(stack.udp); await serve({ request: async ({ name, type }) => { @@ -1640,7 +1669,7 @@ describe('dns', () => { }); const payload = new TextEncoder().encode('dns ping'); - const pingSession = await stack.createPingSession({ + const pingSession = await stack.ping.createSession({ host: 'example.com', }); @@ -1654,11 +1683,11 @@ describe('dns', () => { }); }); -describe('icmp', () => { +describe('ping', () => { test('ping session can ping loopback interface', async () => { const stack = await createStack(); const payload = new TextEncoder().encode('loopback ping'); - const pingSession = await stack.createPingSession({ + const pingSession = await stack.ping.createSession({ host: '127.0.0.1', }); @@ -1675,7 +1704,7 @@ describe('icmp', () => { test('ping session uses default payload', async () => { const stack = await createStack(); - const pingSession = await stack.createPingSession({ + const pingSession = await stack.ping.createSession({ host: '127.0.0.1', }); @@ -1690,7 +1719,7 @@ describe('icmp', () => { test('ping session rejects after close', async () => { const stack = await createStack(); - const pingSession = await stack.createPingSession({ + const pingSession = await stack.ping.createSession({ host: '127.0.0.1', }); @@ -1705,11 +1734,11 @@ describe('icmp', () => { const stack1 = await createStack(); const stack2 = await createStack(); - const tun1 = await stack1.createTunInterface({ + const tun1 = await stack1.interfaces.createTun({ ip: '192.168.1.1/24', }); - const tun2 = await stack2.createTunInterface({ + const tun2 = await stack2.interfaces.createTun({ ip: '192.168.1.2/24', }); @@ -1717,7 +1746,7 @@ describe('icmp', () => { tun2.readable.pipeTo(tun1.writable); const payload = new TextEncoder().encode('tcpip.js ping'); - const pingSession = await stack1.createPingSession({ + const pingSession = await stack1.ping.createSession({ host: '192.168.1.2', }); @@ -1741,11 +1770,11 @@ describe('icmp', () => { test('ping session rejects when the host does not reply', async () => { const stack = await createStack(); - await stack.createTunInterface({ + await stack.interfaces.createTun({ ip: '192.168.1.1/24', }); - const pingSession = await stack.createPingSession({ + const pingSession = await stack.ping.createSession({ host: '192.168.1.2', timeout: 10, }); diff --git a/packages/tcpip/src/stack.ts b/packages/tcpip/src/stack.ts index 05ccac1..835b060 100644 --- a/packages/tcpip/src/stack.ts +++ b/packages/tcpip/src/stack.ts @@ -10,19 +10,12 @@ import type { WasmInstance } from './bindings/types.js'; import { UdpBindings } from './bindings/udp.js'; import { fetchFile } from './fetch-file.js'; import type { - BridgeInterfaceOptions, - LoopbackInterface, - LoopbackInterfaceOptions, NetworkInterface, + NetworkInterfaces, NetworkStack, - PingSessionOptions, - TapInterface, - TapInterfaceOptions, - TcpConnectionOptions, - TcpListenerOptions, - TunInterface, - TunInterfaceOptions, - UdpSocketOptions, + PingApi, + TcpTransport, + UdpTransport, } from './types.js'; export async function createStack( @@ -63,9 +56,10 @@ export class VirtualNetworkStack implements NetworkStack { #icmpBindings: IcmpBindings; ready: Promise; - get interfaces() { - return this.#listInterfaces(); - } + readonly tcp: TcpTransport; + readonly udp: UdpTransport; + readonly ping: PingApi; + readonly interfaces: NetworkInterfaces; constructor(options: NetworkStackOptions = {}) { this.#options = { @@ -73,15 +67,72 @@ export class VirtualNetworkStack implements NetworkStack { initializeLoopback: options.initializeLoopback ?? true, }; - this.#dnsClient = new DnsClient(this, { - nameServer: options.nameServer ?? { ip: '127.0.0.1', port: 53 }, - }); - // Initialize bindings this.#loopbackBindings = new LoopbackBindings(); this.#tunBindings = new TunBindings(); this.#tapBindings = new TapBindings(); this.#bridgeBindings = new BridgeBindings(); + + this.tcp = { + connect: async (options) => { + await this.ready; + return this.#tcpBindings.connect(options); + }, + listen: async (options) => { + await this.ready; + return this.#tcpBindings.listen(options); + }, + }; + this.udp = { + open: async (options = {}) => { + await this.ready; + return this.#udpBindings.open(options); + }, + }; + this.ping = { + createSession: async (options) => { + await this.ready; + return this.#icmpBindings.createPingSession(options); + }, + }; + this.interfaces = { + createLoopback: async (options) => { + await this.ready; + return this.#loopbackBindings.create(options); + }, + createTun: async (options) => { + await this.ready; + return this.#tunBindings.create(options); + }, + createTap: async (options = {}) => { + await this.ready; + return this.#tapBindings.create(options); + }, + createBridge: async (options) => { + await this.ready; + return this.#bridgeBindings.create(options); + }, + remove: async (netInterface) => { + await this.ready; + + switch (netInterface.type) { + case 'loopback': + return this.#loopbackBindings.remove(netInterface); + case 'tun': + return this.#tunBindings.remove(netInterface); + case 'tap': + return this.#tapBindings.remove(netInterface); + case 'bridge': + return this.#bridgeBindings.remove(netInterface); + default: + throw new Error('unknown interface type'); + } + }, + [Symbol.iterator]: () => this.#listInterfaces(), + }; + this.#dnsClient = new DnsClient(this.udp, { + nameServer: options.nameServer ?? { ip: '127.0.0.1', port: 53 }, + }); this.#tcpBindings = new TcpBindings(this.#dnsClient); this.#udpBindings = new UdpBindings(this.#dnsClient); this.#icmpBindings = new IcmpBindings(this.#dnsClient); @@ -92,7 +143,7 @@ export class VirtualNetworkStack implements NetworkStack { // Post-init setup this.ready.then(async () => { if (this.#options.initializeLoopback) { - await this.createLoopbackInterface({ + await this.interfaces.createLoopback({ ip: '127.0.0.1/8', }); } @@ -157,88 +208,91 @@ export class VirtualNetworkStack implements NetworkStack { ); } - *#listInterfaces(): Iterable { + *#listInterfaces(): IterableIterator { yield* this.#loopbackBindings.interfaces.values(); yield* this.#tunBindings.interfaces.values(); yield* this.#tapBindings.interfaces.values(); yield* this.#bridgeBindings.interfaces.values(); } - async createLoopbackInterface( - options: LoopbackInterfaceOptions - ): Promise { - await this.ready; - return this.#loopbackBindings.create(options); + /** + * @deprecated Use `stack.interfaces.createLoopback()` instead. + */ + createLoopbackInterface( + ...args: Parameters + ): ReturnType { + return this.interfaces.createLoopback(...args); } - async createTunInterface( - options: TunInterfaceOptions - ): Promise { - await this.ready; - return this.#tunBindings.create(options); + /** + * @deprecated Use `stack.interfaces.createTun()` instead. + */ + createTunInterface( + ...args: Parameters + ): ReturnType { + return this.interfaces.createTun(...args); } - async createTapInterface( - options: TapInterfaceOptions = {} - ): Promise { - await this.ready; - return this.#tapBindings.create(options); + /** + * @deprecated Use `stack.interfaces.createTap()` instead. + */ + createTapInterface( + ...args: Parameters + ): ReturnType { + return this.interfaces.createTap(...args); } - async createBridgeInterface(options: BridgeInterfaceOptions) { - await this.ready; - return this.#bridgeBindings.create(options); + /** + * @deprecated Use `stack.interfaces.createBridge()` instead. + */ + createBridgeInterface( + ...args: Parameters + ): ReturnType { + return this.interfaces.createBridge(...args); } - async removeInterface(netInterface: NetworkInterface) { - await this.ready; - - switch (netInterface.type) { - case 'loopback': - return this.#loopbackBindings.remove(netInterface); - case 'tun': - return this.#tunBindings.remove(netInterface); - case 'tap': - return this.#tapBindings.remove(netInterface); - case 'bridge': - return this.#bridgeBindings.remove(netInterface); - default: - throw new Error('unknown interface type'); - } + /** + * @deprecated Use `stack.interfaces.remove()` instead. + */ + removeInterface( + ...args: Parameters + ): ReturnType { + return this.interfaces.remove(...args); } /** - * Listens for incoming TCP connections on the specified host/port. + * @deprecated Use `stack.tcp.listen()` instead. */ - async listenTcp(options: TcpListenerOptions) { - await this.ready; - return this.#tcpBindings.listen(options); + listenTcp( + ...args: Parameters + ): ReturnType { + return this.tcp.listen(...args); } /** - * Establishes an outbound TCP connection to a remote host/port. + * @deprecated Use `stack.tcp.connect()` instead. */ - async connectTcp(options: TcpConnectionOptions) { - await this.ready; - return this.#tcpBindings.connect(options); + connectTcp( + ...args: Parameters + ): ReturnType { + return this.tcp.connect(...args); } /** - * Opens a UDP socket for sending and receiving datagrams. - * - * If no local host is provided, the socket will bind to all available interfaces. - * If no local port is provided, the socket will bind to a random port. + * @deprecated Use `stack.udp.open()` instead. */ - async openUdp(options: UdpSocketOptions = {}) { - await this.ready; - return this.#udpBindings.open(options); + openUdp( + ...args: Parameters + ): ReturnType { + return this.udp.open(...args); } /** - * Creates an ICMP ping session for sending echo requests to a host. + * @deprecated Use `stack.ping.createSession()` instead. */ - async createPingSession(options: PingSessionOptions) { - await this.ready; - return this.#icmpBindings.createPingSession(options); + createPingSession( + ...args: Parameters + ): ReturnType { + return this.ping.createSession(...args); } } diff --git a/packages/tcpip/src/types.test-d.ts b/packages/tcpip/src/types.test-d.ts new file mode 100644 index 0000000..5f2c493 --- /dev/null +++ b/packages/tcpip/src/types.test-d.ts @@ -0,0 +1,11 @@ +import type { DatagramTransport, StreamTransport } from '@tcpip/transport'; +import { expectTypeOf, test } from 'vitest'; +import type { TcpTransport, UdpTransport } from './types.js'; + +test('TcpTransport satisfies StreamTransport', () => { + expectTypeOf().toExtend(); +}); + +test('UdpTransport satisfies DatagramTransport', () => { + expectTypeOf().toExtend(); +}); diff --git a/packages/tcpip/src/types.ts b/packages/tcpip/src/types.ts index eda560a..a30239d 100644 --- a/packages/tcpip/src/types.ts +++ b/packages/tcpip/src/types.ts @@ -1,51 +1,26 @@ +import type { + Datagram, + DatagramSocketOptions, + DuplexStream, + StreamConnectOptions, + StreamListenOptions, +} from '@tcpip/transport'; import type { IPv4Address, IPv4Cidr, MacAddress } from '@tcpip/wire'; -export type DuplexStream = { - readable: ReadableStream; - writable: WritableStream; -}; - -export type UdpDatagram = { - host: string; - port: number; - data: Uint8Array; -}; +export type UdpDatagram = Datagram; -export type UdpSocketOptions = { - /** - * The local host to bind to. - * - * If not provided, the socket will bind to all available interfaces. - */ - host?: string; - /** - * The local port to bind to. - * - * If not provided, the socket will bind to a random port. - */ - port?: number; -}; +export type UdpSocketOptions = DatagramSocketOptions; -export type UdpSocket = { - readable: ReadableStream; - writable: WritableStream; +export type UdpSocket = DuplexStream & { close(): Promise; [Symbol.asyncIterator](): AsyncIterator; }; -export type TcpListenerOptions = { - host?: string; - port: number; -}; +export type TcpListenerOptions = StreamListenOptions; -export type TcpConnectionOptions = { - host: string; - port: number; -}; +export type TcpConnectionOptions = StreamConnectOptions; -export type TcpConnection = { - readable: ReadableStream; - writable: WritableStream; +export type TcpConnection = DuplexStream & { close(): Promise; [Symbol.asyncIterator](): AsyncIterator; }; @@ -79,6 +54,34 @@ export type TcpListener = { [Symbol.asyncIterator](): AsyncIterableIterator; }; +export type TcpTransport = { + /** + * Establishes an outbound TCP connection to a remote host/port. + */ + connect(options: TcpConnectionOptions): Promise; + /** + * Listens for incoming TCP connections on the specified host/port. + */ + listen(options: TcpListenerOptions): Promise; +}; + +export type UdpTransport = { + /** + * Opens a UDP socket for sending and receiving datagrams. + * + * If no local host is provided, the socket will bind to all available interfaces. + * If no local port is provided, the socket will bind to a random port. + */ + open(options?: UdpSocketOptions): Promise; +}; + +export type PingApi = { + /** + * Creates an ICMP ping session for sending echo requests to a host. + */ + createSession(options: PingSessionOptions): Promise; +}; + export type LoopbackInterfaceOptions = { ip?: IPv4Cidr; }; @@ -138,27 +141,55 @@ export type NetworkInterface = | TapInterface | BridgeInterface; +export type NetworkInterfaces = Iterable & { + createLoopback(options: LoopbackInterfaceOptions): Promise; + createTun(options: TunInterfaceOptions): Promise; + createTap(options?: TapInterfaceOptions): Promise; + createBridge(options: BridgeInterfaceOptions): Promise; + remove(netInterface: NetworkInterface): Promise; +}; + export type NetworkStack = { readonly ready: Promise; - readonly interfaces: Iterable; + readonly tcp: TcpTransport; + readonly udp: UdpTransport; + readonly ping: PingApi; + readonly interfaces: NetworkInterfaces; + /** + * @deprecated Use `stack.interfaces.createLoopback()` instead. + */ createLoopbackInterface( options: LoopbackInterfaceOptions ): Promise; + /** + * @deprecated Use `stack.interfaces.createTun()` instead. + */ createTunInterface(options: TunInterfaceOptions): Promise; + /** + * @deprecated Use `stack.interfaces.createTap()` instead. + */ createTapInterface(options?: TapInterfaceOptions): Promise; + /** + * @deprecated Use `stack.interfaces.createBridge()` instead. + */ createBridgeInterface( options: BridgeInterfaceOptions ): Promise; - removeInterface( - netInterface: LoopbackInterface | TunInterface | TapInterface - ): Promise; + /** + * @deprecated Use `stack.interfaces.remove()` instead. + */ + removeInterface(netInterface: NetworkInterface): Promise; /** * Listens for incoming TCP connections on the specified host/port. + * + * @deprecated Use `stack.tcp.listen()` instead. */ listenTcp(options: TcpListenerOptions): Promise; /** * Establishes an outbound TCP connection to a remote host/port. + * + * @deprecated Use `stack.tcp.connect()` instead. */ connectTcp(options: TcpConnectionOptions): Promise; /** @@ -166,10 +197,14 @@ export type NetworkStack = { * * If no local host is provided, the socket will bind to all available interfaces. * If no local port is provided, the socket will bind to a random port. + * + * @deprecated Use `stack.udp.open()` instead. */ openUdp(options?: UdpSocketOptions): Promise; /** * Creates an ICMP ping session for sending echo requests to a host. + * + * @deprecated Use `stack.ping.createSession()` instead. */ createPingSession(options: PingSessionOptions): Promise; }; diff --git a/packages/tcpip/src/util.ts b/packages/tcpip/src/util.ts index e469637..e44e7e1 100644 --- a/packages/tcpip/src/util.ts +++ b/packages/tcpip/src/util.ts @@ -1,5 +1,3 @@ -import type { DuplexStream } from './types.js'; - /** * Utility class to facilitate internal communication * between bindings and JS instances. @@ -105,44 +103,6 @@ export class EventMap extends Map { } } -/** - * Converts a `ReadableStream` into an `AsyncIterableIterator`. - * - * Allows you to use ReadableStreams in a `for await ... of` loop. - */ -export function fromReadable( - readable: ReadableStream, - options?: { preventCancel?: boolean } -): AsyncIterableIterator { - const reader = readable.getReader(); - return fromReader(reader, options); -} - -/** - * Converts a `ReadableStreamDefaultReader` into an `AsyncIterableIterator`. - * - * Allows you to use Readers in a `for await ... of` loop. - */ -export async function* fromReader( - reader: ReadableStreamDefaultReader, - options?: { preventCancel?: boolean } -): AsyncIterableIterator { - try { - while (true) { - const { done, value } = await reader.read(); - if (done) { - return value; - } - yield value; - } - } finally { - if (!options?.preventCancel) { - await reader.cancel(); - } - reader.releaseLock(); - } -} - export type UnderlyingSourceLockCallback = () => void; /** @@ -218,57 +178,3 @@ export class ExtendedReadableStream extends ReadableStream { export async function nextMicrotask() { return await new Promise((resolve) => queueMicrotask(resolve)); } - -export type ConnectInterfacesOptions = { - transformAtoB?: (chunk: Uint8Array) => Uint8Array; - transformBtoA?: (chunk: Uint8Array) => Uint8Array; -}; - -/** - * Connects two duplex streams by piping each stream's `readable` - * to the other's `writable`. - * - * This is useful for connecting two network interfaces together - * (e.g. a tap interface and a v86 network interface). - * - * Optionally supports transforming the data as it is passed - * between the two streams. Use this to log or modify the data. - */ -export function connectStreams( - interfaceA: DuplexStream, - interfaceB: DuplexStream, - { transformAtoB, transformBtoA }: ConnectInterfacesOptions = {} -) { - const streamA = transformAtoB - ? interfaceA.readable.pipeThrough( - new TransformStream({ - transform(chunk, controller) { - try { - const transformedChunk = transformAtoB(chunk); - controller.enqueue(transformedChunk); - } catch (error) { - console.warn('Error transforming A to B', error); - } - }, - }) - ) - : interfaceA.readable; - - const streamB = transformBtoA - ? interfaceB.readable.pipeThrough( - new TransformStream({ - transform(chunk, controller) { - try { - const transformedChunk = transformBtoA(chunk); - controller.enqueue(transformedChunk); - } catch (error) { - console.warn('Error transforming B to A', error); - } - }, - }) - ) - : interfaceB.readable; - - streamA.pipeTo(interfaceB.writable); - streamB.pipeTo(interfaceA.writable); -} diff --git a/packages/tcpip/tsconfig.json b/packages/tcpip/tsconfig.json index a054922..e41ab72 100644 --- a/packages/tcpip/tsconfig.json +++ b/packages/tcpip/tsconfig.json @@ -5,8 +5,8 @@ "lib": ["es2022", "dom", "dom.iterable", "esnext.disposable"], "paths": { "@tcpip/dns": ["../dns/src/index.js"], - "@tcpip/wire": ["../wire/src/index.js"], - "tcpip/types": ["./src/types.js"] + "@tcpip/transport": ["../transport/src/index.js"], + "@tcpip/wire": ["../wire/src/index.js"] } } } diff --git a/packages/tcpip/vitest.workspace.ts b/packages/tcpip/vitest.workspace.ts index bd588cb..da1ef20 100644 --- a/packages/tcpip/vitest.workspace.ts +++ b/packages/tcpip/vitest.workspace.ts @@ -1,6 +1,13 @@ import { defineWorkspace } from 'vitest/config'; export default defineWorkspace([ + { + test: { + name: 'types', + environment: 'node', + include: ['src/**/*.test-d.ts'], + }, + }, { esbuild: { target: 'es2022', diff --git a/packages/transport/package.json b/packages/transport/package.json new file mode 100644 index 0000000..82b9021 --- /dev/null +++ b/packages/transport/package.json @@ -0,0 +1,34 @@ +{ + "name": "@tcpip/transport", + "version": "0.0.0", + "description": "Transport contracts for tcpip.js protocol packages", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/chipmk/tcpip.js.git", + "directory": "packages/transport" + }, + "main": "dist/index.cjs", + "types": "dist/index.d.ts", + "type": "module", + "sideEffects": false, + "scripts": { + "build": "tsup --clean", + "clean": "rm -rf dist", + "prepublishOnly": "pnpm run build" + }, + "files": ["dist/**/*"], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.cjs" + } + }, + "dependencies": {}, + "devDependencies": { + "@total-typescript/tsconfig": "catalog:", + "tsup": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/transport/src/index.ts b/packages/transport/src/index.ts new file mode 100644 index 0000000..a39191f --- /dev/null +++ b/packages/transport/src/index.ts @@ -0,0 +1,154 @@ +export type DuplexStream = { + readable: ReadableStream; + writable: WritableStream; +}; + +export type StreamConnectOptions = { + host: string; + port: number; +}; + +export type StreamListenOptions = { + host?: string; + port: number; +}; + +export type StreamConnection = DuplexStream & { + close(): Promise; +}; + +export type StreamListener = { + [Symbol.asyncIterator](): AsyncIterableIterator; +}; + +export type StreamTransport = { + /** + * Establishes an outbound stream connection to a remote host/port. + */ + connect(options: StreamConnectOptions): Promise; + /** + * Listens for incoming stream connections on the specified host/port. + */ + listen?(options: StreamListenOptions): Promise; +}; + +export type Datagram = { + host: string; + port: number; + data: Uint8Array; +}; + +export type DatagramSocketOptions = { + /** + * The local host to bind to. + * + * If not provided, the socket will bind to all available interfaces. + */ + host?: string; + /** + * The local port to bind to. + * + * If not provided, the socket will bind to a random port. + */ + port?: number; +}; + +export type DatagramSocket = DuplexStream & { + close(): Promise; +}; + +export type DatagramTransport = { + /** + * Opens a datagram socket for sending and receiving complete datagrams. + */ + open(options?: DatagramSocketOptions): Promise; +}; + +/** + * Converts a `ReadableStream` into an `AsyncIterableIterator`. + * + * Allows you to use `ReadableStream`s in a `for await ... of` loop. + */ +export function fromReadable( + readable: ReadableStream, + options?: { preventCancel?: boolean } +): AsyncIterableIterator { + const reader = readable.getReader(); + return fromReader(reader, options); +} + +/** + * Converts a `ReadableStreamDefaultReader` into an `AsyncIterableIterator`. + * + * Allows you to use readers in a `for await ... of` loop. + */ +export async function* fromReader( + reader: ReadableStreamDefaultReader, + options?: { preventCancel?: boolean } +): AsyncIterableIterator { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + return value; + } + yield value; + } + } finally { + if (!options?.preventCancel) { + await reader.cancel(); + } + reader.releaseLock(); + } +} + +export type ConnectStreamsOptions = { + transformAtoB?: (chunk: Uint8Array) => Uint8Array; + transformBtoA?: (chunk: Uint8Array) => Uint8Array; +}; + +/** + * Connects two byte duplex streams by piping each stream's `readable` + * to the other's `writable`. + * + * Optionally supports transforming the data as it is passed + * between the two streams. Use this to log or modify the data. + */ +export function connectStreams( + streamA: DuplexStream, + streamB: DuplexStream, + { transformAtoB, transformBtoA }: ConnectStreamsOptions = {} +) { + const readableA = transformAtoB + ? streamA.readable.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + try { + const transformedChunk = transformAtoB(chunk); + controller.enqueue(transformedChunk); + } catch (error) { + console.warn('Error transforming A to B', error); + } + }, + }) + ) + : streamA.readable; + + const readableB = transformBtoA + ? streamB.readable.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + try { + const transformedChunk = transformBtoA(chunk); + controller.enqueue(transformedChunk); + } catch (error) { + console.warn('Error transforming B to A', error); + } + }, + }) + ) + : streamB.readable; + + readableA.pipeTo(streamB.writable); + readableB.pipeTo(streamA.writable); +} diff --git a/packages/transport/tsconfig.json b/packages/transport/tsconfig.json new file mode 100644 index 0000000..c19ca2b --- /dev/null +++ b/packages/transport/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@total-typescript/tsconfig/tsc/dom/library", + "include": ["src/**/*.ts"], + "compilerOptions": { + "lib": ["es2022", "dom", "dom.iterable"] + } +} diff --git a/packages/transport/tsup.config.ts b/packages/transport/tsup.config.ts new file mode 100644 index 0000000..0d255dd --- /dev/null +++ b/packages/transport/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig([ + { + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + outDir: 'dist', + sourcemap: true, + dts: true, + minify: true, + splitting: true, + }, +]); diff --git a/packages/v86/package.json b/packages/v86/package.json index 7b7bad1..81e8b2f 100644 --- a/packages/v86/package.json +++ b/packages/v86/package.json @@ -30,6 +30,7 @@ "devDependencies": { "@tcpip/dhcp": "workspace:*", "@tcpip/http": "workspace:*", + "@tcpip/transport": "workspace:*", "@tcpip/wire": "workspace:*", "@total-typescript/tsconfig": "catalog:", "@types/node": "catalog:", diff --git a/packages/v86/src/network-adapter.test.ts b/packages/v86/src/network-adapter.test.ts index 0e43d6b..914d39b 100644 --- a/packages/v86/src/network-adapter.test.ts +++ b/packages/v86/src/network-adapter.test.ts @@ -1,14 +1,15 @@ import { createDhcp } from '@tcpip/dhcp'; import { createHttp } from '@tcpip/http'; +import { connectStreams } from '@tcpip/transport'; import { type TcpSegment, parseEthernetFrame } from '@tcpip/wire'; -import { connectStreams, createStack } from 'tcpip'; +import { createStack } from 'tcpip'; import { describe, expect, it } from 'vitest'; import { createVm, nextValue } from '../test/util.js'; describe('network adapter', () => { it('should ping a VM from the host stack', async () => { const networkStack = await createStack(); - const tapInterface = await networkStack.createTapInterface({ + const tapInterface = await networkStack.interfaces.createTap({ ip: '192.168.1.1/24', }); @@ -19,7 +20,7 @@ describe('network adapter', () => { connectStreams(tapInterface, net); const payload = new TextEncoder().encode('tcpip.js ping'); - const pingSession = await networkStack.createPingSession({ + const pingSession = await networkStack.ping.createSession({ host: '192.168.1.2', }); @@ -43,7 +44,7 @@ describe('network adapter', () => { it('should ping the host stack from a VM', async () => { const networkStack = await createStack(); - const tapInterface = await networkStack.createTapInterface({ + const tapInterface = await networkStack.interfaces.createTap({ ip: '192.168.1.1/24', }); @@ -63,10 +64,10 @@ describe('network adapter', () => { it('should assign an IP address and DNS server to a VM with DHCP', async () => { const networkStack = await createStack(); - const tapInterface = await networkStack.createTapInterface({ + const tapInterface = await networkStack.interfaces.createTap({ ip: '192.168.1.1/24', }); - const dhcp = await createDhcp(networkStack); + const dhcp = await createDhcp(networkStack.udp); const dhcpServer = await dhcp.serve({ leaseRange: { start: '192.168.1.100', end: '192.168.1.110' }, serverIdentifier: '192.168.1.1', @@ -95,7 +96,7 @@ describe('network adapter', () => { it('should make tcp connection from VM to host', async () => { const networkStack = await createStack(); - const tapInterface = await networkStack.createTapInterface({ + const tapInterface = await networkStack.interfaces.createTap({ ip: '192.168.1.1/24', }); @@ -105,7 +106,7 @@ describe('network adapter', () => { connectStreams(tapInterface, net); - const listener = await networkStack.listenTcp({ port: 5000 }); + const listener = await networkStack.tcp.listen({ port: 5000 }); const telnetPromise = executeCommand('telnet 192.168.1.1 5000'); @@ -127,7 +128,7 @@ describe('network adapter', () => { it('should make tcp connection from host to VM', async () => { const networkStack = await createStack(); - const tapInterface = await networkStack.createTapInterface({ + const tapInterface = await networkStack.interfaces.createTap({ ip: '192.168.1.1/24', }); @@ -145,7 +146,7 @@ describe('network adapter', () => { // Wait for inetd to start await new Promise((resolve) => setTimeout(resolve, 100)); - const connection = await networkStack.connectTcp({ + const connection = await networkStack.tcp.connect({ host: '192.168.1.2', port: 5000, }); @@ -163,7 +164,7 @@ describe('network adapter', () => { it('should serve HTTP responses to a VM', async () => { const networkStack = await createStack(); - const tapInterface = await networkStack.createTapInterface({ + const tapInterface = await networkStack.interfaces.createTap({ ip: '192.168.1.1/24', }); @@ -173,7 +174,7 @@ describe('network adapter', () => { connectStreams(tapInterface, net); - const { serve } = await createHttp(networkStack); + const { serve } = await createHttp(networkStack.tcp); await serve(() => { return new Response('hello from tcpip.js'); }); @@ -187,7 +188,7 @@ describe('network adapter', () => { it('should send data larger than the send buffer from host to VM', async () => { const networkStack = await createStack(); - const tapInterface = await networkStack.createTapInterface({ + const tapInterface = await networkStack.interfaces.createTap({ ip: '192.168.1.1/24', }); @@ -197,7 +198,7 @@ describe('network adapter', () => { connectStreams(tapInterface, net); - const listener = await networkStack.listenTcp({ port: 5000 }); + const listener = await networkStack.tcp.listen({ port: 5000 }); const telnetPromise = executeCommand('telnet 192.168.1.1 5000'); @@ -222,7 +223,7 @@ describe('network adapter', () => { it('should receive 3 way TCP handshake from VM to host', async () => { const networkStack = await createStack(); - const tapInterface = await networkStack.createTapInterface({ + const tapInterface = await networkStack.interfaces.createTap({ ip: '192.168.1.1/24', }); @@ -250,7 +251,7 @@ describe('network adapter', () => { transformBtoA: captureTcpSegments, }); - const listener = await networkStack.listenTcp({ port: 5000 }); + const listener = await networkStack.tcp.listen({ port: 5000 }); // Intentionally don't await executeCommand('telnet 192.168.1.1 5000'); diff --git a/packages/v86/src/network-adapter.ts b/packages/v86/src/network-adapter.ts index 92add7b..a067f41 100644 --- a/packages/v86/src/network-adapter.ts +++ b/packages/v86/src/network-adapter.ts @@ -60,7 +60,7 @@ export class V86NetworkStream { * @example * const stack = await createStack(); * - * const tapInterface = await stack.createTapInterface({ + * const tapInterface = await stack.interfaces.createTap({ * mac: '01:23:45:67:89:ab', * ip: '192.168.1.1/24', * }); diff --git a/packages/v86/tsconfig.json b/packages/v86/tsconfig.json index 4d1f263..9686860 100644 --- a/packages/v86/tsconfig.json +++ b/packages/v86/tsconfig.json @@ -5,10 +5,10 @@ "lib": ["es2022", "dom", "dom.iterable", "esnext.disposable"], "paths": { "tcpip": ["../tcpip/src/index.js"], - "tcpip/types": ["../tcpip/src/types.js"], "@tcpip/dhcp": ["../dhcp/src/index.js"], "@tcpip/dns": ["../dns/src/index.js"], "@tcpip/http": ["../http/src/index.js"], + "@tcpip/transport": ["../transport/src/index.js"], "@tcpip/wire": ["../wire/src/index.js"] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bfafe40..147ecb9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: packages/dhcp: dependencies: + '@tcpip/transport': + specifier: workspace:* + version: link:../transport '@tcpip/wire': specifier: workspace:* version: link:../wire @@ -78,6 +81,9 @@ importers: packages/dns: dependencies: + '@tcpip/transport': + specifier: workspace:* + version: link:../transport '@tcpip/wire': specifier: workspace:* version: link:../wire @@ -99,6 +105,10 @@ importers: version: 3.2.4(@types/node@22.19.17)(@vitest/browser@3.2.4)(tsx@4.21.0) packages/http: + dependencies: + '@tcpip/transport': + specifier: workspace:* + version: link:../transport devDependencies: '@bjorn3/browser_wasi_shim': specifier: ^0.3.0 @@ -136,6 +146,9 @@ importers: '@tcpip/dns': specifier: workspace:* version: link:../dns + '@tcpip/transport': + specifier: workspace:* + version: link:../transport '@tcpip/wire': specifier: workspace:* version: link:../wire @@ -165,6 +178,18 @@ importers: specifier: 'catalog:' version: 3.2.4(@types/node@22.19.17)(@vitest/browser@3.2.4)(tsx@4.21.0) + packages/transport: + devDependencies: + '@total-typescript/tsconfig': + specifier: 'catalog:' + version: 1.0.4 + tsup: + specifier: 'catalog:' + version: 8.5.1(postcss@8.5.14)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + packages/v86: devDependencies: '@tcpip/dhcp': @@ -173,6 +198,9 @@ importers: '@tcpip/http': specifier: workspace:* version: link:../http + '@tcpip/transport': + specifier: workspace:* + version: link:../transport '@tcpip/wire': specifier: workspace:* version: link:../wire diff --git a/release-please-config.json b/release-please-config.json index 9963d0e..f0098c3 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -11,6 +11,9 @@ "packages/tcpip": { "component": "tcpip" }, + "packages/transport": { + "component": "@tcpip/transport" + }, "packages/wire": { "component": "@tcpip/wire" },