Skip to content

container2wasm support #9

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 9 commits into
base: feat/udp
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
463 changes: 447 additions & 16 deletions package-lock.json

Large diffs are not rendered by default.

35 changes: 35 additions & 0 deletions packages/c2w/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "@tcpip/c2w",
"version": "0.1.0",
"description": "Network adapter that connects tcpip.js with container2wasm VMs",
"main": "dist/index.cjs",
"types": "dist/index.d.ts",
"type": "module",
"scripts": {
"build": "tsup --clean",
"test": "vitest",
"prepublishOnly": "npm run build"
},
"files": [
"dist/**/*"
],
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
"default": "./dist/index.cjs"
}
},
"dependencies": {
"@bjorn3/browser_wasi_shim": "^0.3.0",
"comlink": "^4.4.2"
},
"devDependencies": {
"@chialab/esbuild-plugin-worker": "^0.18.1",
"@total-typescript/tsconfig": "^1.0.4",
"tcpip": "0.2",
"typescript": "^5.0.4",
"vitest": "^3.0.1",
"web-worker": "^1.3.0"
}
}
11 changes: 11 additions & 0 deletions packages/c2w/patches/web-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* The 'web-worker' package is a Node.js polyfill for the Web Worker API.
*
* It uses `__filename` to get the current file path, which isn't supported
* in Node.js ES modules. The equivalent in ES modules is `import.meta.url`.
*
* This is an ESBuild patch to replace `__filename` with `import.meta.url`.
*/
const metaUrl = new URL(import.meta.url);

export { metaUrl as __filename };
107 changes: 107 additions & 0 deletions packages/c2w/src/container/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { proxy, wrap } from 'comlink';
import Worker from 'web-worker';
import { NetworkInterface } from './network-interface.js';
import { StdioInterface } from './stdio-interface.js';
import type { VM, VMOptions } from './vm.js';

export type ContainerNetOptions = {
/**
* The MAC address to assign to the VM.
*
* If not provided, a random MAC address will be generated.
*/
macAddress?: string;
};

export type ContainerOptions = {
/**
* The URL of the c2w-compiled WASM file to load.
*/
wasmUrl: string | URL;

/**
* The entrypoint to the container.
*/
entrypoint?: string;

/**
* The command to run in the container.
*/
command?: string[];

/**
* Environment variables to pass to the container.
*/
env?: Record<string, string>;

/**
* Network configuration for the container VM.
*/
net?: ContainerNetOptions;

/**
* Callback when the container VM exits.
*/
onExit?: (exitCode: number) => void;

/**
* Enable debug logging.
*/
debug?: boolean;
};

/**
* Creates a `container2wasm` VM.
*
* Returns an object with `stdio` and `net` properties, which are interfaces for
* interacting with the VM's standard I/O and network interfaces.
*/
export async function createContainer(options: ContainerOptions) {
const stdioInterface = new StdioInterface({
debug: options.debug,
});
const netInterface = new NetworkInterface({
macAddress: options.net?.macAddress,
debug: options.debug,
});

const vmWorker = await createVMWorker({
wasmUrl: options.wasmUrl,
stdio: stdioInterface.vmStdioOptions,
net: netInterface.vmNetOptions,
entrypoint: options.entrypoint,
command: options.command,
env: options.env,
debug: options.debug,
});

vmWorker.run().then((exitCode) => {
vmWorker.close();
options.onExit?.(exitCode);
});

return {
stdio: stdioInterface,
net: netInterface,
};
}

async function createVMWorker(options: VMOptions) {
const worker = new Worker(new URL('./vm-worker.ts', import.meta.url), {
type: 'module',
});

const VMWorker = wrap<typeof VM>(worker);
return await new VMWorker(
{
wasmUrl: String(options.wasmUrl),
stdio: options.stdio,
net: options.net,
entrypoint: options.entrypoint,
command: options.command,
env: options.env,
debug: options.debug,
},
proxy(console.log)
);
}
110 changes: 110 additions & 0 deletions packages/c2w/src/container/network-interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { frameStream } from '../frame/length-prefixed-frames.js';
import { createAsyncRingBuffer } from '../ring-buffer/index.js';
import { RingBuffer } from '../ring-buffer/ring-buffer.js';
import type { DuplexStream } from '../types.js';
import { fromReadable, generateMacAddress, parseMacAddress } from '../util.js';
import type { VMNetOptions } from './vm.js';

export type NetworkInterfaceOptions = {
/**
* The MAC address to assign to the VM.
*
* If not provided, a random MAC address will be generated.
*/
macAddress?: string;

/**
* Enable debug logging.
*/
debug?: boolean;
};

