diff --git a/docs/src/introduction.md b/docs/src/introduction.md index 090da62ae..edb5a0567 100644 --- a/docs/src/introduction.md +++ b/docs/src/introduction.md @@ -5,7 +5,7 @@ ## Features - [Transpiling](./transpiling.md) Wasm Component binaries into [ECMAScript modules](https://nodejs.org/api/esm.html#modules-ecmascript-modules) that can run in any JavaScript environment. -- WASI Preview2 support in Node.js & browsers (experimental). +- WASI Preview2 support in Node.js & browsers. - Component builds of Wasm Tools helpers, available for use as a library or CLI commands for use in native JS environments - Optimization helper for Components via Binaryen. - `componentize` command to easily create components written in JavaScript (wrapper of [ComponentizeJS](https://github.com/bytecodealliance/ComponentizeJS)). diff --git a/docs/src/transpiling.md b/docs/src/transpiling.md index bcef19cf3..b94cf224c 100644 --- a/docs/src/transpiling.md +++ b/docs/src/transpiling.md @@ -179,7 +179,8 @@ For all subsystems - `cli`, `clocks`, `filesystem`, `http`, `io`, `random` and ` To disable this automatic WASI handling the `--no-wasi-shim` flag can be provided and WASI will be treated like any other import without special handling. -Note that browser support for WASI is currently experimental. +Browser WASI support is subject to web-platform capability limitations; some interfaces require +application-provided adapters. ### Interface Implementation Example diff --git a/examples/components/fs-write-file/README.md b/examples/components/fs-write-file/README.md index 525fc2ef7..2f5dae439 100644 --- a/examples/components/fs-write-file/README.md +++ b/examples/components/fs-write-file/README.md @@ -29,7 +29,7 @@ pnpm install At this point, since this project is *just* NodeJS, you could use the module from any NodeJS project or browser project where appropriate. That said, we'll be focusing on building the JS code we've written so far into a WebAssembly binary, which can run *anywhere* -WebAssembly runtimes are supported, including in other languages, and the browser (experimental support). +WebAssembly runtimes are supported, including in other languages and the browser. ## Building the WebAssembly component diff --git a/examples/transpile/README.md b/examples/transpile/README.md index 5dbb2a095..424ab6ef5 100644 --- a/examples/transpile/README.md +++ b/examples/transpile/README.md @@ -4,9 +4,6 @@ This folder contains examples of how to use `@bytecodealliance/jco-transpile` di converting a WebAssembly component into a Javascript ES module that can be run from JS runtimes like [NodeJS][nodejs] and the browser. -> [!WARNING] -> Browser support is still experimental - Most (if not all) individual example projects are standard Javascript projects, and since we are focused on transpiling existing components, they may contain a pre-built WebAssembly binary that is transpiled. diff --git a/packages/jco-std/README.md b/packages/jco-std/README.md index 9dbe2b5de..7b5c4c132 100644 --- a/packages/jco-std/README.md +++ b/packages/jco-std/README.md @@ -9,9 +9,6 @@ an evolving architecture for interoperabl WebAssembly libraries, aplications and WebAssembly components can be used from server side applications _and_ in the browser, and `@bytecodealliance/jco-std` contains shared functionality and helpers for both environments. -> [!WARNING] -> Browser support is considered experimental, and not currently suitable for production applications. - [cm-book]: https://component-model.bytecodealliance.org/ [jco]: https://www.npmjs.com/package/@bytecodealliance/jco diff --git a/packages/jco-transpile/README.md b/packages/jco-transpile/README.md index d73e03569..7840ce497 100644 --- a/packages/jco-transpile/README.md +++ b/packages/jco-transpile/README.md @@ -1,14 +1,11 @@ # `@bytecodealliance/jco-transpile` This [`@bytecodealliance/jco`][jco] sub-project enables transpilation of [WebAssembly Components][cm-book] into ES modules -that can be run in Javascript environments like NodeJS and the browser (experimental). +that can be run in Javascript environments like NodeJS and the browser. `@bytecodealliance/jco-transpile` is used primarily when only transpilation functionality of `jco` is needed, and `jco` derives it's use of transpilation from this library. -> [!WARNING] -> Browser support is considered experimental, and not currently suitable for production applications. - [cm-book]: https://component-model.bytecodealliance.org/ [jco]: https://www.npmjs.com/package/@bytecodealliance/jco diff --git a/packages/jco/README.md b/packages/jco/README.md index 952266c26..fc792f9d1 100644 --- a/packages/jco/README.md +++ b/packages/jco/README.md @@ -25,7 +25,7 @@ Jco provides a fully native JS toolchain for working with [WebAssembly Component Features include: - "Transpiling" Wasm Component binaries into ES modules that can run in any JS environment. -- WASI Preview2 support in Node.js & browsers (experimental). +- WASI Preview2 support in Node.js & browsers. - Component builds of [Wasm Tools](https://github.com/bytecodealliance/wasm-tools) helpers, available for use as a library or CLI commands for use in native JS environments, as well as optimization helper for Components via Binaryen. - Run and serve commands like Wasmtime, as JS implementations of the Command and HTTP Proxy worlds. - "Componentize" command to easily create components written in JavaScript (wrapper of [ComponentizeJS](https://github.com/bytecodealliance/ComponentizeJS)). diff --git a/packages/preview2-shim/README.md b/packages/preview2-shim/README.md index 17fd1d4c9..a8842bf0d 100644 --- a/packages/preview2-shim/README.md +++ b/packages/preview2-shim/README.md @@ -4,12 +4,74 @@ WASI Preview2 implementations for Node.js & browsers. Node.js support is fully tested and conformant against the Wasmtime test suite. -Browser support is considered experimental, and not currently suitable for production applications. +Browser support is available with the platform limitations documented below. The Node.js implementation owns its worker artifact. Direct package use and supported downstream bundlers should resolve it through the public shim imports; applications do not need to import or copy files from `dist/io`. +## Browser support matrix + +Browser defaults are capability-safe: clocks and secure randomness use Web APIs, stdout and stderr +write to the console, stdin is closed, outbound HTTP uses `fetch`, filesystem preopens must be +configured explicitly, and raw sockets are unavailable unless an embedding supplies an adapter. + +| WASI area | Browser status | Default capability | +| ----------------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------- | +| CLI environment and arguments | Configurable per `WASIShim`; compatibility setters are global | Empty snapshots unless configured | +| CLI stdin | Adapter-backed | Closed stream | +| CLI stdout and stderr | Web API | Console-backed, preserving split UTF-8 writes until flush/newline | +| CLI terminals | Adapter-backed | No terminal resource | +| Clocks | Web API | `performance.now`, `Date.now`, and timer-backed pollables | +| Random | Web API | `crypto.getRandomValues`, including requests larger than 64 KiB | +| I/O streams and poll | Implemented browser resources | Non-blocking streams depend on their injected handlers | +| Filesystem | Adapter-backed; opt-in in-memory compatibility implementation | No persistent storage is selected implicitly | +| Outbound HTTP | Web API | Delegates to `fetch` | +| Incoming HTTP | Host adapter required | Browsers cannot listen for arbitrary inbound HTTP | +| TCP, UDP, and DNS | Host adapter required | Raw sockets are not exposed by standard browsers | +| `WASIShim` instantiation | Implemented | Interface namespaces can be overridden per instance | + +An operation is not considered supported merely because its interface shape exists. Adapter-backed +rows require the embedding application to provide that capability; unavailable operations fail with +a WASI-domain error instead of logging or returning a placeholder resource. + +Browser applications select storage explicitly. The bundled file-data adapter is ephemeral and must +be opted into: + +```js +import { filesystem } from "@bytecodealliance/preview2-shim"; +import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; + +const shim = new WASIShim({ + environment: { MODE: "browser" }, + arguments: ["component"], + stdout: { write: (bytes) => terminal.write(bytes) }, + browserFilesystem: { + adapter: new filesystem.InMemoryFilesystemAdapter(), + preopens: { "/data": { dir: {} } }, + }, + sandbox: { enableNetwork: false }, +}); +``` + +The browser shim does not request File System Access permissions or choose IndexedDB/OPFS on an +application's behalf. Applications that need another storage model implement the generated +`wasi:filesystem/types` and `wasi:filesystem/preopens` namespaces and inject them through the +`filesystem` option: + +```js +const shim = new WASIShim({ + filesystem: { + types: applicationFilesystemTypes, + preopens: applicationFilesystemPreopens, + }, +}); +``` + +This keeps permission prompts, handle acquisition, persistence, and synchronization policy in +application code. Raw TCP, UDP, and DNS are denied by default; outbound HTTP remains a separate +`fetch` capability. + # Features ## WASI Shim object for easy instantiation @@ -57,9 +119,9 @@ const component = await instantiate(loader, new WASIShim().getImportObject()); ## Sandboxing -By default, the preview2-shim provides full access to the host filesystem, environment variables, -and network - matching the default behavior of Node.js libraries. However, you can configure -sandboxing to restrict what guests can access. +On Node.js, the preview2-shim provides host filesystem, environment, and network access by default, +matching the usual behavior of Node.js libraries. Browser defaults expose no filesystem preopens or +raw sockets. Both platforms can configure which capabilities a guest receives. ### Using WASIShim for sandboxing @@ -78,7 +140,7 @@ const sandboxedShim = new WASIShim({ }, }); -// Limited filesystem access - map virtual paths to host paths +// Node.js only: map virtual paths to host paths const limitedShim = new WASIShim({ sandbox: { preopens: { @@ -95,7 +157,10 @@ const component = await instantiate(loader, sandboxedShim.getImportObject()); ### Notes on sandboxing - By default (when no options are passed), the shim is providing full access to match typical - Node.js library behavior. + Node.js library behavior. In browsers, filesystem preopens remain empty until the application + explicitly injects filesystem namespaces or selects the ephemeral file-data adapter. +- `sandbox.preopens` maps guest paths to Node.js host paths. Browser applications use the + `filesystem` or `browserFilesystem` options shown above; host paths are rejected in browsers. - Each `WASIShim` instance has its own isolated preopens, environment variables, and arguments. Multiple instances with different configurations will not affect each other. - The direct preopen functions (`_setPreopens`, `_clearPreopens`, etc.) modify global state and diff --git a/packages/preview2-shim/src/browser/cli.ts b/packages/preview2-shim/src/browser/cli.ts index 5f191ad9d..b050aa336 100644 --- a/packages/preview2-shim/src/browser/cli.ts +++ b/packages/preview2-shim/src/browser/cli.ts @@ -1,4 +1,5 @@ import type { + environment as EnvironmentNamespace, exit as ExitNamespace, stderr as StderrNamespace, stdin as StdinNamespace, @@ -52,45 +53,61 @@ export function _setStdout(handler: OutputStreamHandler): void { stdoutStream.handler = handler; } +export interface BrowserCliConfig { + environment?: Record; + arguments?: string[]; + initialCwd?: string; + stdin?: InputStreamHandler; + stdout?: OutputStreamHandler; + stderr?: OutputStreamHandler; +} + const stdinStream = inputStreamCreate({ - blockingRead(_len: bigint) { - // TODO - return new Uint8Array(0); + blockingRead() { + throw { tag: "closed" }; }, subscribe() { - // TODO return pollableCreate(); }, - [symbolDispose]() { - // TODO - }, + [symbolDispose]() {}, }); -const textDecoder = new TextDecoder(); +function consoleStream(writeLine: (line: string) => void): OutputStreamHandler { + const decoder = new TextDecoder(); + let pending = ""; -const stdoutStream = outputStreamCreate({ - write(contents: Uint8Array): void { - if (contents.at(-1) == 10) { - // console.log already appends a new line - contents = contents.subarray(0, -1); + const emitCompleteLines = () => { + const lines = pending.split("\n"); + pending = lines.pop()!; + for (const line of lines) { + writeLine(line.endsWith("\r") ? line.slice(0, -1) : line); } - console.log(textDecoder.decode(contents)); - }, - blockingFlush() {}, - [symbolDispose]() {}, -}); + }; + + return { + write(contents: Uint8Array) { + pending += decoder.decode(contents, { stream: true }); + emitCompleteLines(); + }, + flush() { + pending += decoder.decode(); + if (pending) { + writeLine(pending); + } + pending = ""; + }, + blockingFlush() { + this.flush?.(); + }, + drop() { + this.flush?.(); + }, + }; +} -const stderrStream = outputStreamCreate({ - write(contents: Uint8Array): void { - if (contents.at(-1) == 10) { - // console.error already appends a new line - contents = contents.subarray(0, -1); - } - console.error(textDecoder.decode(contents)); - }, - blockingFlush() {}, - [symbolDispose]() {}, -}); +const stdoutStream = outputStreamCreate(consoleStream((line) => console.log(line))); + +const stderrStream = outputStreamCreate(consoleStream((line) => console.error(line))); export const stdin: typeof StdinNamespace = { getStdin() { @@ -113,10 +130,6 @@ export const stderr: typeof StderrNamespace = { class TerminalInput implements TerminalInputNamespace.TerminalInput {} class TerminalOutput implements TerminalOutputNamespace.TerminalOutput {} -const terminalStdoutInstance = new TerminalOutput(); -const terminalStderrInstance = new TerminalOutput(); -const terminalStdinInstance = new TerminalInput(); - export const terminalInput: typeof TerminalInputNamespace = { TerminalInput, }; @@ -127,18 +140,67 @@ export const terminalOutput: typeof TerminalOutputNamespace = { export const terminalStderr: typeof TerminalStderrNamespace = { getTerminalStderr() { - return terminalStderrInstance; + return undefined; }, }; export const terminalStdin: typeof TerminalStdinNamespace = { getTerminalStdin() { - return terminalStdinInstance; + return undefined; }, }; export const terminalStdout: typeof TerminalStdoutNamespace = { getTerminalStdout() { - return terminalStdoutInstance; + return undefined; }, }; + +/** Create isolated browser CLI interfaces without changing compatibility globals. */ +export function createCli(config: BrowserCliConfig = {}): { + environment: typeof EnvironmentNamespace; + exit: typeof ExitNamespace; + stdin: typeof StdinNamespace; + stdout: typeof StdoutNamespace; + stderr: typeof StderrNamespace; + terminalInput: typeof TerminalInputNamespace; + terminalOutput: typeof TerminalOutputNamespace; + terminalStdin: typeof TerminalStdinNamespace; + terminalStdout: typeof TerminalStdoutNamespace; + terminalStderr: typeof TerminalStderrNamespace; +} { + const stdinInstance = inputStreamCreate( + config.stdin ?? { + blockingRead() { + throw { tag: "closed" }; + }, + subscribe: () => pollableCreate(), + }, + ); + const stdoutInstance = outputStreamCreate( + config.stdout ?? consoleStream((line) => console.log(line)), + ); + const stderrInstance = outputStreamCreate( + config.stderr ?? consoleStream((line) => console.error(line)), + ); + const env = Object.entries(config.environment ?? {}); + const args = [...(config.arguments ?? [])]; + const cwd = config.initialCwd ?? "/"; + + return { + environment: { + getEnvironment: () => env.map(([key, value]) => [key, value] as [string, string]), + getArguments: () => [...args], + initialCwd: () => cwd, + }, + exit, + stdin: { getStdin: () => stdinInstance }, + stdout: { getStdout: () => stdoutInstance }, + stderr: { getStderr: () => stderrInstance }, + terminalInput, + terminalOutput, + terminalStdin, + terminalStdout, + terminalStderr, + }; +} diff --git a/packages/preview2-shim/src/browser/clocks.ts b/packages/preview2-shim/src/browser/clocks.ts index d8128cb9e..59737cf5b 100644 --- a/packages/preview2-shim/src/browser/clocks.ts +++ b/packages/preview2-shim/src/browser/clocks.ts @@ -4,6 +4,32 @@ import type { } from "../../types/clocks.js"; import { pollableCreate } from "./io.js"; +const MAX_TIMEOUT_MS = 0x7fffffff; +const MAX_U64 = (1n << 64n) - 1n; + +function checkedInstant(value: bigint, name: string): bigint { + if (typeof value !== "bigint" || value < 0n || value > MAX_U64) { + throw new TypeError(`${name} must be a valid u64`); + } + return value; +} + +function timeout(durationNs: bigint): Promise { + let remainingMs = Number((durationNs + 999_999n) / 1_000_000n); + return new Promise((resolve) => { + const next = () => { + if (remainingMs <= 0) { + resolve(); + return; + } + const delay = Math.min(remainingMs, MAX_TIMEOUT_MS); + remainingMs -= delay; + setTimeout(next, delay); + }; + next(); + }); +} + export const monotonicClock: typeof MonotonicClockNamespace = { resolution(): bigint { // usually we dont get sub-millisecond accuracy in the browser @@ -15,17 +41,19 @@ export const monotonicClock: typeof MonotonicClockNamespace = { return BigInt(Math.floor(performance.now() * 1e6)); }, subscribeInstant(instant: bigint) { - instant = BigInt(instant); + instant = checkedInstant(instant, "instant"); const now = monotonicClock.now(); if (instant <= now) { - return pollableCreate(new Promise((resolve) => setTimeout(resolve, 0))); + return pollableCreate(); } return monotonicClock.subscribeDuration(instant - now); }, subscribeDuration(duration: bigint) { - duration = BigInt(duration); - const ms = duration <= 0n ? 0 : Number(duration / 1_000_000n); - return pollableCreate(new Promise((resolve) => setTimeout(resolve, ms))); + duration = checkedInstant(duration, "duration"); + if (duration === 0n) { + return pollableCreate(); + } + return pollableCreate(timeout(duration)); }, }; diff --git a/packages/preview2-shim/src/browser/filesystem.ts b/packages/preview2-shim/src/browser/filesystem.ts index 7095c5199..6a8b0c843 100644 --- a/packages/preview2-shim/src/browser/filesystem.ts +++ b/packages/preview2-shim/src/browser/filesystem.ts @@ -34,9 +34,34 @@ export interface FileDataEntry { */ export type FileData = FileDataEntry; +export interface BrowserFilesystemAdapter { + getRoot(capability: Capability): FileData; + dispose?(): void; +} + +export interface BrowserFilesystemConfig { + adapter: BrowserFilesystemAdapter; + preopens: Record; +} + +/** Explicit ephemeral storage adapter for browser applications and tests. */ +export class InMemoryFilesystemAdapter implements BrowserFilesystemAdapter { + getRoot(capability: FileData): FileData { + if (!capability.dir) { + throw new TypeError("an in-memory preopen root must be a directory"); + } + return capability; + } +} + export function _setFileData(fileData: FileData): void { _fileData = fileData; - _rootPreopen![0] = descriptorCreate(fileData); + if (_rootPreopen) { + const descriptor = descriptorCreate(fileData); + _rootPreopen[0] = descriptor; + } else { + _setPreopens({ "/": fileData }); + } const cwd = environment.initialCwd(); _setCwd(cwd || "/"); } @@ -106,6 +131,29 @@ function getChildEntry( return entry; } +function getParentEntry(root: FileDataEntry, path: string): [FileDataEntry, string] { + const segments = path.split("/").filter((segment) => segment !== "" && segment !== "."); + if (segments.length === 0 || segments.some((segment) => segment === "..")) { + throw "invalid"; + } + const name = segments.pop()!; + let parent = root; + for (const segment of segments) { + const child = parent.dir?.[segment]; + if (!child) { + throw "no-entry"; + } + if (!child.dir) { + throw "not-directory"; + } + parent = child; + } + if (!parent.dir) { + throw "not-directory"; + } + return [parent, name]; +} + function getSource(fileEntry: FileDataEntry): Uint8Array { if (typeof fileEntry.source === "string") { fileEntry.source = new TextEncoder().encode(fileEntry.source); @@ -166,6 +214,11 @@ class Descriptor implements TypesNamespace.Descriptor { #stream: any; #entry!: FileDataEntry; #mtime = 0; + #flags: TypesNamespace.DescriptorFlags = { + read: true, + write: true, + mutateDirectory: true, + }; _getEntry(descriptor: Descriptor): FileDataEntry { return descriptor.#entry; @@ -221,21 +274,19 @@ class Descriptor implements TypesNamespace.Descriptor { } appendViaStream() { - console.log(`[filesystem] APPEND STREAM`); - return {} as IOutputStream; + return this.writeViaStream(this.stat().size); } - advise(offset: Filesize, length: Filesize, advice: TypesNamespace.Advice) { - console.log(`[filesystem] ADVISE`, offset, length, advice); + advise(_offset: Filesize, _length: Filesize, _advice: TypesNamespace.Advice) { + if (this.getType() === "directory") { + throw "bad-descriptor"; + } } - syncData() { - console.log(`[filesystem] SYNC DATA`); - } + syncData() {} getFlags() { - console.log(`[filesystem] FLAGS FOR`); - return {} as TypesNamespace.DescriptorFlags; + return { ...this.#flags }; } getType() { @@ -252,11 +303,21 @@ class Descriptor implements TypesNamespace.Descriptor { } setSize(size: bigint) { - console.log(`[filesystem] SET SIZE`, size); + if (this.getType() === "directory") { + throw "is-directory"; + } + const length = coerceToSafeIntegerNumber(size); + const source = getSource(this.#entry); + const resized = new Uint8Array(length); + resized.set(source.subarray(0, length)); + this.#entry.source = resized; + this.#mtime++; } - setTimes(dataAccessTimestamp: any, dataModificationTimestamp: any) { - console.log(`[filesystem] SET TIMES`, dataAccessTimestamp, dataModificationTimestamp); + setTimes(_dataAccessTimestamp: any, dataModificationTimestamp: any) { + if (dataModificationTimestamp?.tag !== "no-change") { + this.#mtime++; + } } read(length: bigint, offset: bigint) { @@ -271,10 +332,20 @@ class Descriptor implements TypesNamespace.Descriptor { } write(buffer: Uint8Array, offset: Filesize) { - if (offset !== 0n) { - throw "invalid-seek"; + if (this.getType() === "directory") { + throw "is-directory"; } - this.#entry.source = buffer; + const off = coerceToSafeIntegerNumber(offset); + const source = getSource(this.#entry); + const end = off + buffer.byteLength; + if (!Number.isSafeInteger(end)) { + throw "file-too-large"; + } + const target = new Uint8Array(Math.max(source.byteLength, end)); + target.set(source); + target.set(buffer, off); + this.#entry.source = target; + this.#mtime++; return BigInt(buffer.byteLength); } @@ -287,9 +358,7 @@ class Descriptor implements TypesNamespace.Descriptor { ); } - sync() { - console.log(`[filesystem] SYNC`); - } + sync() {} createDirectoryAt(path: string) { const entry = getChildEntry(this.#entry, path, { @@ -345,12 +414,34 @@ class Descriptor implements TypesNamespace.Descriptor { }; } - setTimesAt() { - console.log(`[filesystem] SET TIMES AT`); + setTimesAt(_pathFlags: PathFlags, path: string, _atime: any, mtime: any) { + const entry = getChildEntry(this.#entry, path, { create: false, directory: false }); + if (mtime?.tag !== "no-change") { + // Metadata is currently descriptor-local; touching the entry makes + // the mutation visible through metadata hashes on newly opened handles. + fileWriteBuffers.delete(entry); + this.#mtime++; + } } - linkAt() { - console.log(`[filesystem] LINK AT`); + linkAt( + _pathFlags: PathFlags, + oldPath: string, + newDescriptor: TypesNamespace.Descriptor, + newPath: string, + ) { + const entry = getChildEntry(this.#entry, oldPath, { create: false, directory: false }); + if (entry.dir) { + throw "not-permitted"; + } + const [newParent, newName] = getParentEntry( + descriptorGetEntry(newDescriptor as Descriptor), + newPath, + ); + if (newParent.dir![newName]) { + throw "exist"; + } + newParent.dir![newName] = entry; } openAt( @@ -359,29 +450,80 @@ class Descriptor implements TypesNamespace.Descriptor { openFlags: OpenFlags, _flags: TypesNamespace.DescriptorFlags, ) { - const childEntry = getChildEntry(this.#entry, path, openFlags); + let childEntry: FileDataEntry; + try { + childEntry = getChildEntry(this.#entry, path, { + create: false, + directory: false, + }); + if (openFlags.create && openFlags.exclusive) { + throw "exist"; + } + } catch (error) { + if (error !== "no-entry" || !openFlags.create) { + throw error; + } + childEntry = getChildEntry(this.#entry, path, openFlags); + } + if (openFlags.directory && !childEntry.dir) { + throw "not-directory"; + } + if (openFlags.truncate) { + if (childEntry.dir) { + throw "is-directory"; + } + childEntry.source = new Uint8Array(); + } return descriptorCreate(childEntry); } - readlinkAt(_path: string) { - console.log(`[filesystem] READLINK AT`); - return ""; + readlinkAt(_path: string): string { + throw "unsupported"; } - removeDirectoryAt() { - console.log(`[filesystem] REMOVE DIR AT`); + removeDirectoryAt(path: string) { + const [parent, name] = getParentEntry(this.#entry, path); + const entry = parent.dir?.[name]; + if (!entry) { + throw "no-entry"; + } + if (!entry.dir) { + throw "not-directory"; + } + if (Object.keys(entry.dir).length) { + throw "not-empty"; + } + delete parent.dir![name]; } - renameAt() { - console.log(`[filesystem] RENAME AT`); + renameAt(oldPath: string, newDescriptor: TypesNamespace.Descriptor, newPath: string) { + const [oldParent, oldName] = getParentEntry(this.#entry, oldPath); + const entry = oldParent.dir?.[oldName]; + if (!entry) { + throw "no-entry"; + } + const [newParent, newName] = getParentEntry( + descriptorGetEntry(newDescriptor as Descriptor), + newPath, + ); + newParent.dir![newName] = entry; + delete oldParent.dir![oldName]; } symlinkAt() { - console.log(`[filesystem] SYMLINK AT`); + throw "unsupported"; } - unlinkFileAt() { - console.log(`[filesystem] UNLINK FILE AT`); + unlinkFileAt(path: string) { + const [parent, name] = getParentEntry(this.#entry, path); + const entry = parent.dir?.[name]; + if (!entry) { + throw "no-entry"; + } + if (entry.dir) { + throw "is-directory"; + } + delete parent.dir![name]; } isSameObject(other: TypesNamespace.Descriptor) { @@ -406,8 +548,8 @@ const descriptorCreate = Descriptor._create; // @ts-expect-error - Deleting static method delete Descriptor._create; -let _preopens: [Descriptor, string][] = [[descriptorCreate(_fileData), "/"]]; -let _rootPreopen: [Descriptor, string] | null = _preopens[0]; +let _preopens: [Descriptor, string][] = []; +let _rootPreopen: [Descriptor, string] | null = null; export const preopens: typeof PreopensNamespace = { getDirectories() { @@ -415,6 +557,35 @@ export const preopens: typeof PreopensNamespace = { }, }; +/** Create isolated filesystem namespaces backed by an application-selected adapter. */ +export function createFilesystem({ + adapter, + preopens: configuredPreopens, +}: BrowserFilesystemConfig) { + const entries: [Descriptor, string][] = Object.entries(configuredPreopens).map( + ([guestPath, capability]) => [descriptorCreate(adapter.getRoot(capability)), guestPath], + ); + let disposed = false; + return { + types, + preopens: { + getDirectories() { + if (disposed) { + throw new Error("filesystem adapter has been disposed"); + } + return [...entries]; + }, + } as typeof PreopensNamespace, + dispose() { + if (disposed) { + return; + } + disposed = true; + adapter.dispose?.(); + }, + }; +} + /** * Replace all preopens with the given set. * @param preopensConfig - Map of virtual paths to file data entries @@ -433,9 +604,10 @@ export function _setPreopens(preopensConfig: Record): void { */ export function _addPreopen(virtualPath: string, fileData: FileData): void { const descriptor = descriptorCreate(fileData); - _preopens.push([descriptor, virtualPath]); + const entry: [Descriptor, string] = [descriptor, virtualPath]; + _preopens.push(entry); if (virtualPath === "/") { - _rootPreopen = [descriptor, virtualPath]; + _rootPreopen = entry; } } @@ -465,10 +637,9 @@ export function _getPreopens(): [Descriptor, string][] { * @returns A preopen descriptor */ export function _createPreopenDescriptor(hostPreopen: string) { - _fileData.dir = { - [hostPreopen]: {}, - }; - return descriptorCreate(_fileData); + throw new TypeError( + `browser preopen ${JSON.stringify(hostPreopen)} is a host path; configure browser file data or an adapter instead`, + ); } export const types: typeof TypesNamespace = { diff --git a/packages/preview2-shim/src/browser/http.ts b/packages/preview2-shim/src/browser/http.ts index 32c848a64..ad587483a 100644 --- a/packages/preview2-shim/src/browser/http.ts +++ b/packages/preview2-shim/src/browser/http.ts @@ -5,7 +5,7 @@ import type { } from "../../types/http.js"; import type { Error as IoError } from "../../types/interfaces/wasi-io-error.js"; import type { Pollable } from "../../types/interfaces/wasi-io-poll.js"; -import { inputStreamCreate, outputStreamCreate, pollableCreate } from "./io.js"; +import { inputStreamCreate, ioErrorCreate, outputStreamCreate, pollableCreate } from "./io.js"; type Result = TypesNamespace.Result; @@ -425,6 +425,7 @@ class IncomingBody implements TypesNamespace.IncomingBody { let done = false; let reader: ReadableStreamDefaultReader | null = null; let readPromise: Promise | null = null; + let readError: IoError | null = null; function ensureReader() { if (!reader && fetchResponse.body) { @@ -451,15 +452,25 @@ class IncomingBody implements TypesNamespace.IncomingBody { bufferOffset = 0; } }, - () => { + (cause) => { readPromise = null; done = true; + readError = ioErrorCreate( + cause instanceof Error ? cause.message : String(cause), + ); }, ); } + function checkReadError() { + if (readError) { + throw { tag: "last-operation-failed", val: readError }; + } + } + incomingBody.#stream = inputStreamCreate({ read(len: bigint) { + checkReadError(); if (done && (buffer === null || bufferOffset >= buffer.byteLength)) { throw { tag: "closed" }; } @@ -480,6 +491,7 @@ class IncomingBody implements TypesNamespace.IncomingBody { throw { tag: "would-block" }; }, blockingRead(len: bigint): any { + checkReadError(); if (done && (buffer === null || bufferOffset >= buffer.byteLength)) { throw { tag: "closed" }; } @@ -500,6 +512,7 @@ class IncomingBody implements TypesNamespace.IncomingBody { startRead(); const waitFor = readPromise || Promise.resolve(); return waitFor.then(() => { + checkReadError(); if (done && (buffer === null || bufferOffset >= buffer.byteLength)) { throw { tag: "closed" }; } @@ -521,14 +534,20 @@ class IncomingBody implements TypesNamespace.IncomingBody { }); }, subscribe() { - if (done || (buffer !== null && bufferOffset < buffer.byteLength)) { - return pollableCreate(); - } - startRead(); - if (readPromise) { - return pollableCreate(readPromise); - } - return pollableCreate(); + return pollableCreate({ + ready: () => + readError !== null || + done || + (buffer !== null && bufferOffset < buffer.byteLength), + wait: () => { + startRead(); + return readPromise ?? Promise.resolve(); + }, + }); + }, + drop() { + done = true; + void reader?.cancel(); }, }); @@ -582,6 +601,136 @@ const incomingResponseCreate = IncomingResponse._create; // @ts-expect-error - Deleting static method delete IncomingResponse._create; +class IncomingRequest implements TypesNamespace.IncomingRequest { + #request!: Request; + #headers!: Fields; + #body: IncomingBody | undefined; + + method(): TypesNamespace.Method { + const method = this.#request.method.toLowerCase(); + return { tag: method } as TypesNamespace.Method; + } + pathWithQuery() { + const url = new URL(this.#request.url); + return `${url.pathname}${url.search}`; + } + scheme(): TypesNamespace.Scheme { + const protocol = new URL(this.#request.url).protocol; + if (protocol === "http:") { + return { tag: "HTTP" }; + } + if (protocol === "https:") { + return { tag: "HTTPS" }; + } + return { tag: "other", val: protocol.slice(0, -1) }; + } + authority() { + return new URL(this.#request.url).host; + } + headers() { + return this.#headers; + } + consume() { + if (!this.#body) { + throw new Error("incoming request body already consumed"); + } + const body = this.#body; + this.#body = undefined; + return body; + } + static _create(request: Request) { + const incoming = new IncomingRequest(); + incoming.#request = request; + const encoder = new TextEncoder(); + incoming.#headers = fieldsLock( + fieldsFromEntriesChecked( + [...request.headers.entries()].map(([name, value]) => [ + name, + encoder.encode(value), + ]), + ), + ); + incoming.#body = incomingBodyCreate(new Response(request.body)); + return incoming; + } +} +const incomingRequestCreate = IncomingRequest._create; +// @ts-expect-error - Deleting static method +delete IncomingRequest._create; + +class OutgoingResponse implements TypesNamespace.OutgoingResponse { + #headers: Fields; + #status = 200; + #body = outgoingBodyCreate(); + #bodyRequested = false; + + constructor(headers: Fields) { + fieldsLock(headers); + this.#headers = headers; + } + statusCode() { + return this.#status; + } + setStatusCode(statusCode: number) { + if (!Number.isInteger(statusCode) || statusCode < 100 || statusCode > 999) { + throw new TypeError("invalid HTTP status code"); + } + this.#status = statusCode; + } + headers() { + return this.#headers; + } + body() { + if (this.#bodyRequested) { + throw new Error("outgoing response body already requested"); + } + this.#bodyRequested = true; + return this.#body; + } + static _toResponse(response: OutgoingResponse) { + const headers = new Headers(); + for (const [name, value] of response.#headers.entries()) { + headers.append(name, utf8Decoder.decode(value)); + } + return new Response(outgoingBodyData(response.#body) as BodyInit | null, { + status: response.#status, + headers, + }); + } +} +const outgoingResponseToResponse = OutgoingResponse._toResponse; +// @ts-expect-error - Deleting static method +delete OutgoingResponse._toResponse; + +class ResponseOutparam implements TypesNamespace.ResponseOutparam { + #used = false; + #resolve!: (response: Response) => void; + + static set( + param: ResponseOutparam, + response: Result, + ) { + if (param.#used) { + throw new Error("response outparam already set"); + } + param.#used = true; + if (response.tag === "ok") { + param.#resolve(outgoingResponseToResponse(response.val as OutgoingResponse)); + } else { + param.#resolve(new Response("WASI HTTP handler error", { status: 500 })); + } + } + + static _create(): [ResponseOutparam, Promise] { + const param = new ResponseOutparam(); + const response = new Promise((resolve) => (param.#resolve = resolve)); + return [param, response]; + } +} +const responseOutparamCreate = ResponseOutparam._create; +// @ts-expect-error - Deleting static method +delete ResponseOutparam._create; + class FutureTrailers implements TypesNamespace.FutureTrailers { #requested = false; @@ -617,15 +766,13 @@ function mapFetchError(err: Error) { if (err.name === "AbortError") { return { tag: "connection-timeout" }; } - if (err.name === "TypeError") { - return { tag: "connection-refused" }; - } return { tag: "internal-error", val: err.message }; } class FutureIncomingResponse implements TypesNamespace.FutureIncomingResponse { #result: any = undefined; #promise: Promise | null = null; + #controller: AbortController | null = null; subscribe(): Pollable { return pollableCreate(this.#promise!); @@ -641,6 +788,8 @@ class FutureIncomingResponse implements TypesNamespace.FutureIncomingResponse { } [symbolDispose]() { + this.#controller?.abort(); + this.#controller = null; this.#promise = null; } @@ -654,6 +803,7 @@ class FutureIncomingResponse implements TypesNamespace.FutureIncomingResponse { const future = new FutureIncomingResponse(); const controller = new AbortController(); + future.#controller = controller; let timer: ReturnType | undefined; if (timeoutMs < Infinity) { timer = setTimeout(() => controller.abort(), timeoutMs); @@ -733,24 +883,37 @@ export const outgoingHandler: typeof OutgoingHandlerNamespace = { }; export const incomingHandler: typeof IncomingHandlerNamespace = { - // Not implemented - handle() {}, + handle() { + throw "not-supported"; + }, }; +export type BrowserIncomingHandler = ( + request: TypesNamespace.IncomingRequest, + responseOut: TypesNamespace.ResponseOutparam, +) => void | Promise; + +/** Translate a browser Request through a host-provided WASI incoming handler. */ +export async function handleIncomingRequest( + request: Request, + handler: BrowserIncomingHandler, +): Promise { + const [responseOut, response] = responseOutparamCreate(); + await handler(incomingRequestCreate(request), responseOut); + return response; +} + export const types: typeof TypesNamespace = { Fields, FutureIncomingResponse, FutureTrailers, IncomingBody, - // @ts-expect-error Not implemented - IncomingRequest: class IncomingRequest {}, + IncomingRequest, IncomingResponse, OutgoingBody, OutgoingRequest, - // @ts-expect-error Not implemented - OutgoingResponse: class OutgoingResponse {}, - // @ts-expect-error Not implemented - ResponseOutparam: class ResponseOutparam {}, + OutgoingResponse, + ResponseOutparam, RequestOptions, httpErrorCode, }; diff --git a/packages/preview2-shim/src/browser/io.ts b/packages/preview2-shim/src/browser/io.ts index bcb0e7427..6784655bd 100644 --- a/packages/preview2-shim/src/browser/io.ts +++ b/packages/preview2-shim/src/browser/io.ts @@ -6,6 +6,8 @@ import type { let id = 0; +const MAX_U64 = (1n << 64n) - 1n; + const symbolDispose = Symbol.dispose || Symbol.for("dispose"); type IInputStream = StreamsNamespace.InputStream; @@ -19,6 +21,25 @@ export type InputStreamHandler = Partial & drop?: () => void; }; +export interface PollableSource { + ready(): boolean; + wait(): Promise; +} + +function checkedLength(len: bigint, name = "length"): number { + if (typeof len !== "bigint" || len < 0n || len > MAX_U64) { + throw new TypeError(`${name} must be a valid u64`); + } + if (len > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new RangeError(`${name} exceeds JavaScript's safe integer range`); + } + return Number(len); +} + +function closed(): never { + throw { tag: "closed" } satisfies StreamsNamespace.StreamError; +} + /** * Handler interface for creating custom output streams */ @@ -33,9 +54,13 @@ class IoError extends Error implements ErrorNamespace.Error { } } +export const ioErrorCreate = (message: string): ErrorNamespace.Error => new IoError(message); + class InputStream implements IInputStream { id!: number; handler!: InputStreamHandler; + #open = true; + #children = new Set(); static _create(handler: InputStreamHandler) { const stream = new InputStream(); @@ -48,17 +73,29 @@ class InputStream implements IInputStream { } read(len: bigint) { + checkedLength(len); + if (!this.#open) { + closed(); + } if (this.handler.read) { - return this.handler.read(len); + return this.handler.read.call(this, len); } return this.handler.blockingRead.call(this, len); } blockingRead(len: bigint) { + checkedLength(len); + if (!this.#open) { + closed(); + } return this.handler.blockingRead.call(this, len); } skip(len: bigint) { + checkedLength(len); + if (!this.#open) { + closed(); + } if (this.handler.skip) { return this.handler.skip.call(this, len); } @@ -70,6 +107,10 @@ class InputStream implements IInputStream { } blockingSkip(len: bigint) { + checkedLength(len); + if (!this.#open) { + closed(); + } if (this.handler.blockingSkip) { return this.handler.blockingSkip.call(this, len); } @@ -78,13 +119,28 @@ class InputStream implements IInputStream { } subscribe() { - if (this.handler.subscribe) { - return this.handler.subscribe(); + if (!this.#open) { + return pollableCreate(); + } + const pollable = this.handler.subscribe + ? this.handler.subscribe.call(this) + : pollableCreate(); + if (pollable instanceof Pollable) { + this.#children.add(pollable); + pollable._onDispose(() => this.#children.delete(pollable)); } - return new Pollable(); + return pollable; } [symbolDispose]() { + if (!this.#open) { + return; + } + this.#open = false; + for (const child of this.#children) { + child._invalidate(); + } + this.#children.clear(); if (this.handler.drop) { this.handler.drop.call(this); } @@ -99,6 +155,8 @@ class OutputStream implements IOutputStream { id!: number; open!: boolean; handler!: OutputStreamHandler; + #permit = 0n; + #children = new Set(); static _create(handler: OutputStreamHandler) { const stream = new OutputStream(); @@ -113,66 +171,124 @@ class OutputStream implements IOutputStream { checkWrite() { if (!this.open) { - return 0n; + closed(); } if (this.handler.checkWrite) { - return this.handler.checkWrite.call(this); + const permit = this.handler.checkWrite.call(this); + checkedLength(permit, "write permit"); + this.#permit = permit; + return permit; } - return 1_000_000n; + this.#permit = 1_000_000n; + return this.#permit; } write(buf: Uint8Array) { + if (!this.open) { + closed(); + } + if (BigInt(buf.byteLength) > this.#permit) { + throw new Error("write exceeds the permit returned by checkWrite"); + } + this.#permit -= BigInt(buf.byteLength); this.handler.write.call(this, buf); } blockingWriteAndFlush(buf: Uint8Array) { + if (!this.open) { + closed(); + } + if (buf.byteLength > 4096) { + throw new RangeError("blockingWriteAndFlush accepts at most 4096 bytes"); + } if (this.handler.blockingWriteAndFlush) { return this.handler.blockingWriteAndFlush.call(this, buf); } this.handler.write.call(this, buf); + if (this.handler.blockingFlush) { + this.handler.blockingFlush.call(this); + } else { + this.handler.flush?.call(this); + } } flush() { + if (!this.open) { + closed(); + } + this.#permit = 0n; if (this.handler.flush) { this.handler.flush.call(this); } } blockingFlush() { - this.open = true; + if (!this.open) { + closed(); + } if (this.handler.blockingFlush) { this.handler.blockingFlush.call(this); + } else { + this.handler.flush?.call(this); } } writeZeroes(len: bigint) { - this.write.call(this, new Uint8Array(Number(len))); + const length = checkedLength(len); + if (len > this.#permit) { + throw new Error("write exceeds the permit returned by checkWrite"); + } + this.write.call(this, new Uint8Array(length)); } blockingWriteZeroesAndFlush(len: bigint) { - this.blockingWriteAndFlush.call(this, new Uint8Array(Number(len))); + const length = checkedLength(len); + if (length > 4096) { + throw new RangeError("blockingWriteZeroesAndFlush accepts at most 4096 bytes"); + } + this.blockingWriteAndFlush.call(this, new Uint8Array(length)); } splice(src: InputStream, len: bigint) { - const spliceLen = Math.min(Number(len), Number(this.checkWrite.call(this))); + const spliceLen = Math.min(checkedLength(len), Number(this.checkWrite.call(this))); const bytes = src.read(BigInt(spliceLen)); this.write.call(this, bytes); return BigInt(bytes.byteLength); } - blockingSplice(_src: InputStream, _len: bigint) { - console.log(`[streams] Blocking splice ${this.id}`); - return 0n; + blockingSplice(src: InputStream, len: bigint) { + const spliceLen = Math.min(checkedLength(len), Number(this.checkWrite.call(this))); + const bytes = src.blockingRead(BigInt(spliceLen)); + this.write.call(this, bytes); + return BigInt(bytes.byteLength); } subscribe() { - if (this.handler.subscribe) { - return this.handler.subscribe(); + if (!this.open) { + return pollableCreate(); } - return new Pollable(); + const pollable = this.handler.subscribe + ? this.handler.subscribe.call(this) + : pollableCreate(); + if (pollable instanceof Pollable) { + this.#children.add(pollable); + pollable._onDispose(() => this.#children.delete(pollable)); + } + return pollable; } - [symbolDispose]() {} + [symbolDispose]() { + if (!this.open) { + return; + } + this.open = false; + this.#permit = 0n; + for (const child of this.#children) { + child._invalidate(); + } + this.#children.clear(); + this.handler.drop?.call(this); + } } export const outputStreamCreate = OutputStream._create; @@ -186,39 +302,82 @@ export const error: typeof ErrorNamespace = { export const streams: typeof StreamsNamespace = { InputStream, OutputStream }; class Pollable implements PollNamespace.Pollable { - #ready = false; - #promise: Promise | null = null; + #source: PollableSource = { ready: () => true, wait: () => Promise.resolve() }; + #invalid = false; + #disposed = false; + #wait: Promise | null = null; + #disposeCallbacks: (() => void)[] = []; - static _create(promise?: Promise) { + static _create(source?: Promise | PollableSource) { const pollable = new Pollable(); - if (!promise) { - pollable.#ready = true; - } else { - pollable.#promise = promise.then( + if (source instanceof Promise) { + let ready = false; + const wait = source.then( () => { - pollable.#ready = true; + ready = true; }, () => { - pollable.#ready = true; + ready = true; }, ); + pollable.#source = { ready: () => ready, wait: () => wait }; + } else if (source) { + pollable.#source = source; } return pollable; } ready() { - return this.#ready; + this.#assertUsable(); + return this.#source.ready(); } block() { - if (this.#ready) { + this.#assertUsable(); + if (this.#source.ready()) { return Promise.resolve(); } - return this.#promise || Promise.resolve(); + // Deduplicate simultaneous waiters, but discard a completed wait so a + // level-triggered source can be polled again after its event is consumed. + if (!this.#wait) { + this.#wait = Promise.resolve(this.#source.wait()).finally(() => { + this.#wait = null; + }); + } + return this.#wait; + } + + _onDispose(callback: () => void) { + if (this.#disposed) { + callback(); + } else { + this.#disposeCallbacks.push(callback); + } + } + + _invalidate() { + this.#invalid = true; + this.#wait = null; + } + + #assertUsable() { + if (this.#disposed) { + throw new Error("pollable has been disposed"); + } + if (this.#invalid) { + throw new Error("pollable's parent resource has been disposed"); + } } [symbolDispose]() { - this.#promise = null; + if (this.#disposed) { + return; + } + this.#disposed = true; + this.#wait = null; + for (const callback of this.#disposeCallbacks.splice(0)) { + callback(); + } } } @@ -244,19 +403,15 @@ function pollList(list: Pollable[]): Uint32Array | Promise { } // None ready synchronously. Wait for the first to resolve via Promise.race, // then sweep for any others that became ready concurrently. - return Promise.race( - list.map((p, i) => - p.block().then(() => { - const result = [i]; - for (let j = 0; j < list.length; j++) { - if (j !== i && list[j].ready()) { - result.push(j); - } - } - return new Uint32Array(result); - }), - ), - ); + return Promise.race(list.map((pollable) => pollable.block())).then(() => { + const result: number[] = []; + for (let i = 0; i < list.length; i++) { + if (list[i].ready()) { + result.push(i); + } + } + return new Uint32Array(result); + }); } function pollOne(poll: Pollable): Promise { diff --git a/packages/preview2-shim/src/browser/random.ts b/packages/preview2-shim/src/browser/random.ts index 3a6b4e634..8b65c6276 100644 --- a/packages/preview2-shim/src/browser/random.ts +++ b/packages/preview2-shim/src/browser/random.ts @@ -5,6 +5,17 @@ import type { } from "../../types/random.js"; const MAX_BYTES = 65536; +const MAX_U64 = (1n << 64n) - 1n; + +function checkedByteLength(len: bigint): number { + if (typeof len !== "bigint" || len < 0n || len > MAX_U64) { + throw new TypeError("random byte length must be a valid u64"); + } + if (len > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new RangeError("random byte length exceeds JavaScript's safe integer range"); + } + return Number(len); +} let insecureRandomValue1: bigint | undefined, insecureRandomValue2: bigint | undefined; @@ -31,12 +42,13 @@ export const insecureSeed: typeof InsecureSeedNamespace = { export const random: typeof RandomNamespace = { getRandomBytes(len: bigint) { - const bytes = new Uint8Array(Number(len)); + const byteLength = checkedByteLength(len); + const bytes = new Uint8Array(byteLength); - if (len > MAX_BYTES) { + if (byteLength > MAX_BYTES) { // this is the max bytes crypto.getRandomValues // can do at once see https://developer.mozilla.org/en-US/docs/Web/API/window.crypto.getRandomValues - for (var generated = 0; generated < len; generated += MAX_BYTES) { + for (let generated = 0; generated < byteLength; generated += MAX_BYTES) { // buffer.slice automatically checks if the end is past the end of // the buffer so we don't have to here crypto.getRandomValues(bytes.subarray(generated, generated + MAX_BYTES)); diff --git a/packages/preview2-shim/src/browser/sockets.ts b/packages/preview2-shim/src/browser/sockets.ts index 62a0c44eb..615126dd4 100644 --- a/packages/preview2-shim/src/browser/sockets.ts +++ b/packages/preview2-shim/src/browser/sockets.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import type { instanceNetwork as InstanceNetworkNamespace, ipNameLookup as IpNameLookupNamespace, @@ -9,75 +8,99 @@ import type { udp as UdpNamespace, } from "../../types/sockets.js"; +const unsupported = (): never => { + throw "not-supported"; +}; + +class Network implements NetworkNamespace.Network {} +const defaultNetwork = new Network(); + export const instanceNetwork: typeof InstanceNetworkNamespace = { - instanceNetwork() { - console.log(`[sockets] instance network`); - }, + instanceNetwork: () => defaultNetwork, }; +export const network: typeof NetworkNamespace = { Network }; + +class ResolveAddressStream implements IpNameLookupNamespace.ResolveAddressStream { + resolveNextAddress = unsupported; + subscribe = unsupported; +} + export const ipNameLookup: typeof IpNameLookupNamespace = { - dropResolveAddressStream() {}, - subscribe() {}, - resolveAddresses() {}, - resolveNextAddress() {}, - nonBlocking() {}, - setNonBlocking() {}, + ResolveAddressStream, + resolveAddresses: unsupported, }; -export const network: typeof NetworkNamespace = { - dropNetwork() {}, -}; +class TcpSocket implements TcpNamespace.TcpSocket { + startBind = unsupported; + finishBind = unsupported; + startConnect = unsupported; + finishConnect = unsupported; + startListen = unsupported; + finishListen = unsupported; + accept = unsupported; + localAddress = unsupported; + remoteAddress = unsupported; + isListening = unsupported; + addressFamily = unsupported; + setListenBacklogSize = unsupported; + keepAliveEnabled = unsupported; + setKeepAliveEnabled = unsupported; + keepAliveIdleTime = unsupported; + setKeepAliveIdleTime = unsupported; + keepAliveInterval = unsupported; + setKeepAliveInterval = unsupported; + keepAliveCount = unsupported; + setKeepAliveCount = unsupported; + hopLimit = unsupported; + setHopLimit = unsupported; + receiveBufferSize = unsupported; + setReceiveBufferSize = unsupported; + sendBufferSize = unsupported; + setSendBufferSize = unsupported; + subscribe = unsupported; + shutdown = unsupported; +} export const tcpCreateSocket: typeof TcpCreateSocketNamespace = { - createTcpSocket() {}, + createTcpSocket: unsupported, }; -export const tcp: typeof TcpNamespace = { - subscribe() {}, - dropTcpSocket() {}, - bind() {}, - connect() {}, - listen() {}, - accept() {}, - localAddress() {}, - remoteAddress() {}, - addressFamily() {}, - setListenBacklogSize() {}, - keepAlive() {}, - setKeepAlive() {}, - noDelay() {}, - setNoDelay() {}, - unicastHopLimit() {}, - setUnicastHopLimit() {}, - receiveBufferSize() {}, - setReceiveBufferSize() {}, - sendBufferSize() {}, - setSendBufferSize() {}, - nonBlocking() {}, - setNonBlocking() {}, - shutdown() {}, -}; +export const tcp: typeof TcpNamespace = { TcpSocket }; + +class IncomingDatagramStream implements UdpNamespace.IncomingDatagramStream { + receive = unsupported; + subscribe = unsupported; +} + +class OutgoingDatagramStream implements UdpNamespace.OutgoingDatagramStream { + checkSend = unsupported; + send = unsupported; + subscribe = unsupported; +} + +class UdpSocket implements UdpNamespace.UdpSocket { + startBind = unsupported; + finishBind = unsupported; + stream = unsupported; + localAddress = unsupported; + remoteAddress = unsupported; + addressFamily = unsupported; + unicastHopLimit = unsupported; + setUnicastHopLimit = unsupported; + receiveBufferSize = unsupported; + setReceiveBufferSize = unsupported; + sendBufferSize = unsupported; + setSendBufferSize = unsupported; + subscribe = unsupported; +} export const udpCreateSocket: typeof UdpCreateSocketNamespace = { - createUdpSocket() {}, + createUdpSocket: unsupported, }; export const udp: typeof UdpNamespace = { - subscribe() {}, - dropUdpSocket() {}, - bind() {}, - connect() {}, - receive() {}, - send() {}, - localAddress() {}, - remoteAddress() {}, - addressFamily() {}, - unicastHopLimit() {}, - setUnicastHopLimit() {}, - receiveBufferSize() {}, - setReceiveBufferSize() {}, - sendBufferSize() {}, - setSendBufferSize() {}, - nonBlocking() {}, - setNonBlocking() {}, + IncomingDatagramStream, + OutgoingDatagramStream, + UdpSocket, }; diff --git a/packages/preview2-shim/src/common/instantiation.ts b/packages/preview2-shim/src/common/instantiation.ts index 75243f829..2d21b6afe 100644 --- a/packages/preview2-shim/src/common/instantiation.ts +++ b/packages/preview2-shim/src/common/instantiation.ts @@ -66,7 +66,7 @@ import type { * } * }); * - * // Limited filesystem access + * // Node.js only: limited host filesystem access * const limitedShim = new WASIShim({ * sandbox: { * preopens: { @@ -77,6 +77,9 @@ import type { * }); * ``` * + * Browser applications should inject `filesystem` namespaces or explicitly + * configure `browserFilesystem`; browser preopens never interpret host paths. + * * Note that this object is similar but not identical to the Node `WASI` object -- * it is solely concerned with shimming of preview2 when dealing with a WebAssembly * component transpiled by Jco. While this object *does* work with Node (and the browser) @@ -113,8 +116,31 @@ export class WASIShim { // Support both old 'shims' parameter name and new 'config' style const shims = config; - this.#cli = shims?.cli ?? wasi.cli; - this.#filesystem = shims?.filesystem ?? wasi.filesystem; + const defaultCli = wasi.cli as any; + this.#cli = + shims?.cli ?? + (defaultCli.createCli && + (shims?.environment !== undefined || + shims?.arguments !== undefined || + shims?.initialCwd !== undefined || + shims?.stdin !== undefined || + shims?.stdout !== undefined || + shims?.stderr !== undefined) + ? defaultCli.createCli({ + environment: shims?.environment, + arguments: shims?.arguments, + initialCwd: shims?.initialCwd, + stdin: shims?.stdin, + stdout: shims?.stdout, + stderr: shims?.stderr, + }) + : defaultCli); + const defaultFilesystem = wasi.filesystem as any; + this.#filesystem = + shims?.filesystem ?? + (shims?.browserFilesystem && defaultFilesystem.createFilesystem + ? defaultFilesystem.createFilesystem(shims.browserFilesystem) + : defaultFilesystem); this.#io = shims?.io ?? wasi.io; this.#random = shims?.random ?? wasi.random; this.#clocks = shims?.clocks ?? wasi.clocks; diff --git a/packages/preview2-shim/test/fixtures/browser/basic-harness/index.html b/packages/preview2-shim/test/fixtures/browser/basic-harness/index.html index bfab6d273..93cc92757 100644 --- a/packages/preview2-shim/test/fixtures/browser/basic-harness/index.html +++ b/packages/preview2-shim/test/fixtures/browser/basic-harness/index.html @@ -47,12 +47,19 @@