From f8dee5a91deeee5e64b536b796f50975d8c11dc8 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 3 Aug 2026 19:27:14 +0900 Subject: [PATCH 1/3] feat(p2-shim): improve browser support This commit improves the existing browser support along with adding interfaces for where we shouldn't make a choice for the user, namely: - sockets - filesystem With this improved support, we can take a few steps towards official support for jco-in-the-browser. --- packages/preview2-shim/README.md | 49 ++++ packages/preview2-shim/src/browser/cli.ts | 134 ++++++--- packages/preview2-shim/src/browser/clocks.ts | 24 +- .../preview2-shim/src/browser/filesystem.ts | 255 +++++++++++++++--- packages/preview2-shim/src/browser/http.ts | 205 ++++++++++++-- packages/preview2-shim/src/browser/io.ts | 222 ++++++++++++--- packages/preview2-shim/src/browser/random.ts | 18 +- packages/preview2-shim/src/browser/sockets.ts | 137 ++++++---- .../preview2-shim/src/common/instantiation.ts | 27 +- .../fixtures/browser/basic-harness/index.html | 6 +- packages/preview2-shim/test/test.ts | 178 +++++++++++- .../preview2-shim/types/instantiation.d.ts | 17 ++ 12 files changed, 1063 insertions(+), 209 deletions(-) diff --git a/packages/preview2-shim/README.md b/packages/preview2-shim/README.md index 17fd1d4c9..cdd2d444c 100644 --- a/packages/preview2-shim/README.md +++ b/packages/preview2-shim/README.md @@ -10,6 +10,55 @@ The Node.js implementation owns its worker artifact. Direct package use and supp 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; in-memory compatibility implementation remains experimental | 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 in-memory adapter is ephemeral; durable +adapters can implement `BrowserFilesystemAdapter` around application-owned storage: + +```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. Acquire capabilities in application code and pass them to a custom adapter. +Raw TCP, UDP, and DNS are denied by default; outbound HTTP remains a separate `fetch` capability. + # Features ## WASI Shim object for easy instantiation 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..d6d331134 100644 --- a/packages/preview2-shim/src/browser/clocks.ts +++ b/packages/preview2-shim/src/browser/clocks.ts @@ -4,6 +4,24 @@ import type { } from "../../types/clocks.js"; import { pollableCreate } from "./io.js"; +const MAX_TIMEOUT_MS = 0x7fffffff; + +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 @@ -24,8 +42,10 @@ export const monotonicClock: typeof MonotonicClockNamespace = { }, subscribeDuration(duration: bigint) { duration = BigInt(duration); - const ms = duration <= 0n ? 0 : Number(duration / 1_000_000n); - return pollableCreate(new Promise((resolve) => setTimeout(resolve, ms))); + if (duration <= 0n) { + return pollableCreate(new Promise((resolve) => setTimeout(resolve, 0))); + } + 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..9474455e7 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,6 +73,10 @@ class InputStream implements IInputStream { } read(len: bigint) { + checkedLength(len); + if (!this.#open) { + closed(); + } if (this.handler.read) { return this.handler.read(len); } @@ -55,10 +84,18 @@ class InputStream implements IInputStream { } 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,26 @@ class InputStream implements IInputStream { } 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() as Pollable) + : pollableCreate(); + this.#children.add(pollable); + pollable._onDispose(() => this.#children.delete(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 +153,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,19 +169,36 @@ 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); } @@ -133,46 +206,70 @@ class OutputStream implements IOutputStream { } 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); } } writeZeroes(len: bigint) { - this.write.call(this, new Uint8Array(Number(len))); + this.write.call(this, new Uint8Array(checkedLength(len))); } blockingWriteZeroesAndFlush(len: bigint) { - this.blockingWriteAndFlush.call(this, new Uint8Array(Number(len))); + this.blockingWriteAndFlush.call(this, new Uint8Array(checkedLength(len))); } 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() as Pollable) + : pollableCreate(); + 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 +283,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 +384,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..f878d936f 100644 --- a/packages/preview2-shim/src/common/instantiation.ts +++ b/packages/preview2-shim/src/common/instantiation.ts @@ -113,8 +113,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..8551897b2 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,16 @@