export class NetworkInterface
implements DuplexStream<Uint8Array>, AsyncIterable<Uint8Array>
{
#receiveBuffer: SharedArrayBuffer;
#sendBuffer: SharedArrayBuffer;

readonly readable: ReadableStream<Uint8Array>;
readonly writable: WritableStream<Uint8Array>;
readonly macAddress: string;

/**
* The network options to pass to the VM.
*
* Internal use only.
*/
get vmNetOptions(): VMNetOptions {
return {
receiveBuffer: this.#sendBuffer, // VM reads from sendBuffer
sendBuffer: this.#receiveBuffer, // VM writes to receiveBuffer
macAddress: this.macAddress,
};
}

constructor(options: NetworkInterfaceOptions) {
this.macAddress =
options.macAddress ?? parseMacAddress(generateMacAddress());

// Create shared buffers for network communication
this.#receiveBuffer = new SharedArrayBuffer(1024 * 1024);
this.#sendBuffer = new SharedArrayBuffer(1024 * 1024);

// Create ring buffers for network communication
const receiveRingPromise = createAsyncRingBuffer(
this.#receiveBuffer,
(...data: unknown[]) => console.log('Net interface: Receive:', ...data),
options.debug
);
const sendRing = new RingBuffer(
this.#sendBuffer,
(...data: unknown[]) => console.log('Net interface: Send:', ...data),
options.debug
);

// Create a raw duplex stream for reading and writing frames
const rawStream: DuplexStream<Uint8Array> = {
readable: new ReadableStream<Uint8Array>(
{
async pull(controller) {
const receiveRing = await receiveRingPromise;
const data = await receiveRing.read(
controller.desiredSize ?? undefined
);

controller.enqueue(data);
},
},
{
highWaterMark: 1024 * 1024,
size(chunk) {
return chunk.length;
},
}
),
writable: new WritableStream<Uint8Array>({
write(chunk) {
sendRing.write(chunk);
},
}),
};

// c2w uses 4-byte length-prefixed frames
const { readable, writable } = frameStream(rawStream, { headerLength: 4 });

// Expose streams for external reading and writing
this.readable = readable;
this.writable = writable;
}

listen() {
if (this.readable.locked) {
throw new Error('readable stream already locked');
}
return fromReadable(this.readable);
}

[Symbol.asyncIterator](): AsyncIterableIterator<Uint8Array> {
return this.listen();
}
}
106 changes: 106 additions & 0 deletions packages/c2w/src/container/stdio-interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { createAsyncRingBuffer } from '../ring-buffer/index.js';
import { RingBuffer } from '../ring-buffer/ring-buffer.js';
import { fromReadable } from '../util.js';
import type { VMStdioOptions } from './vm.js';

export type StdioInterfaceOptions = {
/**
* Enable debug logging.
*/
debug?: boolean;
};

export class StdioInterface {
#stdinBuffer: SharedArrayBuffer;
#stdoutBuffer: SharedArrayBuffer;
#stderrBuffer: SharedArrayBuffer;

readonly stdin: WritableStream<Uint8Array>;
readonly stdout: ReadableStream<Uint8Array>;
readonly stderr: ReadableStream<Uint8Array>;

get iterateStdout() {
return fromReadable(this.stdout);
}

get iterateStderr() {
return fromReadable(this.stderr);
}

/**
* The stdio options to pass to the VM.
*
* Internal use only.
*/
get vmStdioOptions(): VMStdioOptions {
return {
stdinBuffer: this.#stdinBuffer,
stdoutBuffer: this.#stdoutBuffer,
stderrBuffer: this.#stderrBuffer,
};
}

constructor(options: StdioInterfaceOptions = {}) {
// Create shared buffers for network communication
this.#stdinBuffer = new SharedArrayBuffer(1024 * 1024);
this.#stdoutBuffer = new SharedArrayBuffer(1024 * 1024);
this.#stderrBuffer = new SharedArrayBuffer(1024 * 1024);

// Create ring buffers for network communication
const stdinRing = new RingBuffer(
this.#stdinBuffer,
(...data: unknown[]) => console.log('Stdio interface: Stdin:', ...data),
options.debug
);
const stdoutRingPromise = createAsyncRingBuffer(
this.#stdoutBuffer,
(...data: unknown[]) => console.log('Stdio interface: Stdout:', ...data),
options.debug
);
const stderrRingPromise = createAsyncRingBuffer(
this.#stderrBuffer,
(...data: unknown[]) => console.log('Stdio interface: Stderr:', ...data),
options.debug
);

this.stdin = new WritableStream<Uint8Array>({
write(chunk) {
stdinRing.write(chunk);
},
});

this.stdout = new ReadableStream<Uint8Array>(
{
async pull(controller) {
const ring = await stdoutRingPromise;
const data = await ring.read(controller.desiredSize ?? undefined);

controller.enqueue(data);
},
},
{
highWaterMark: 1024 * 1024,
size(chunk) {
return chunk.length;
},
}
);

this.stderr = new ReadableStream<Uint8Array>(
{
async pull(controller) {
const ring = await stderrRingPromise;
const data = await ring.read(controller.desiredSize ?? undefined);

controller.enqueue(data);
},
},
{
highWaterMark: 1024 * 1024,
size(chunk) {
return chunk.length;
},
}
);
}
}
4 changes: 4 additions & 0 deletions packages/c2w/src/container/vm-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { expose } from 'comlink';
import { VM } from './vm.js';

expose(VM);
Loading