From f598529c7c4230d13b1cd814e70db85240b8f832 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 3 Aug 2026 19:27:14 +0900 Subject: [PATCH 01/22] 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 @@ + +

Running

+ + diff --git a/packages/jco-transpile/test/browser/index.html b/packages/jco-transpile/test/browser/index.html deleted file mode 100644 index db46c465b..000000000 --- a/packages/jco-transpile/test/browser/index.html +++ /dev/null @@ -1,101 +0,0 @@ - - - diff --git a/packages/jco-transpile/test/browser/index.script.js b/packages/jco-transpile/test/browser/index.script.js deleted file mode 100644 index 97eec3802..000000000 --- a/packages/jco-transpile/test/browser/index.script.js +++ /dev/null @@ -1,18 +0,0 @@ -import { - $init, - generate as _generate, - generateTypes as _generateTypes, -} from '../../vendor/js-component-bindgen-component.js'; - -export async function generate() { - await $init; - return _generate.apply(this, arguments); -} - -export async function generateTypes() { - await $init; - return _generateTypes.apply(this, arguments); -} - -// for backwards compat -export { generate as transpile }; diff --git a/packages/jco-transpile/test/browser/index.ts b/packages/jco-transpile/test/browser/index.ts new file mode 100644 index 000000000..2dc80858c --- /dev/null +++ b/packages/jco-transpile/test/browser/index.ts @@ -0,0 +1,139 @@ +import { mkdir, rm } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { env } from 'node:process'; +import { pathToFileURL } from 'node:url'; + +import { componentize } from '@bytecodealliance/componentize-js'; +import puppeteer, { type Browser } from 'puppeteer'; +import { afterAll, assert, beforeAll, suite, test } from 'vitest'; + +import { transpileBytes, writeFiles } from '../../src/index.js'; +import { WEBIDL_FIXTURES_DIR, COMPONENT_FIXTURES_DIR } from '../common.js'; +import { getTmpDir, setupAsyncTest, startTestWebServer } from '../helpers.js'; + +const HARNESS_PATH = 'jco-transpile/test/browser/harness.html'; +const CASES_MODULE = '/jco-transpile/test/browser/cases.js'; + +suite('Browser', () => { + let browser: Browser; + let serverPort: number; + let closeServer: () => Promise; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = await getTmpDir(); + const server = await startTestWebServer({ + routes: [ + { urlPrefix: '/tmpdir/', basePathURL: pathToFileURL(`${tmpDir}/`) }, + { basePathURL: new URL('../../../', import.meta.url) }, + ], + }); + serverPort = server.serverPort; + closeServer = server.cleanup; + browser = await puppeteer.launch({ + executablePath: env.PUPPETEER_PATH, + args: [ + '--enable-experimental-webassembly-jspi', + '--flag-switches-begin', + '--enable-features=WebAssemblyExperimentalJSPI', + '--flag-switches-end', + ], + }); + }); + + afterAll(async () => { + await browser?.close(); + await closeServer?.(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + test('transpiles a component in the browser', async () => { + await runBrowserCase({ module: CASES_MODULE, exportName: 'transpile' }); + }); + + for (const fixture of ['dom', 'console']) { + test(`runs the ${fixture} Web IDL component`, async () => { + const { component } = await componentize({ + sourcePath: join(WEBIDL_FIXTURES_DIR, `${fixture}.test.js`), + disableFeatures: ['clocks', 'random', 'stdio'], + witPath: join(WEBIDL_FIXTURES_DIR, `${fixture}.wit`), + worldName: `${fixture === 'dom' ? 'window' : fixture}-test`, + }); + const outDir = resolve(tmpDir, fixture); + const { files } = await transpileBytes(component, { name: fixture }); + await writeFiles(files, { baseDir: outDir }); + await runBrowserCase({ module: `/tmpdir/${fixture}/${fixture}.js` }); + }); + } + + test('runs an asynchronous component with JSPI', async () => { + const outputDir = resolve(tmpDir, 'jspi'); + await mkdir(outputDir); + const component = await setupAsyncTest({ + asyncMode: 'jspi', + component: { + name: 'async_call', + path: join(COMPONENT_FIXTURES_DIR, 'runtime/async_call.component.wasm'), + outputDir, + skipInstantiation: true, + }, + jco: { + transpile: { + extraArgs: { + asyncImports: ['something:test/test-interface#call-async'], + asyncExports: ['run-async'], + }, + }, + }, + }); + try { + const value = await runBrowserCase({ + module: CASES_MODULE, + exportName: 'jspi', + args: [`/tmpdir/jspi/async_call/async_call.js`], + }); + assert.deepStrictEqual(value, { responseText: 'callAsync' }); + } finally { + await component.cleanup(); + } + }); + + async function runBrowserCase({ module, exportName = 'test', args = [] }) { + const page = await browser.newPage(); + const diagnostics: string[] = []; + page.on('console', (message) => diagnostics.push(`console.${message.type()}: ${message.text()}`)); + page.on('pageerror', (error) => diagnostics.push(`pageerror: ${error.stack ?? error.message}`)); + page.on('requestfailed', (request) => + diagnostics.push(`requestfailed: ${request.failure()?.errorText} ${request.url()}`), + ); + + try { + const params = new URLSearchParams({ module, export: exportName, args: JSON.stringify(args) }); + const url = `http://localhost:${serverPort}/${HARNESS_PATH}#${params}`; + const response = await page.goto(url); + assert.ok(response?.ok(), `failed to load ${url}: HTTP ${response?.status()}`); + const result = await page.evaluate(() => window.__jcoTest); + if (!result.ok) { + assert.fail( + [`${result.error.name}: ${result.error.message}`, result.error.stack, ...diagnostics] + .filter(Boolean) + .join('\n'), + ); + } + if (env.JCO_DEBUG && diagnostics.length) { + console.log(diagnostics.join('\n')); + } + return result.value; + } finally { + await page.close(); + } + } +}); + +declare global { + interface Window { + __jcoTest: Promise< + { ok: true; value: unknown } | { ok: false; error: { name: string; message: string; stack?: string } } + >; + } +} diff --git a/packages/jco-transpile/test/browser/jspi.ts b/packages/jco-transpile/test/browser/jspi.ts deleted file mode 100644 index 451ce6fcc..000000000 --- a/packages/jco-transpile/test/browser/jspi.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { join } from 'node:path'; -import { env } from 'node:process'; - -import { pathToFileURL } from 'node:url'; -import puppeteer from 'puppeteer'; - -import { suite, test, assert } from 'vitest'; - -import { setupAsyncTest, startTestWebServer, loadTestPage } from '../helpers.js'; -import { AsyncFunction, COMPONENT_FIXTURES_DIR } from '../common.js'; - -suite(`Async`, async () => { - const componentPath = join(COMPONENT_FIXTURES_DIR, 'runtime/async_call.component.wasm'); - - test('Transpile async (browser, JSPI)', { retry: 3 }, async () => { - if (typeof WebAssembly?.Suspending !== 'function') { - return; - } - const componentName = 'async-call'; - const { - instance, - cleanup: componentCleanup, - outputDir, - } = await setupAsyncTest({ - asyncMode: 'jspi', - component: { - name: 'async_call', - path: componentPath, - imports: { - 'something:test/test-interface': { - callAsync: async () => 'called async', - callSync: () => 'called sync', - }, - }, - }, - jco: { - transpile: { - extraArgs: { - asyncImports: ['something:test/test-interface#call-async'], - asyncExports: ['run-async'], - }, - }, - }, - }); - const moduleName = componentName.toLowerCase().replaceAll('-', '_'); - const moduleRelPath = `${moduleName}/${moduleName}.js`; - - assert.strictEqual(instance.runSync instanceof AsyncFunction, false, 'runSync() should be a sync function'); - assert.strictEqual(instance.runAsync instanceof AsyncFunction, true, 'runAsync() should be an async function'); - - // Start a test web server - const { serverPort, cleanup: webServerCleanup } = await startTestWebServer({ - routes: [ - // NOTE: the goal here is to serve relative paths via the browser hash - // - // (1) browser visits test page (served by test web server) - // (2) browser requests component itself by looking at URL hash fragment - // (i.e. "#transpiled:async_call/async_call.js" -> , "/transpiled/async_call/async_call.js") - // (i.e. "/transpiled/async_call/async_call.js" -> file read of /tmp/xxxxxx/async_call/async_call.js) - { - urlPrefix: '/transpiled/', - basePathURL: pathToFileURL(`${outputDir}/`), - }, - // Serve all other files (ex. the initial HTML for the page) - { basePathURL: new URL('../../test/', import.meta.url) }, - ], - }); - - // Start a browser to visit the test server - const browser = await puppeteer.launch({ - executablePath: env.PUPPETEER_PATH, - args: [ - '--enable-experimental-webassembly-jspi', - '--flag-switches-begin', - '--enable-features=WebAssemblyExperimentalJSPI', - '--flag-switches-end', - ], - }); - - // Load the test page in the browser, which will trigger tests against - // the component and/or related browser polyfills - const { - output: { json }, - } = await loadTestPage({ - browser, - serverPort, - path: 'fixtures/browser/test-pages/something__test.async.html', - hash: `transpiled:${moduleRelPath}`, - }); - - // Check the output expected to be returned from handle of the - // guest export (this depends on the component) - assert.deepStrictEqual(json, { responseText: 'callAsync' }); - - await browser.close(); - await webServerCleanup(); - await componentCleanup(); - }); -}); diff --git a/packages/jco-transpile/test/fixtures/browser/test-pages/something__test.async.html b/packages/jco-transpile/test/fixtures/browser/test-pages/something__test.async.html deleted file mode 100644 index a4b509b75..000000000 --- a/packages/jco-transpile/test/fixtures/browser/test-pages/something__test.async.html +++ /dev/null @@ -1,107 +0,0 @@ - - - - diff --git a/packages/jco-transpile/test/helpers.ts b/packages/jco-transpile/test/helpers.ts index fbe4b7e4f..52b1e6e9b 100644 --- a/packages/jco-transpile/test/helpers.ts +++ b/packages/jco-transpile/test/helpers.ts @@ -511,30 +511,32 @@ export async function startTestWebServer(args) { if (!args.routes) { throw new Error('missing serve paths'); } - const serverPort = await getRandomPort(); - const server = createHttpServer(async (req, res) => { - // Build a utility function for returning an error - const returnError = (e) => { - log(`[webserver] failed to find file [${fileURL}]`); - res.writeHead(404); - res.end(e.message); - }; + const requestUrl = req.url; + if (!requestUrl) { + res.writeHead(400); + res.end('missing request URL'); + return; + } // Find route to serve incoming request const route = args.routes.find((dir) => { - return !dir.urlPrefix || (dir.urlPrefix && req.url.startsWith(dir.urlPrefix)); + return !dir.urlPrefix || requestUrl.startsWith(dir.urlPrefix); }); if (!route) { - log(`[webserver] failed to find route to serve [${req.url.path}]`); - returnError(new Error(`failed to resolve url [${req.url}] with any provided routes`)); + log(`[webserver] failed to find route to serve [${requestUrl}]`); + res.writeHead(404); + res.end(`failed to resolve url [${requestUrl}] with any configured route`); return; } if (!route.basePathURL) { throw new Error('invalid/missing path in specified route'); } - const fileURL = new URL(`./${req.url.slice(route.urlPrefix ? route.urlPrefix.length : '')}`, route.basePathURL); + const fileURL = new URL( + `./${requestUrl.slice(route.urlPrefix ? route.urlPrefix.length : '')}`, + route.basePathURL, + ); log(`[webserver] attempting to read file on disk @ [${fileURL}]`); @@ -542,13 +544,15 @@ export async function startTestWebServer(args) { try { const html = await readFile(fileURL); res.writeHead(200, { - 'content-type': mime.getType(extname(req.url)), + 'content-type': mime.getType(extname(requestUrl)) ?? 'application/octet-stream', }); res.end(html); log(`[webserver] served file [${fileURL}]`); } catch (e) { if (e.code === 'ENOENT') { - returnError(e); + log(`[webserver] failed to find file [${fileURL}]`); + res.writeHead(404); + res.end(e.message); } else { log(`[webserver] ERROR [${e}]`); res.writeHead(500); @@ -557,22 +561,34 @@ export async function startTestWebServer(args) { } }); - const served = new Promise((resolve) => { - server.on('listening', () => { + const served = new Promise((resolve, reject) => { + server.once('error', reject); + server.once('listening', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('test web server did not bind a TCP port')); + return; + } resolve({ - serverPort, + serverPort: address.port, server, - cleanup: async () => { - log('[cleanup] cleaning up http server...'); - server.close(() => { - log('server successfully closed'); - }); - }, + cleanup: () => + new Promise((resolve, reject) => { + log('[cleanup] cleaning up http server...'); + server.close((error) => { + if (error) { + reject(error); + } else { + log('server successfully closed'); + resolve(); + } + }); + }), }); }); }); - server.listen(serverPort); + server.listen(0); return await served; } From d2659c3168123c7b5bd4656b70ad90db44479f91 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 24 Aug 2026 08:03:03 +0000 Subject: [PATCH 05/22] fix(p2-shim): create only final filesystem paths --- .../preview2-shim/src/browser/filesystem.ts | 45 +++++++++---------- packages/preview2-shim/test/test.ts | 28 ++++++++++++ 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/packages/preview2-shim/src/browser/filesystem.ts b/packages/preview2-shim/src/browser/filesystem.ts index 6a8b0c843..3c8197ee8 100644 --- a/packages/preview2-shim/src/browser/filesystem.ts +++ b/packages/preview2-shim/src/browser/filesystem.ts @@ -93,11 +93,7 @@ function coerceToSafeIntegerNumber(obj: number | bigint): number { return n; } -function getChildEntry( - parentEntry: FileDataEntry, - subpath: string, - openFlags: OpenFlags, -): FileDataEntry { +function getChildEntry(parentEntry: FileDataEntry, subpath: string): FileDataEntry { if (subpath === "." && _rootPreopen && descriptorGetEntry(_rootPreopen[0]) === parentEntry) { subpath = _getCwd(); if (subpath.startsWith("/") && subpath !== "/") { @@ -116,12 +112,11 @@ function getChildEntry( throw "no-entry"; } if (segment === "." || segment === "") { - } else if (!entry.dir[segment] && openFlags.create) { - entry = entry.dir[segment] = openFlags.directory - ? { dir: {} } - : { source: new Uint8Array([]) }; } else { entry = entry.dir[segment]; + if (!entry) { + throw "no-entry"; + } } subpath = subpath.slice(segmentIdx + 1); } while (segmentIdx !== -1); @@ -361,13 +356,16 @@ class Descriptor implements TypesNamespace.Descriptor { sync() {} createDirectoryAt(path: string) { - const entry = getChildEntry(this.#entry, path, { - create: true, - directory: true, - }); - if (entry.source) { + try { + getChildEntry(this.#entry, path); throw "exist"; + } catch (error) { + if (error !== "no-entry") { + throw error; + } } + const [parent, name] = getParentEntry(this.#entry, path); + parent.dir![name] = { dir: {} }; } stat() { @@ -391,10 +389,7 @@ class Descriptor implements TypesNamespace.Descriptor { } statAt(_pathFlags: PathFlags, path: string) { - const entry = getChildEntry(this.#entry, path, { - create: false, - directory: false, - }); + const entry = getChildEntry(this.#entry, path); let type: TypesNamespace.DescriptorType = "unknown"; let size = 0n; if (entry.source) { @@ -415,7 +410,7 @@ class Descriptor implements TypesNamespace.Descriptor { } setTimesAt(_pathFlags: PathFlags, path: string, _atime: any, mtime: any) { - const entry = getChildEntry(this.#entry, path, { create: false, directory: false }); + const entry = getChildEntry(this.#entry, path); if (mtime?.tag !== "no-change") { // Metadata is currently descriptor-local; touching the entry makes // the mutation visible through metadata hashes on newly opened handles. @@ -430,7 +425,7 @@ class Descriptor implements TypesNamespace.Descriptor { newDescriptor: TypesNamespace.Descriptor, newPath: string, ) { - const entry = getChildEntry(this.#entry, oldPath, { create: false, directory: false }); + const entry = getChildEntry(this.#entry, oldPath); if (entry.dir) { throw "not-permitted"; } @@ -452,10 +447,7 @@ class Descriptor implements TypesNamespace.Descriptor { ) { let childEntry: FileDataEntry; try { - childEntry = getChildEntry(this.#entry, path, { - create: false, - directory: false, - }); + childEntry = getChildEntry(this.#entry, path); if (openFlags.create && openFlags.exclusive) { throw "exist"; } @@ -463,7 +455,10 @@ class Descriptor implements TypesNamespace.Descriptor { if (error !== "no-entry" || !openFlags.create) { throw error; } - childEntry = getChildEntry(this.#entry, path, openFlags); + const [parent, name] = getParentEntry(this.#entry, path); + childEntry = parent.dir![name] = openFlags.directory + ? { dir: {} } + : { source: new Uint8Array() }; } if (openFlags.directory && !childEntry.dir) { throw "not-directory"; diff --git a/packages/preview2-shim/test/test.ts b/packages/preview2-shim/test/test.ts index 49b6218c8..f5e43b3ff 100644 --- a/packages/preview2-shim/test/test.ts +++ b/packages/preview2-shim/test/test.ts @@ -1255,6 +1255,34 @@ suite("Browser filesystem", () => { assert.throws(() => root.statAt({}, "empty")); }); + test("creates only the final path component", async () => { + const { _setFileData, preopens } = await import("../src/browser/filesystem.js"); + const fileData = { dir: { parent: { dir: {} }, existing: { dir: {} } } }; + _setFileData(fileData); + const [[root]] = preopens.getDirectories(); + + let error: unknown; + try { + root.openAt({}, "missing/file", { create: true }, { write: true }); + } catch (caught) { + error = caught; + } + assert.strictEqual(error, "no-entry"); + assert.strictEqual((fileData.dir as Record).missing, undefined); + + const created = root.openAt({}, "parent/file", { create: true }, { write: true }); + assert.strictEqual(created.getType(), "regular-file"); + root.createDirectoryAt("parent/child"); + assert.strictEqual(root.statAt({}, "parent/child").type, "directory"); + error = undefined; + try { + root.createDirectoryAt("existing"); + } catch (caught) { + error = caught; + } + assert.strictEqual(error, "exist"); + }); + test("createFilesystem isolates explicitly selected in-memory roots", async () => { const { createFilesystem, InMemoryFilesystemAdapter } = await import("../src/browser/filesystem.js"); From 1b48b625d8647620e44c5887490a7c10d913c43b Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 24 Aug 2026 08:04:24 +0000 Subject: [PATCH 06/22] fix(p2-shim): preserve filesystem rename invariants --- .../preview2-shim/src/browser/filesystem.ts | 25 +++++++++ packages/preview2-shim/test/test.ts | 55 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/packages/preview2-shim/src/browser/filesystem.ts b/packages/preview2-shim/src/browser/filesystem.ts index 3c8197ee8..1ccd6fd30 100644 --- a/packages/preview2-shim/src/browser/filesystem.ts +++ b/packages/preview2-shim/src/browser/filesystem.ts @@ -156,6 +156,13 @@ function getSource(fileEntry: FileDataEntry): Uint8Array { return fileEntry.source!; } +function containsEntry(root: FileDataEntry, target: FileDataEntry): boolean { + if (root === target) { + return true; + } + return root.dir ? Object.values(root.dir).some((entry) => containsEntry(entry, target)) : false; +} + // Keep spare capacity separate so FileDataEntry.source always reflects the logical file size. const fileWriteBuffers = new WeakMap(); @@ -501,6 +508,24 @@ class Descriptor implements TypesNamespace.Descriptor { descriptorGetEntry(newDescriptor as Descriptor), newPath, ); + const replaced = newParent.dir![newName]; + if ((oldParent === newParent && oldName === newName) || replaced === entry) { + return; + } + if (entry.dir && containsEntry(entry, newParent)) { + throw "invalid"; + } + if (replaced) { + if (entry.dir && !replaced.dir) { + throw "not-directory"; + } + if (!entry.dir && replaced.dir) { + throw "is-directory"; + } + if (replaced.dir && Object.keys(replaced.dir).length > 0) { + throw "not-empty"; + } + } newParent.dir![newName] = entry; delete oldParent.dir![oldName]; } diff --git a/packages/preview2-shim/test/test.ts b/packages/preview2-shim/test/test.ts index f5e43b3ff..36416b606 100644 --- a/packages/preview2-shim/test/test.ts +++ b/packages/preview2-shim/test/test.ts @@ -1283,6 +1283,61 @@ suite("Browser filesystem", () => { assert.strictEqual(error, "exist"); }); + test("renames entries without deleting or corrupting the tree", async () => { + const { _setFileData, preopens } = await import("../src/browser/filesystem.js"); + _setFileData({ + dir: { + file: { source: "source" }, + target: { source: "target" }, + directory: { dir: { child: { source: "child" } } }, + empty: { dir: {} }, + nonempty: { dir: { value: { source: "value" } } }, + }, + }); + const [[root]] = preopens.getDirectories(); + const thrownValue = (fn: () => void) => { + try { + fn(); + } catch (error) { + return error; + } + assert.fail("operation should have thrown"); + }; + + root.renameAt("file", root, "file"); + assert.strictEqual(root.statAt({}, "file").type, "regular-file"); + + root.renameAt("file", root, "target"); + assert.strictEqual(root.statAt({}, "target").size, 6n); + assert.strictEqual( + thrownValue(() => root.statAt({}, "file")), + "no-entry", + ); + + assert.strictEqual( + thrownValue(() => root.renameAt("target", root, "empty")), + "is-directory", + ); + assert.strictEqual( + thrownValue(() => root.renameAt("directory", root, "target")), + "not-directory", + ); + assert.strictEqual( + thrownValue(() => root.renameAt("directory", root, "nonempty")), + "not-empty", + ); + + const directory = root.openAt({}, "directory", { directory: true }, {}); + assert.strictEqual( + thrownValue(() => root.renameAt("directory", directory, "descendant")), + "invalid", + ); + assert.strictEqual(root.statAt({}, "directory/child").type, "regular-file"); + + root.renameAt("directory", root, "empty"); + assert.strictEqual(root.statAt({}, "empty/child").type, "regular-file"); + }); + test("createFilesystem isolates explicitly selected in-memory roots", async () => { const { createFilesystem, InMemoryFilesystemAdapter } = await import("../src/browser/filesystem.js"); From 04b4ac8986a154274d75dbb2e53dc17c123bedb5 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 24 Aug 2026 08:05:40 +0000 Subject: [PATCH 07/22] fix(p2-shim): share browser filesystem identity --- .../preview2-shim/src/browser/filesystem.ts | 69 +++++++++++++++---- packages/preview2-shim/test/test.ts | 24 +++++++ 2 files changed, 78 insertions(+), 15 deletions(-) diff --git a/packages/preview2-shim/src/browser/filesystem.ts b/packages/preview2-shim/src/browser/filesystem.ts index 1ccd6fd30..e39baf78a 100644 --- a/packages/preview2-shim/src/browser/filesystem.ts +++ b/packages/preview2-shim/src/browser/filesystem.ts @@ -166,6 +166,28 @@ function containsEntry(root: FileDataEntry, target: FileDataEntry): boolean { // Keep spare capacity separate so FileDataEntry.source always reflects the logical file size. const fileWriteBuffers = new WeakMap(); +interface EntryMetadata { + id: bigint; + version: bigint; + linkCount: bigint; +} + +let nextEntryId = 0n; +const entryMetadata = new WeakMap(); + +function metadata(entry: FileDataEntry): EntryMetadata { + let value = entryMetadata.get(entry); + if (!value) { + value = { id: ++nextEntryId, version: 0n, linkCount: 1n }; + entryMetadata.set(entry, value); + } + return value; +} + +function touch(entry: FileDataEntry): void { + metadata(entry).version++; +} + function getFileWriteBuffer( entry: FileDataEntry, source: Uint8Array, @@ -215,7 +237,6 @@ delete DirectoryEntryStream._create; class Descriptor implements TypesNamespace.Descriptor { #stream: any; #entry!: FileDataEntry; - #mtime = 0; #flags: TypesNamespace.DescriptorFlags = { read: true, write: true, @@ -271,6 +292,7 @@ class Descriptor implements TypesNamespace.Descriptor { buffer.set(buf, offset); entry.source = buffer.subarray(0, Math.max(source.byteLength, end)); offset = end; + touch(entry); }, }) as IOutputStream; } @@ -313,12 +335,15 @@ class Descriptor implements TypesNamespace.Descriptor { const resized = new Uint8Array(length); resized.set(source.subarray(0, length)); this.#entry.source = resized; - this.#mtime++; + touch(this.#entry); } - setTimes(_dataAccessTimestamp: any, dataModificationTimestamp: any) { - if (dataModificationTimestamp?.tag !== "no-change") { - this.#mtime++; + setTimes(dataAccessTimestamp: any, dataModificationTimestamp: any) { + if ( + dataAccessTimestamp?.tag !== "no-change" || + dataModificationTimestamp?.tag !== "no-change" + ) { + touch(this.#entry); } } @@ -347,7 +372,7 @@ class Descriptor implements TypesNamespace.Descriptor { target.set(source); target.set(buffer, off); this.#entry.source = target; - this.#mtime++; + touch(this.#entry); return BigInt(buffer.byteLength); } @@ -373,6 +398,7 @@ class Descriptor implements TypesNamespace.Descriptor { } const [parent, name] = getParentEntry(this.#entry, path); parent.dir![name] = { dir: {} }; + touch(parent); } stat() { @@ -387,7 +413,7 @@ class Descriptor implements TypesNamespace.Descriptor { } return { type, - linkCount: 0n, + linkCount: metadata(this.#entry).linkCount, size, dataAccessTimestamp: timeZero, dataModificationTimestamp: timeZero, @@ -408,7 +434,7 @@ class Descriptor implements TypesNamespace.Descriptor { } return { type, - linkCount: 0n, + linkCount: metadata(entry).linkCount, size, dataAccessTimestamp: timeZero, dataModificationTimestamp: timeZero, @@ -422,7 +448,7 @@ class Descriptor implements TypesNamespace.Descriptor { // Metadata is currently descriptor-local; touching the entry makes // the mutation visible through metadata hashes on newly opened handles. fileWriteBuffers.delete(entry); - this.#mtime++; + touch(entry); } } @@ -444,6 +470,8 @@ class Descriptor implements TypesNamespace.Descriptor { throw "exist"; } newParent.dir![newName] = entry; + metadata(entry).linkCount++; + touch(newParent); } openAt( @@ -466,6 +494,7 @@ class Descriptor implements TypesNamespace.Descriptor { childEntry = parent.dir![name] = openFlags.directory ? { dir: {} } : { source: new Uint8Array() }; + touch(parent); } if (openFlags.directory && !childEntry.dir) { throw "not-directory"; @@ -475,6 +504,7 @@ class Descriptor implements TypesNamespace.Descriptor { throw "is-directory"; } childEntry.source = new Uint8Array(); + touch(childEntry); } return descriptorCreate(childEntry); } @@ -496,6 +526,8 @@ class Descriptor implements TypesNamespace.Descriptor { throw "not-empty"; } delete parent.dir![name]; + metadata(entry).linkCount--; + touch(parent); } renameAt(oldPath: string, newDescriptor: TypesNamespace.Descriptor, newPath: string) { @@ -525,9 +557,14 @@ class Descriptor implements TypesNamespace.Descriptor { if (replaced.dir && Object.keys(replaced.dir).length > 0) { throw "not-empty"; } + metadata(replaced).linkCount--; } newParent.dir![newName] = entry; delete oldParent.dir![oldName]; + touch(oldParent); + if (newParent !== oldParent) { + touch(newParent); + } } symlinkAt() { @@ -544,20 +581,22 @@ class Descriptor implements TypesNamespace.Descriptor { throw "is-directory"; } delete parent.dir![name]; + metadata(entry).linkCount--; + touch(parent); } isSameObject(other: TypesNamespace.Descriptor) { - return other === this; + return descriptorGetEntry(other as Descriptor) === this.#entry; } metadataHash() { - let upper = 0n; - upper += BigInt(this.#mtime); - return { upper, lower: 0n }; + const value = metadata(this.#entry); + return { upper: value.id, lower: value.version }; } - metadataHashAt(_pathFlags: any, _path: string) { - return this.metadataHash(); + metadataHashAt(_pathFlags: any, path: string) { + const value = metadata(getChildEntry(this.#entry, path)); + return { upper: value.id, lower: value.version }; } } diff --git a/packages/preview2-shim/test/test.ts b/packages/preview2-shim/test/test.ts index 36416b606..1f293dbce 100644 --- a/packages/preview2-shim/test/test.ts +++ b/packages/preview2-shim/test/test.ts @@ -1338,6 +1338,30 @@ suite("Browser filesystem", () => { assert.strictEqual(root.statAt({}, "empty/child").type, "regular-file"); }); + test("shares identity and metadata across descriptors and hard links", async () => { + const { _setFileData, preopens } = await import("../src/browser/filesystem.js"); + _setFileData({ dir: { file: { source: "value" } } }); + const [[root]] = preopens.getDirectories(); + const first = root.openAt({}, "file", {}, { read: true, write: true }); + const second = root.openAt({}, "file", {}, { read: true, write: true }); + + assert.strictEqual(first.isSameObject(second), true); + assert.deepStrictEqual(root.metadataHashAt({}, "file"), first.metadataHash()); + const before = first.metadataHash(); + second.write(new Uint8Array([1]), 0n); + assert.notDeepEqual(first.metadataHash(), before); + assert.deepStrictEqual(first.metadataHash(), second.metadataHash()); + + root.linkAt({}, "file", root, "link"); + const link = root.openAt({}, "link", {}, { read: true }); + assert.strictEqual(first.isSameObject(link), true); + assert.strictEqual(first.stat().linkCount, 2n); + assert.strictEqual(link.stat().linkCount, 2n); + root.unlinkFileAt("file"); + assert.strictEqual(link.stat().linkCount, 1n); + assert.deepStrictEqual(root.metadataHashAt({}, "link"), link.metadataHash()); + }); + test("createFilesystem isolates explicitly selected in-memory roots", async () => { const { createFilesystem, InMemoryFilesystemAdapter } = await import("../src/browser/filesystem.js"); From 7c8e0ad7bdf9301ba8b67714d697d3a380bcaa34 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 24 Aug 2026 08:07:30 +0000 Subject: [PATCH 08/22] fix(p2-shim): wake invalidated browser pollables --- packages/preview2-shim/src/browser/io.ts | 14 +++++++++--- packages/preview2-shim/test/test.ts | 29 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/preview2-shim/src/browser/io.ts b/packages/preview2-shim/src/browser/io.ts index 6784655bd..2841fe667 100644 --- a/packages/preview2-shim/src/browser/io.ts +++ b/packages/preview2-shim/src/browser/io.ts @@ -307,6 +307,8 @@ class Pollable implements PollNamespace.Pollable { #disposed = false; #wait: Promise | null = null; #disposeCallbacks: (() => void)[] = []; + #wakeUnusable!: () => void; + #unusable = new Promise((resolve) => (this.#wakeUnusable = resolve)); static _create(source?: Promise | PollableSource) { const pollable = new Pollable(); @@ -340,7 +342,10 @@ class Pollable implements PollNamespace.Pollable { // 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 = Promise.race([ + Promise.resolve(this.#source.wait()), + this.#unusable.then(() => this.#assertUsable()), + ]).finally(() => { this.#wait = null; }); } @@ -356,8 +361,11 @@ class Pollable implements PollNamespace.Pollable { } _invalidate() { + if (this.#invalid || this.#disposed) { + return; + } this.#invalid = true; - this.#wait = null; + this.#wakeUnusable(); } #assertUsable() { @@ -374,7 +382,7 @@ class Pollable implements PollNamespace.Pollable { return; } this.#disposed = true; - this.#wait = null; + this.#wakeUnusable(); for (const callback of this.#disposeCallbacks.splice(0)) { callback(); } diff --git a/packages/preview2-shim/test/test.ts b/packages/preview2-shim/test/test.ts index 1f293dbce..89941d9ab 100644 --- a/packages/preview2-shim/test/test.ts +++ b/packages/preview2-shim/test/test.ts @@ -1514,6 +1514,35 @@ suite("Browser shim guards", () => { } }); + test("dropping resources wakes blocked child pollables", async () => { + const { inputStreamCreate, poll, pollableCreate } = await import("../src/browser/io.js"); + const input = inputStreamCreate({ + blockingRead: () => new Uint8Array(), + subscribe: () => + // The source intentionally never becomes ready on its own. + pollableCreate({ + ready: () => false, + wait: () => new Promise(() => {}), + }), + }); + const child = input.subscribe(); + const blocked = child.block(); + const polled = poll.poll([child]) as Promise; + + (input as any)[symbolDispose](); + + const blockError = await blocked.then( + () => undefined, + (error) => error, + ); + const pollError = await polled.then( + () => undefined, + (error) => error, + ); + assert.match(blockError.message, /parent resource has been disposed/); + assert.match(pollError.message, /parent resource has been disposed/); + }); + test("browser random rejects invalid allocation lengths", async () => { const { random } = await import("../src/browser/random.js"); assert.throws(() => random.getRandomBytes(-1n), /valid u64/); From 498342f89a19b81bb499a7c7774438bbb9eedf31 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 24 Aug 2026 08:10:19 +0000 Subject: [PATCH 09/22] fix(p2-shim): await browser HTTP request bodies --- packages/preview2-shim/src/browser/http.ts | 101 +++++++++++++-------- packages/preview2-shim/test/test.ts | 39 ++++++++ 2 files changed, 102 insertions(+), 38 deletions(-) diff --git a/packages/preview2-shim/src/browser/http.ts b/packages/preview2-shim/src/browser/http.ts index ad587483a..a3f4e75bb 100644 --- a/packages/preview2-shim/src/browser/http.ts +++ b/packages/preview2-shim/src/browser/http.ts @@ -209,6 +209,8 @@ class OutgoingBody implements TypesNamespace.OutgoingBody { #outputStream: any = null; #chunks: Uint8Array[] = []; #finished = false; + #resolveFinished!: () => void; + #finishedPromise = new Promise((resolve) => (this.#resolveFinished = resolve)); write() { const outputStream = this.#outputStream; @@ -227,30 +229,43 @@ class OutgoingBody implements TypesNamespace.OutgoingBody { throw { tag: "internal-error", val: "body already finished" }; } body.#finished = true; + body.#resolveFinished(); } static _bodyData(outgoingBody: OutgoingBody): Uint8Array | null { - if (outgoingBody.#chunks.length === 0) { + return outgoingBody.#bodyData(); + } + + #bodyData(): Uint8Array | null { + if (this.#chunks.length === 0) { return null; } let totalLen = 0; - for (const chunk of outgoingBody.#chunks) { + for (const chunk of this.#chunks) { totalLen += chunk.byteLength; } const result = new Uint8Array(totalLen); let offset = 0; - for (const chunk of outgoingBody.#chunks) { + for (const chunk of this.#chunks) { result.set(chunk, offset); offset += chunk.byteLength; } return result; } + static async _finishedBodyData(outgoingBody: OutgoingBody): Promise { + await outgoingBody.#finishedPromise; + return outgoingBody.#bodyData(); + } + static _create(): OutgoingBody { const outgoingBody = new OutgoingBody(); const chunks = outgoingBody.#chunks; outgoingBody.#outputStream = outputStreamCreate({ write(buf: Uint8Array): void { + if (outgoingBody.#finished) { + throw { tag: "closed" }; + } chunks.push(new Uint8Array(buf)); }, blockingFlush() {}, @@ -269,6 +284,9 @@ delete OutgoingBody._create; const outgoingBodyData = OutgoingBody._bodyData; // @ts-expect-error - Deleting static method delete OutgoingBody._bodyData; +const outgoingBodyFinishedData = OutgoingBody._finishedBodyData; +// @ts-expect-error - Deleting static method +delete OutgoingBody._finishedBodyData; type Method = TypesNamespace.Method; type Scheme = TypesNamespace.Scheme; @@ -372,7 +390,11 @@ class OutgoingRequest implements TypesNamespace.OutgoingRequest { } } - const bodyData = outgoingBodyData(request.#body); + // Fetch request streams are not consistently supported by browsers. Buffer the body, + // but do not dispatch until the guest has explicitly finished it. + const bodyData = request.#bodyRequested + ? outgoingBodyFinishedData(request.#body) + : Promise.resolve(null); let timeoutMs = Number(DEFAULT_HTTP_TIMEOUT_NS / 1_000_000n); if (options) { @@ -797,7 +819,7 @@ class FutureIncomingResponse implements TypesNamespace.FutureIncomingResponse { url: string, method: string, headers: Headers, - bodyData: Uint8Array | null, + bodyData: Promise, timeoutMs: number, ): FutureIncomingResponse { const future = new FutureIncomingResponse(); @@ -809,41 +831,44 @@ class FutureIncomingResponse implements TypesNamespace.FutureIncomingResponse { timer = setTimeout(() => controller.abort(), timeoutMs); } - const init: RequestInit = { - method, - headers, - signal: controller.signal, - }; - if (bodyData && method !== "GET" && method !== "HEAD") { - init.body = bodyData as BodyInit; - } - - future.#promise = fetch(url, init).then( - (response) => { - if (timer) { - clearTimeout(timer); - } - future.#result = { - tag: "ok", - val: { - tag: "ok", - val: incomingResponseCreate(response), - }, + future.#promise = bodyData + .then((bodyData) => { + const init: RequestInit = { + method, + headers, + signal: controller.signal, }; - }, - (err) => { - if (timer) { - clearTimeout(timer); + if (bodyData && method !== "GET" && method !== "HEAD") { + init.body = bodyData as BodyInit; } - future.#result = { - tag: "ok", - val: { - tag: "err", - val: mapFetchError(err), - }, - }; - }, - ); + return globalThis.fetch(url, init); + }) + .then( + (response) => { + if (timer) { + clearTimeout(timer); + } + future.#result = { + tag: "ok", + val: { + tag: "ok", + val: incomingResponseCreate(response), + }, + }; + }, + (err) => { + if (timer) { + clearTimeout(timer); + } + future.#result = { + tag: "ok", + val: { + tag: "err", + val: mapFetchError(err), + }, + }; + }, + ); return future; } diff --git a/packages/preview2-shim/test/test.ts b/packages/preview2-shim/test/test.ts index 89941d9ab..026581636 100644 --- a/packages/preview2-shim/test/test.ts +++ b/packages/preview2-shim/test/test.ts @@ -1587,6 +1587,45 @@ suite("Browser shim guards", () => { assert.strictEqual(await response.text(), "created"); }); + test("browser outgoing HTTP waits for the complete request body", async () => { + const { outgoingHandler, types } = await import("../src/browser/http.js"); + const originalFetch = globalThis.fetch; + let requestedBody: Uint8Array | undefined; + let fetchCalls = 0; + globalThis.fetch = async (_input, init) => { + fetchCalls++; + requestedBody = new Uint8Array(await new Response(init?.body).arrayBuffer()); + return new Response("ok"); + }; + try { + const request = new types.OutgoingRequest(new types.Fields()); + request.setMethod({ tag: "post" }); + request.setScheme({ tag: "HTTPS" }); + request.setAuthority("example.com"); + request.setPathWithQuery("/"); + const body = request.body(); + const stream = body.write(); + + const response = outgoingHandler.handle(request, undefined); + await Promise.resolve(); + assert.strictEqual(fetchCalls, 0); + + stream.checkWrite(); + stream.write(new TextEncoder().encode("complete body")); + types.OutgoingBody.finish(body, undefined); + await response.subscribe().block(); + + assert.strictEqual(fetchCalls, 1); + assert.strictEqual(new TextDecoder().decode(requestedBody), "complete body"); + throws( + () => stream.write(new Uint8Array([0])), + ({ tag }) => tag === "closed", + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + test("browser CLI factories isolate configuration and streams", async () => { const { createCli } = await import("../src/browser/cli.js"); const firstWrites: number[] = []; From 6b6294eab2cdca8dd63c879974f65c927e6abe9a Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 24 Aug 2026 08:11:24 +0000 Subject: [PATCH 10/22] fix(p2-shim): align browser HTTP request options --- packages/preview2-shim/src/browser/http.ts | 41 ++++++++------- packages/preview2-shim/test/test.ts | 58 ++++++++++++++++++++++ 2 files changed, 81 insertions(+), 18 deletions(-) diff --git a/packages/preview2-shim/src/browser/http.ts b/packages/preview2-shim/src/browser/http.ts index a3f4e75bb..654d9c78a 100644 --- a/packages/preview2-shim/src/browser/http.ts +++ b/packages/preview2-shim/src/browser/http.ts @@ -167,16 +167,16 @@ const fieldsFromEntriesChecked = Fields._fromEntriesChecked; delete Fields._fromEntriesChecked; class RequestOptions implements TypesNamespace.RequestOptions { - #connectTimeout = DEFAULT_HTTP_TIMEOUT_NS; - #firstByteTimeout = DEFAULT_HTTP_TIMEOUT_NS; - #betweenBytesTimeout = DEFAULT_HTTP_TIMEOUT_NS; + #connectTimeout: bigint | undefined; + #firstByteTimeout: bigint | undefined; + #betweenBytesTimeout: bigint | undefined; connectTimeout() { return this.#connectTimeout; } - setConnectTimeout(duration: bigint) { - if (duration < 0n) { + setConnectTimeout(duration: bigint | undefined) { + if (duration !== undefined && duration < 0n) { throw new Error("duration must not be negative"); } this.#connectTimeout = duration; @@ -186,8 +186,8 @@ class RequestOptions implements TypesNamespace.RequestOptions { return this.#firstByteTimeout; } - setFirstByteTimeout(duration: bigint) { - if (duration < 0n) { + setFirstByteTimeout(duration: bigint | undefined) { + if (duration !== undefined && duration < 0n) { throw new Error("duration must not be negative"); } this.#firstByteTimeout = duration; @@ -197,8 +197,8 @@ class RequestOptions implements TypesNamespace.RequestOptions { return this.#betweenBytesTimeout; } - setBetweenBytesTimeout(duration: bigint) { - if (duration < 0n) { + setBetweenBytesTimeout(duration: bigint | undefined) { + if (duration !== undefined && duration < 0n) { throw new Error("duration must not be negative"); } this.#betweenBytesTimeout = duration; @@ -352,14 +352,19 @@ class OutgoingRequest implements TypesNamespace.OutgoingRequest { } setAuthority(authority: string | undefined) { - if (authority) { - const [host, port, ...extra] = authority.split(":"); - const portNum = Number(port); - if ( - extra.length || - (port !== undefined && (portNum.toString() !== port || portNum > 65535)) || - !host.match(/^[a-zA-Z0-9-.]+$/) - ) { + if (authority !== undefined) { + const match = authority.startsWith("[") + ? authority.match(/^\[([0-9A-Fa-f:.]+)\](?::([0-9]+))?$/) + : authority.match(/^([a-zA-Z0-9.-]+)(?::([0-9]+))?$/); + if (!match || (match[2] !== undefined && Number(match[2]) > 65535)) { + throw undefined; + } + try { + const parsed = new URL(`http://${authority}/`); + if (parsed.username || parsed.password || !parsed.hostname) { + throw undefined; + } + } catch { throw undefined; } } @@ -386,7 +391,7 @@ class OutgoingRequest implements TypesNamespace.OutgoingRequest { for (const [key, value] of request.#headers.entries()) { const lowerKey = key.toLowerCase(); if (!forbiddenHeaders.has(lowerKey)) { - headers.set(key, utf8Decoder.decode(value)); + headers.append(key, utf8Decoder.decode(value)); } } diff --git a/packages/preview2-shim/test/test.ts b/packages/preview2-shim/test/test.ts index 026581636..080f89f68 100644 --- a/packages/preview2-shim/test/test.ts +++ b/packages/preview2-shim/test/test.ts @@ -1668,6 +1668,64 @@ suite("Browser shim guards", () => { const opts = new types.RequestOptions(); assert.throws(() => opts.setBetweenBytesTimeout(-1n), /negative/); }); + + test("browser HTTP options preserve undefined timeouts", async () => { + const { types } = await import("../src/browser/http.js"); + const opts = new types.RequestOptions(); + assert.strictEqual(opts.connectTimeout(), undefined); + opts.setConnectTimeout(10n); + opts.setConnectTimeout(undefined); + opts.setFirstByteTimeout(undefined); + opts.setBetweenBytesTimeout(undefined); + assert.strictEqual(opts.connectTimeout(), undefined); + assert.strictEqual(opts.firstByteTimeout(), undefined); + assert.strictEqual(opts.betweenBytesTimeout(), undefined); + }); + + test("browser HTTP validates DNS, IPv4, and IPv6 authorities", async () => { + const { types } = await import("../src/browser/http.js"); + const request = new types.OutgoingRequest(new types.Fields()); + for (const authority of ["example.com", "127.0.0.1:8080", "[::1]:8080"]) { + request.setAuthority(authority); + assert.strictEqual(request.authority(), authority); + } + for (const authority of [ + "", + "user@example.com", + "example.com:", + "example.com:65536", + "::1", + "[not-ipv6]", + ]) { + throws(() => request.setAuthority(authority)); + } + }); + + test("browser HTTP preserves repeated outgoing headers", async () => { + const { outgoingHandler, types } = await import("../src/browser/http.js"); + const originalFetch = globalThis.fetch; + let receivedHeader: string | null = null; + globalThis.fetch = async (_input, init) => { + receivedHeader = (init?.headers as Headers).get("x-repeat"); + return new Response(); + }; + try { + const encoder = new TextEncoder(); + const request = new types.OutgoingRequest( + types.Fields.fromList([ + ["x-repeat", encoder.encode("one")], + ["x-repeat", encoder.encode("two")], + ]), + ); + request.setAuthority("example.com"); + request.setPathWithQuery("/"); + const response = outgoingHandler.handle(request, undefined); + await response.subscribe().block(); + assert.strictEqual(receivedHeader, "one, two"); + } finally { + globalThis.fetch = originalFetch; + } + }); }); function testWithGCWrap(asyncTestFn: any) { From d6c3f18cb9a63a31c9bbe12d28fdaf5f3ffc05d7 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 24 Aug 2026 08:12:37 +0000 Subject: [PATCH 11/22] fix(p2-shim): expose browser incoming HTTP handlers --- packages/preview2-shim/src/browser/http.ts | 41 ++++++++++++++++--- .../preview2-shim/src/common/instantiation.ts | 1 + packages/preview2-shim/test/test.ts | 31 +++++++++++++- 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/packages/preview2-shim/src/browser/http.ts b/packages/preview2-shim/src/browser/http.ts index 654d9c78a..09b260ad1 100644 --- a/packages/preview2-shim/src/browser/http.ts +++ b/packages/preview2-shim/src/browser/http.ts @@ -714,12 +714,15 @@ class OutgoingResponse implements TypesNamespace.OutgoingResponse { this.#bodyRequested = true; return this.#body; } - static _toResponse(response: OutgoingResponse) { + static async _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, { + const body = response.#bodyRequested + ? await outgoingBodyFinishedData(response.#body) + : null; + return new Response(body as BodyInit | null, { status: response.#status, headers, }); @@ -732,6 +735,7 @@ delete OutgoingResponse._toResponse; class ResponseOutparam implements TypesNamespace.ResponseOutparam { #used = false; #resolve!: (response: Response) => void; + #reject!: (cause: unknown) => void; static set( param: ResponseOutparam, @@ -742,21 +746,38 @@ class ResponseOutparam implements TypesNamespace.ResponseOutparam { } param.#used = true; if (response.tag === "ok") { - param.#resolve(outgoingResponseToResponse(response.val as OutgoingResponse)); + void outgoingResponseToResponse(response.val as OutgoingResponse).then( + param.#resolve, + param.#reject, + ); } else { - param.#resolve(new Response("WASI HTTP handler error", { status: 500 })); + param.#resolve( + new Response(`WASI HTTP handler error: ${JSON.stringify(response.val)}`, { + status: 500, + }), + ); } } + static _isUsed(param: ResponseOutparam): boolean { + return param.#used; + } + static _create(): [ResponseOutparam, Promise] { const param = new ResponseOutparam(); - const response = new Promise((resolve) => (param.#resolve = resolve)); + const response = new Promise((resolve, reject) => { + param.#resolve = resolve; + param.#reject = reject; + }); return [param, response]; } } const responseOutparamCreate = ResponseOutparam._create; // @ts-expect-error - Deleting static method delete ResponseOutparam._create; +const responseOutparamIsUsed = ResponseOutparam._isUsed; +// @ts-expect-error - Deleting static method +delete ResponseOutparam._isUsed; class FutureTrailers implements TypesNamespace.FutureTrailers { #requested = false; @@ -923,6 +944,13 @@ export type BrowserIncomingHandler = ( responseOut: TypesNamespace.ResponseOutparam, ) => void | Promise; +/** Create a `wasi:http/incoming-handler` namespace backed by a host callback. */ +export function createIncomingHandler( + handler: BrowserIncomingHandler, +): typeof IncomingHandlerNamespace { + return { handle: handler } as typeof IncomingHandlerNamespace; +} + /** Translate a browser Request through a host-provided WASI incoming handler. */ export async function handleIncomingRequest( request: Request, @@ -930,6 +958,9 @@ export async function handleIncomingRequest( ): Promise { const [responseOut, response] = responseOutparamCreate(); await handler(incomingRequestCreate(request), responseOut); + if (!responseOutparamIsUsed(responseOut)) { + throw new Error("WASI HTTP handler returned without setting its response outparam"); + } return response; } diff --git a/packages/preview2-shim/src/common/instantiation.ts b/packages/preview2-shim/src/common/instantiation.ts index 2d21b6afe..ebceb7586 100644 --- a/packages/preview2-shim/src/common/instantiation.ts +++ b/packages/preview2-shim/src/common/instantiation.ts @@ -223,6 +223,7 @@ export class WASIShim { obj[`wasi:clocks/wall-clock${versionSuffix}`] = this.#clocks.wallClock; obj[`wasi:http/types${versionSuffix}`] = this.#http.types; + obj[`wasi:http/incoming-handler${versionSuffix}`] = this.#http.incomingHandler; obj[`wasi:http/outgoing-handler${versionSuffix}`] = this.#http.outgoingHandler; return obj as WASIImportObject; diff --git a/packages/preview2-shim/test/test.ts b/packages/preview2-shim/test/test.ts index 080f89f68..6c294376e 100644 --- a/packages/preview2-shim/test/test.ts +++ b/packages/preview2-shim/test/test.ts @@ -1,5 +1,5 @@ import { env } from "node:process"; -import { throws } from "node:assert"; +import { rejects, throws } from "node:assert"; import { createServer } from "node:net"; import { fileURLToPath } from "node:url"; import { suite, test, assert, beforeEach, afterEach } from "vitest"; @@ -1587,6 +1587,35 @@ suite("Browser shim guards", () => { assert.strictEqual(await response.text(), "created"); }); + test("browser incoming HTTP rejects an unset response outparam", async () => { + const { handleIncomingRequest } = await import("../src/browser/http.js"); + await rejects( + handleIncomingRequest(new Request("https://example.com/"), () => {}), + /without setting its response outparam/, + ); + }); + + test("browser incoming HTTP exposes an injectable handler namespace", async () => { + const { createIncomingHandler, types } = await import("../src/browser/http.js"); + let called = false; + const handler = createIncomingHandler((_request, responseOut) => { + called = true; + types.ResponseOutparam.set(responseOut, { + tag: "err", + val: { tag: "internal-error", val: "rejected" }, + }); + }); + const response = await handleIncomingViaNamespace(handler); + assert.strictEqual(called, true); + assert.strictEqual(response.status, 500); + assert.match(await response.text(), /internal-error.*rejected/); + + async function handleIncomingViaNamespace(namespace: typeof handler) { + const { handleIncomingRequest } = await import("../src/browser/http.js"); + return handleIncomingRequest(new Request("https://example.com/"), namespace.handle); + } + }); + test("browser outgoing HTTP waits for the complete request body", async () => { const { outgoingHandler, types } = await import("../src/browser/http.js"); const originalFetch = globalThis.fetch; From d98c768b617bcbc7f75d6be15af2ee1a629b5e60 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 24 Aug 2026 08:13:49 +0000 Subject: [PATCH 12/22] fix(p2-shim): isolate sandbox network capabilities --- .../preview2-shim/src/common/instantiation.ts | 23 ++++++----------- packages/preview2-shim/src/nodejs/sockets.ts | 25 +++++++++++++++++++ packages/preview2-shim/test/test.ts | 22 ++++++++++++++++ 3 files changed, 55 insertions(+), 15 deletions(-) diff --git a/packages/preview2-shim/src/common/instantiation.ts b/packages/preview2-shim/src/common/instantiation.ts index ebceb7586..a4bc765e6 100644 --- a/packages/preview2-shim/src/common/instantiation.ts +++ b/packages/preview2-shim/src/common/instantiation.ts @@ -144,7 +144,14 @@ export class WASIShim { this.#io = shims?.io ?? wasi.io; this.#random = shims?.random ?? wasi.random; this.#clocks = shims?.clocks ?? wasi.clocks; - this.#sockets = shims?.sockets ?? wasi.sockets; + const defaultSockets = wasi.sockets as any; + this.#sockets = + shims?.sockets ?? + (defaultSockets.createSockets + ? defaultSockets.createSockets({ + enableNetwork: shims?.sandbox?.enableNetwork, + }) + : defaultSockets); this.#http = shims?.http ?? wasi.http; // Extract sandbox options @@ -159,20 +166,6 @@ export class WASIShim { if (sandbox?.env !== undefined || sandbox?.args !== undefined) { this.#environment = createIsolatedEnvironment(sandbox?.env, sandbox?.args, this.#cli); } - - // Apply network restrictions if disabled - if (sandbox?.enableNetwork === false) { - // Use the sockets module's built-in deny functions - if (this.#sockets._denyTcp) { - this.#sockets._denyTcp(); - } - if (this.#sockets._denyUdp) { - this.#sockets._denyUdp(); - } - if (this.#sockets._denyDnsLookup) { - this.#sockets._denyDnsLookup(); - } - } } /** diff --git a/packages/preview2-shim/src/nodejs/sockets.ts b/packages/preview2-shim/src/nodejs/sockets.ts index b6dc97ca5..5ac2b909d 100644 --- a/packages/preview2-shim/src/nodejs/sockets.ts +++ b/packages/preview2-shim/src/nodejs/sockets.ts @@ -585,3 +585,28 @@ export const udp: typeof UdpNamespace = { OutgoingDatagramStream, IncomingDatagramStream, }; + +export interface SocketsConfig { + enableNetwork?: boolean; +} + +/** Create socket namespaces with an instance-local network capability. */ +export function createSockets(config: SocketsConfig = {}) { + const localNetwork = new Network(); + if (config.enableNetwork === false) { + _denyDnsLookup(localNetwork); + _denyTcp(localNetwork); + _denyUdp(localNetwork); + } + return { + instanceNetwork: { + instanceNetwork: () => localNetwork, + } as typeof InstanceNetworkNamespace, + network, + ipNameLookup, + tcpCreateSocket, + tcp, + udpCreateSocket, + udp, + }; +} diff --git a/packages/preview2-shim/test/test.ts b/packages/preview2-shim/test/test.ts index 6c294376e..09e55db25 100644 --- a/packages/preview2-shim/test/test.ts +++ b/packages/preview2-shim/test/test.ts @@ -1010,6 +1010,28 @@ suite("Sandboxing", () => { ); }); + test("WASIShim network capabilities are isolated per instance", async () => { + const { WASIShim } = await import("@bytecodealliance/preview2-shim/instantiation"); + const restricted = new WASIShim({ sandbox: { enableNetwork: false } }).getImportObject(); + const allowed = new WASIShim({ sandbox: { enableNetwork: true } }).getImportObject(); + const restrictedNetwork = restricted["wasi:sockets/instance-network"].instanceNetwork(); + const allowedNetwork = allowed["wasi:sockets/instance-network"].instanceNetwork(); + assert.notStrictEqual(restrictedNetwork, allowedNetwork); + + const restrictedSocket = + restricted["wasi:sockets/tcp-create-socket"].createTcpSocket("ipv4"); + const allowedSocket = allowed["wasi:sockets/tcp-create-socket"].createTcpSocket("ipv4"); + const address = { + tag: "ipv4" as const, + val: { address: [127, 0, 0, 1] as [number, number, number, number], port: 0 }, + }; + assert.throws( + () => restrictedSocket.startBind(restrictedNetwork, address), + /access-denied/, + ); + assert.doesNotThrow(() => allowedSocket.startBind(allowedNetwork, address)); + }); + test("Fully sandboxed WASIShim", async () => { const { WASIShim } = await import("@bytecodealliance/preview2-shim/instantiation"); From a3554c89f713907884a4f4193e3df815fafb2f2c Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 24 Aug 2026 08:15:18 +0000 Subject: [PATCH 13/22] fix(p2-shim): delegate preopens to filesystem factories --- .../preview2-shim/src/common/instantiation.ts | 67 +++++++------------ .../preview2-shim/src/nodejs/filesystem.ts | 13 ++++ packages/preview2-shim/test/test.ts | 13 ++++ 3 files changed, 50 insertions(+), 43 deletions(-) diff --git a/packages/preview2-shim/src/common/instantiation.ts b/packages/preview2-shim/src/common/instantiation.ts index a4bc765e6..8d1a1d439 100644 --- a/packages/preview2-shim/src/common/instantiation.ts +++ b/packages/preview2-shim/src/common/instantiation.ts @@ -1,5 +1,4 @@ import * as wasi from "@bytecodealliance/preview2-shim"; -import { types, _createPreopenDescriptor } from "@bytecodealliance/preview2-shim/filesystem"; import type { WASIShimConfig, GetImportObjectArgs, @@ -102,8 +101,6 @@ export class WASIShim { #sockets: any; /** Object that confirms to the shim interface for `wasi:http` */ #http: any; - /** Isolated preopens for this instance */ - #preopens: any; /** Isolated environment for this instance */ #environment: any; @@ -115,6 +112,7 @@ export class WASIShim { constructor(config?: WASIShimConfig) { // Support both old 'shims' parameter name and new 'config' style const shims = config; + const sandbox = shims?.sandbox; const defaultCli = wasi.cli as any; this.#cli = @@ -136,11 +134,28 @@ export class WASIShim { }) : defaultCli); const defaultFilesystem = wasi.filesystem as any; - this.#filesystem = - shims?.filesystem ?? - (shims?.browserFilesystem && defaultFilesystem.createFilesystem - ? defaultFilesystem.createFilesystem(shims.browserFilesystem) - : defaultFilesystem); + if (shims?.filesystem && sandbox?.preopens !== undefined) { + throw new TypeError( + "sandbox.preopens cannot configure an application-provided filesystem; provide its preopens namespace directly", + ); + } + if (shims?.browserFilesystem && !defaultFilesystem.createFilesystem) { + throw new TypeError("the selected filesystem does not support browser adapters"); + } + this.#filesystem = shims?.filesystem ?? defaultFilesystem; + if (shims?.browserFilesystem) { + this.#filesystem = defaultFilesystem.createFilesystem({ + adapter: shims.browserFilesystem.adapter, + preopens: sandbox?.preopens ?? shims.browserFilesystem.preopens, + }); + } else if (sandbox?.preopens !== undefined) { + if (!defaultFilesystem.createFilesystem) { + throw new TypeError("the selected filesystem cannot create isolated preopens"); + } + this.#filesystem = defaultFilesystem.createFilesystem({ + preopens: sandbox.preopens, + }); + } this.#io = shims?.io ?? wasi.io; this.#random = shims?.random ?? wasi.random; this.#clocks = shims?.clocks ?? wasi.clocks; @@ -154,14 +169,6 @@ export class WASIShim { : defaultSockets); this.#http = shims?.http ?? wasi.http; - // Extract sandbox options - const sandbox = shims?.sandbox; - - // Create isolated preopens if configured - if (sandbox?.preopens !== undefined) { - this.#preopens = createIsolatedPreopens(sandbox.preopens); - } - // Create isolated environment if env or args are configured if (sandbox?.env !== undefined || sandbox?.args !== undefined) { this.#environment = createIsolatedEnvironment(sandbox?.env, sandbox?.args, this.#cli); @@ -200,8 +207,7 @@ export class WASIShim { obj[`wasi:sockets/udp${versionSuffix}`] = this.#sockets.udp; obj[`wasi:sockets/udp-create-socket${versionSuffix}`] = this.#sockets.udpCreateSocket; - obj[`wasi:filesystem/preopens${versionSuffix}`] = - this.#preopens ?? this.#filesystem.preopens; + obj[`wasi:filesystem/preopens${versionSuffix}`] = this.#filesystem.preopens; obj[`wasi:filesystem/types${versionSuffix}`] = this.#filesystem.types; obj[`wasi:io/error${versionSuffix}`] = this.#io.error; @@ -223,31 +229,6 @@ export class WASIShim { } } -/** - * Create an isolated preopens object with its own preopen entries. - * - * @param preopensConfig - Map of virtual paths to host paths - * @returns A preopens object with Descriptor and getDirectories() - */ -function createIsolatedPreopens(preopensConfig: Record) { - const entries: any[] = []; - - // Populate entries using the filesystem's descriptor creation - if (_createPreopenDescriptor) { - for (const [virtualPath, hostPath] of Object.entries(preopensConfig)) { - const descriptor = _createPreopenDescriptor(hostPath); - entries.push([descriptor, virtualPath]); - } - } - - return { - Descriptor: types.Descriptor, - getDirectories() { - return entries; - }, - }; -} - /** * Create an isolated CLI environment with its own env and args. * diff --git a/packages/preview2-shim/src/nodejs/filesystem.ts b/packages/preview2-shim/src/nodejs/filesystem.ts index 4004b5632..f7c2d8dc8 100644 --- a/packages/preview2-shim/src/nodejs/filesystem.ts +++ b/packages/preview2-shim/src/nodejs/filesystem.ts @@ -721,6 +721,19 @@ export const types: typeof TypesNamespace = { }, }; +/** Create isolated filesystem namespaces from Node.js host-path preopens. */ +export function createFilesystem({ preopens }: { preopens: Record }) { + const entries: Array<[Descriptor, string]> = Object.entries(preopens).map( + ([virtualPath, hostPath]) => [descriptorCreatePreopen(hostPath), virtualPath], + ); + return { + types, + preopens: { + getDirectories: () => [...entries], + } as typeof PreopensNamespace, + }; +} + /** * Replace all preopens with the given set. * @param {Record} preopens - Map of virtual paths to host paths diff --git a/packages/preview2-shim/test/test.ts b/packages/preview2-shim/test/test.ts index 09e55db25..9c4eb151d 100644 --- a/packages/preview2-shim/test/test.ts +++ b/packages/preview2-shim/test/test.ts @@ -856,6 +856,19 @@ suite("Instantiation", () => { assert.strictEqual(imports["wasi:filesystem/types"], filesystem.types); assert.strictEqual(imports["wasi:filesystem/preopens"], customPreopens); }); + + test("WASIShim does not reinterpret custom filesystem preopens", async () => { + const { WASIShim } = await import("@bytecodealliance/preview2-shim/instantiation"); + const { filesystem } = await import("@bytecodealliance/preview2-shim"); + assert.throws( + () => + new WASIShim({ + filesystem, + sandbox: { preopens: { "/guest": "/host" } }, + }), + /provide its preopens namespace directly/, + ); + }); }); suite("Sandboxing", () => { From 57f0ff26abb936566015015f3fe95b20a567a928 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 24 Aug 2026 08:16:27 +0000 Subject: [PATCH 14/22] docs(p2-shim): detail browser capability support --- packages/preview2-shim/README.md | 42 +++++++++++++++++-- .../preview2-shim/types/instantiation.d.ts | 1 + 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/preview2-shim/README.md b/packages/preview2-shim/README.md index a8842bf0d..0515d645f 100644 --- a/packages/preview2-shim/README.md +++ b/packages/preview2-shim/README.md @@ -35,6 +35,29 @@ An operation is not considered supported merely because its interface shape exis 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. +### Detailed browser capabilities + +The following table describes the built-in browser implementation. An application-provided +namespace can replace any row through `WASIShim`. + +| Interface | Implemented | Host adapter required | Unsupported by browser implementation | +| ----------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------- | +| `wasi:cli` | environment, arguments, initial cwd, exit, stream and terminal accessors | stdin/stdout/stderr handlers and terminal resources | — | +| `wasi:clocks` | wall clock, monotonic clock, timer subscriptions | — | timezone APIs (not part of Preview 2) | +| `wasi:random` | secure and insecure bytes, insecure seed | — | — | +| `wasi:io` | errors, input/output streams, poll and pollables | readiness and I/O behavior for injected stream handlers | synchronous blocking of the browser event loop | +| `wasi:filesystem` | descriptors, files, directories, links, metadata, streams, preopens through the ephemeral adapter | persistent storage, permissions, and external file handles | symbolic-link creation and reading | +| `wasi:http/outgoing-handler` | Fetch-backed requests and buffered request bodies | Fetch implementation and network permission | request/response trailers; streaming uploads | +| `wasi:http/incoming-handler` | request/response translation and injectable handler namespace | HTTP server, service worker, or other request source | direct browser listening | +| `wasi:sockets/ip-name-lookup` | interface shape only | complete interface replacement | built-in DNS lookup | +| `wasi:sockets/tcp*` | interface shape only | complete interface replacement | built-in raw TCP | +| `wasi:sockets/udp*` | interface shape only | complete interface replacement | built-in raw UDP | + +Outbound HTTP buffers a requested body until `outgoing-body.finish` before calling `fetch`. +This preserves complete-body semantics across browsers but does not provide streaming upload or +upload backpressure. Incoming Fetch bodies retain their asynchronous stream behavior. HTTP +trailers are not implemented. + Browser applications select storage explicitly. The bundled file-data adapter is ephemeral and must be opted into: @@ -72,6 +95,15 @@ This keeps permission prompts, handle acquisition, persistence, and synchronizat application code. Raw TCP, UDP, and DNS are denied by default; outbound HTTP remains a separate `fetch` capability. +Browser filesystem adapters own the capabilities passed in `preopens` and the roots returned from +`getRoot`. A root may be shared by multiple descriptors and preopen names; the adapter is therefore +responsible for persistence and synchronization of shared mutations. Calling `dispose` on the +namespace returned by `createFilesystem` calls the adapter's optional `dispose` method once and +invalidates further preopen access. `WASIShim` does not currently cascade disposal, so embeddings +using external handles must retain and dispose their application-owned filesystem namespace or +adapter themselves. The bundled in-memory adapter keeps all state in memory and shares mutations +for the same file-data object. + # Features ## WASI Shim object for easy instantiation @@ -159,14 +191,18 @@ const component = await instantiate(loader, sandboxedShim.getImportObject()); - By default (when no options are passed), the shim is providing full access to match typical 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. +- `sandbox.preopens` maps guest paths to Node.js host paths on Node.js. With + `browserFilesystem`, the same option maps guest paths to capabilities understood by its adapter + and overrides `browserFilesystem.preopens`. A fully custom `filesystem` namespace owns its + preopens directly and cannot be combined with `sandbox.preopens`. - 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 affect all components not using `WASIShim` with explicit configuration. For isolation, prefer using `WASIShim` with the `sandbox` option containing `preopens` and `env`. -- When `sandbox.enableNetwork: false`, all socket and HTTP operations will throw "access-denied" errors. +- When `sandbox.enableNetwork: false`, Node.js socket operations receive an instance-local denied + network capability. Outbound HTTP is a separate Fetch capability; replace or omit the HTTP + namespace when the embedding must deny it as well. [jco]: https://www.npmjs.com/package/@bytecodealliance/jco diff --git a/packages/preview2-shim/types/instantiation.d.ts b/packages/preview2-shim/types/instantiation.d.ts index dbb1f93f9..b9abf024f 100644 --- a/packages/preview2-shim/types/instantiation.d.ts +++ b/packages/preview2-shim/types/instantiation.d.ts @@ -43,6 +43,7 @@ type _WASIImportObject = { 'wasi:clocks/wall-clock': typeof import('./interfaces/wasi-clocks-wall-clock.d.ts'); 'wasi:http/types': typeof import('./interfaces/wasi-http-types.d.ts'); + 'wasi:http/incoming-handler': typeof import('./interfaces/wasi-http-incoming-handler.d.ts'); 'wasi:http/outgoing-handler': typeof import('./interfaces/wasi-http-outgoing-handler.d.ts'); }; From 3fd64a7610c6300cea5e2eb8e835ce4cb995e52d Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 24 Aug 2026 08:23:59 +0000 Subject: [PATCH 15/22] fix(p2-shim): yield browser tasks from ready polls --- packages/preview2-shim/src/browser/io.ts | 15 ++++++++++++++- packages/preview2-shim/test/test.ts | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/preview2-shim/src/browser/io.ts b/packages/preview2-shim/src/browser/io.ts index 2841fe667..43b985a7b 100644 --- a/packages/preview2-shim/src/browser/io.ts +++ b/packages/preview2-shim/src/browser/io.ts @@ -407,7 +407,20 @@ function pollList(list: Pollable[]): Uint32Array | Promise { } } if (ready.length > 0) { - return new Uint32Array(ready); + // Browser guests commonly use an immediately-ready timer alongside an + // asynchronous Web API pollable. Yield a host task so Fetch, timers, and + // other event sources can progress instead of starving in a sync loop. + return new Promise((resolve) => + setTimeout(() => { + const result: number[] = []; + for (let i = 0; i < list.length; i++) { + if (list[i].ready()) { + result.push(i); + } + } + resolve(new Uint32Array(result)); + }, 0), + ); } // None ready synchronously. Wait for the first to resolve via Promise.race, // then sweep for any others that became ready concurrently. diff --git a/packages/preview2-shim/test/test.ts b/packages/preview2-shim/test/test.ts index 9c4eb151d..1e1966362 100644 --- a/packages/preview2-shim/test/test.ts +++ b/packages/preview2-shim/test/test.ts @@ -1473,7 +1473,7 @@ suite("Browser shim guards", () => { wait: () => new Promise((resolve) => (resolveSecond = resolve)), }); - assert.deepStrictEqual(poll.poll([first, second, first]), new Uint32Array([1])); + assert.deepStrictEqual(await poll.poll([first, second, first]), new Uint32Array([1])); secondReady = false; const next = poll.poll([first, second, first]); firstReady = true; From dc022d2480e574f3d5dead3b5cb27af59a4292dd Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 24 Aug 2026 08:26:10 +0000 Subject: [PATCH 16/22] test(p2-shim): release browser harness resources --- packages/preview2-shim/test/common.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/preview2-shim/test/common.ts b/packages/preview2-shim/test/common.ts index 84bd3795d..f15bffe80 100644 --- a/packages/preview2-shim/test/common.ts +++ b/packages/preview2-shim/test/common.ts @@ -1,6 +1,6 @@ import process, { env } from "node:process"; import { pathToFileURL, URL, fileURLToPath } from "node:url"; -import { mkdtemp, readFile, stat } from "node:fs/promises"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { sep, normalize, resolve, extname } from "node:path"; import { createServer as createHTTPServer } from "node:http"; @@ -258,7 +258,13 @@ export async function startTestServer(args: StartTestServerArgs): Promise { - await new Promise((resolve) => server.close(() => resolve())); + await Promise.all([ + browser.close(), + new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ), + ]); + await rm(transpiledOutputDir, { recursive: true, force: true }); }, }; } From 0046521adffc03f87721cf37eabc6d162d4d29b2 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 24 Aug 2026 08:33:43 +0000 Subject: [PATCH 17/22] fix(p2-shim): remove obsolete body helper --- packages/preview2-shim/src/browser/http.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/preview2-shim/src/browser/http.ts b/packages/preview2-shim/src/browser/http.ts index 09b260ad1..6f44a7d9b 100644 --- a/packages/preview2-shim/src/browser/http.ts +++ b/packages/preview2-shim/src/browser/http.ts @@ -232,10 +232,6 @@ class OutgoingBody implements TypesNamespace.OutgoingBody { body.#resolveFinished(); } - static _bodyData(outgoingBody: OutgoingBody): Uint8Array | null { - return outgoingBody.#bodyData(); - } - #bodyData(): Uint8Array | null { if (this.#chunks.length === 0) { return null; @@ -281,9 +277,6 @@ class OutgoingBody implements TypesNamespace.OutgoingBody { const outgoingBodyCreate = OutgoingBody._create; // @ts-expect-error - Deleting static method delete OutgoingBody._create; -const outgoingBodyData = OutgoingBody._bodyData; -// @ts-expect-error - Deleting static method -delete OutgoingBody._bodyData; const outgoingBodyFinishedData = OutgoingBody._finishedBodyData; // @ts-expect-error - Deleting static method delete OutgoingBody._finishedBodyData; From bfd01f6617719f33dbaaaf1a311b3dcfe4e2dc25 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 24 Aug 2026 08:41:49 +0000 Subject: [PATCH 18/22] feat(p2-shim): allow browser request streaming --- packages/preview2-shim/README.md | 14 ++++ packages/preview2-shim/src/browser/http.ts | 85 +++++++++++++++++++--- packages/preview2-shim/test/test.ts | 45 +++++++++++- 3 files changed, 131 insertions(+), 13 deletions(-) diff --git a/packages/preview2-shim/README.md b/packages/preview2-shim/README.md index 0515d645f..f7c55d1c7 100644 --- a/packages/preview2-shim/README.md +++ b/packages/preview2-shim/README.md @@ -58,6 +58,20 @@ This preserves complete-body semantics across browsers but does not provide stre upload backpressure. Incoming Fetch bodies retain their asynchronous stream behavior. HTTP trailers are not implemented. +Chromium-based browsers can opt into Fetch request streaming. This setting uses a +`ReadableStream` request body with `duplex: "half"`; unsupported browsers reject the request, so +applications should enable it only after applying their own browser support policy or feature +detection: + +```js +import { http } from "@bytecodealliance/preview2-shim"; + +http._setRequestStreaming(true); +``` + +The setting affects subsequent requests made through the browser HTTP shim. Call +`http._setRequestStreaming(false)` to restore portable completion buffering. + Browser applications select storage explicitly. The bundled file-data adapter is ephemeral and must be opted into: diff --git a/packages/preview2-shim/src/browser/http.ts b/packages/preview2-shim/src/browser/http.ts index 6f44a7d9b..8461a336d 100644 --- a/packages/preview2-shim/src/browser/http.ts +++ b/packages/preview2-shim/src/browser/http.ts @@ -211,6 +211,9 @@ class OutgoingBody implements TypesNamespace.OutgoingBody { #finished = false; #resolveFinished!: () => void; #finishedPromise = new Promise((resolve) => (this.#resolveFinished = resolve)); + #requestStream: ReadableStream | null = null; + #requestStreamController: ReadableStreamDefaultController | null = null; + #requestStreamCancelled = false; write() { const outputStream = this.#outputStream; @@ -229,6 +232,9 @@ class OutgoingBody implements TypesNamespace.OutgoingBody { throw { tag: "internal-error", val: "body already finished" }; } body.#finished = true; + if (!body.#requestStreamCancelled) { + body.#requestStreamController?.close(); + } body.#resolveFinished(); } @@ -254,15 +260,41 @@ class OutgoingBody implements TypesNamespace.OutgoingBody { return outgoingBody.#bodyData(); } + static _requestBodyStream(outgoingBody: OutgoingBody): ReadableStream { + if (outgoingBody.#requestStream === null) { + outgoingBody.#requestStream = new ReadableStream({ + start(controller) { + outgoingBody.#requestStreamController = controller; + for (const chunk of outgoingBody.#chunks) { + controller.enqueue(chunk); + } + outgoingBody.#chunks.length = 0; + if (outgoingBody.#finished) { + controller.close(); + } + }, + cancel() { + outgoingBody.#requestStreamCancelled = true; + }, + }); + } + return outgoingBody.#requestStream; + } + static _create(): OutgoingBody { const outgoingBody = new OutgoingBody(); const chunks = outgoingBody.#chunks; outgoingBody.#outputStream = outputStreamCreate({ write(buf: Uint8Array): void { - if (outgoingBody.#finished) { + if (outgoingBody.#finished || outgoingBody.#requestStreamCancelled) { throw { tag: "closed" }; } - chunks.push(new Uint8Array(buf)); + const chunk = new Uint8Array(buf); + if (outgoingBody.#requestStreamController) { + outgoingBody.#requestStreamController.enqueue(chunk); + } else { + chunks.push(chunk); + } }, blockingFlush() {}, subscribe(): any { @@ -280,6 +312,13 @@ delete OutgoingBody._create; const outgoingBodyFinishedData = OutgoingBody._finishedBodyData; // @ts-expect-error - Deleting static method delete OutgoingBody._finishedBodyData; +const outgoingBodyRequestStream = OutgoingBody._requestBodyStream; +// @ts-expect-error - Deleting static method +delete OutgoingBody._requestBodyStream; + +interface BrowserHttpConfig { + streamingRequestBodies?: boolean; +} type Method = TypesNamespace.Method; type Scheme = TypesNamespace.Scheme; @@ -370,7 +409,11 @@ class OutgoingRequest implements TypesNamespace.OutgoingRequest { [symbolDispose]() {} - static _handle(request: OutgoingRequest, options?: RequestOptions): FutureIncomingResponse { + static _handle( + request: OutgoingRequest, + options?: RequestOptions, + config: BrowserHttpConfig = {}, + ): FutureIncomingResponse { const scheme = schemeString(request.#scheme); const method = "val" in request.#method ? request.#method.val : request.#method.tag; @@ -388,11 +431,13 @@ class OutgoingRequest implements TypesNamespace.OutgoingRequest { } } - // Fetch request streams are not consistently supported by browsers. Buffer the body, - // but do not dispatch until the guest has explicitly finished it. + // Request streams are opt-in because Firefox and Safari do not yet support them. + // The portable default buffers until the guest explicitly finishes the body. const bodyData = request.#bodyRequested - ? outgoingBodyFinishedData(request.#body) - : Promise.resolve(null); + ? config.streamingRequestBodies + ? outgoingBodyRequestStream(request.#body) + : outgoingBodyFinishedData(request.#body) + : null; let timeoutMs = Number(DEFAULT_HTTP_TIMEOUT_NS / 1_000_000n); if (options) { @@ -838,7 +883,7 @@ class FutureIncomingResponse implements TypesNamespace.FutureIncomingResponse { url: string, method: string, headers: Headers, - bodyData: Promise, + bodyData: Promise | ReadableStream | null, timeoutMs: number, ): FutureIncomingResponse { const future = new FutureIncomingResponse(); @@ -850,15 +895,18 @@ class FutureIncomingResponse implements TypesNamespace.FutureIncomingResponse { timer = setTimeout(() => controller.abort(), timeoutMs); } - future.#promise = bodyData + future.#promise = Promise.resolve(bodyData) .then((bodyData) => { - const init: RequestInit = { + const init: RequestInit & { duplex?: "half" } = { method, headers, signal: controller.signal, }; if (bodyData && method !== "GET" && method !== "HEAD") { init.body = bodyData as BodyInit; + if (bodyData instanceof ReadableStream) { + init.duplex = "half"; + } } return globalThis.fetch(url, init); }) @@ -921,9 +969,22 @@ function httpErrorCode(err: IoError): TypesNamespace.ErrorCode | undefined { }; } +let requestStreamingEnabled = false; + +/** Enable or disable Fetch `ReadableStream` request bodies. Disabled by default. */ +export function _setRequestStreaming(enabled: boolean): void { + if (typeof enabled !== "boolean") { + throw new TypeError("request streaming setting must be a boolean"); + } + requestStreamingEnabled = enabled; +} + export const outgoingHandler: typeof OutgoingHandlerNamespace = { - // @ts-expect-error Not matching signature in WIT - handle: outgoingRequestHandle, + handle(request, options) { + return outgoingRequestHandle(request as OutgoingRequest, options as RequestOptions, { + streamingRequestBodies: requestStreamingEnabled, + }); + }, }; export const incomingHandler: typeof IncomingHandlerNamespace = { diff --git a/packages/preview2-shim/test/test.ts b/packages/preview2-shim/test/test.ts index 1e1966362..13a296a6d 100644 --- a/packages/preview2-shim/test/test.ts +++ b/packages/preview2-shim/test/test.ts @@ -1652,7 +1652,9 @@ suite("Browser shim guards", () => { }); test("browser outgoing HTTP waits for the complete request body", async () => { - const { outgoingHandler, types } = await import("../src/browser/http.js"); + const { _setRequestStreaming, outgoingHandler, types } = + await import("../src/browser/http.js"); + _setRequestStreaming(false); const originalFetch = globalThis.fetch; let requestedBody: Uint8Array | undefined; let fetchCalls = 0; @@ -1690,6 +1692,47 @@ suite("Browser shim guards", () => { } }); + test("browser outgoing HTTP can stream request bodies to Fetch", async () => { + const { _setRequestStreaming, outgoingHandler, types } = + await import("../src/browser/http.js"); + const originalFetch = globalThis.fetch; + let fetchCalls = 0; + let duplex: string | undefined; + let bodyText: Promise | undefined; + globalThis.fetch = async (_input, init) => { + fetchCalls++; + duplex = (init as RequestInit & { duplex?: string }).duplex; + bodyText = new Response(init?.body).text(); + return new Response("ok"); + }; + _setRequestStreaming(true); + try { + const request = new types.OutgoingRequest(new types.Fields()); + request.setMethod({ tag: "post" }); + request.setScheme({ tag: "HTTPS" }); + request.setAuthority("example.com"); + request.setPathWithQuery("/"); + const body = request.body(); + const stream = body.write(); + stream.checkWrite(); + stream.write(new TextEncoder().encode("before ")); + + const response = outgoingHandler.handle(request, undefined); + await Promise.resolve(); + assert.strictEqual(fetchCalls, 1); + assert.strictEqual(duplex, "half"); + + stream.checkWrite(); + stream.write(new TextEncoder().encode("finish")); + types.OutgoingBody.finish(body, undefined); + assert.strictEqual(await bodyText, "before finish"); + await response.subscribe().block(); + } finally { + _setRequestStreaming(false); + globalThis.fetch = originalFetch; + } + }); + test("browser CLI factories isolate configuration and streams", async () => { const { createCli } = await import("../src/browser/cli.js"); const firstWrites: number[] = []; From 8d28cc5211cdc38d6409187d88ef9b8a44e6d57a Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 24 Aug 2026 08:42:25 +0000 Subject: [PATCH 19/22] refactor(p2-shim): name HTTP authority patterns --- packages/preview2-shim/src/browser/http.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/preview2-shim/src/browser/http.ts b/packages/preview2-shim/src/browser/http.ts index 8461a336d..7ce0a1bdc 100644 --- a/packages/preview2-shim/src/browser/http.ts +++ b/packages/preview2-shim/src/browser/http.ts @@ -17,6 +17,8 @@ const DEFAULT_HTTP_TIMEOUT_NS = 600_000_000_000n; // RFC 9110 compliant header validation const TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; const FIELD_VALUE_RE = /^[\t\x20-\x7E\x80-\xFF]*$/; +const BRACKETED_IPV6_AUTHORITY_RE = /^\[([0-9A-Fa-f:.]+)\](?::([0-9]+))?$/; +const DNS_OR_IPV4_AUTHORITY_RE = /^([a-zA-Z0-9.-]+)(?::([0-9]+))?$/; function validateHeaderName(name: string): void { if (!TOKEN_RE.test(name)) { @@ -386,8 +388,8 @@ class OutgoingRequest implements TypesNamespace.OutgoingRequest { setAuthority(authority: string | undefined) { if (authority !== undefined) { const match = authority.startsWith("[") - ? authority.match(/^\[([0-9A-Fa-f:.]+)\](?::([0-9]+))?$/) - : authority.match(/^([a-zA-Z0-9.-]+)(?::([0-9]+))?$/); + ? authority.match(BRACKETED_IPV6_AUTHORITY_RE) + : authority.match(DNS_OR_IPV4_AUTHORITY_RE); if (!match || (match[2] !== undefined && Number(match[2]) > 65535)) { throw undefined; } From 685f0e8034ebe13c92705001de0bd613c316fb42 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 24 Aug 2026 08:45:16 +0000 Subject: [PATCH 20/22] test(p2-shim): exercise Chromium request streaming --- packages/preview2-shim/test/browser.ts | 128 +++++++++++++++++- packages/preview2-shim/test/common.ts | 1 + .../fixtures/browser/basic-harness/index.html | 5 +- 3 files changed, 132 insertions(+), 2 deletions(-) diff --git a/packages/preview2-shim/test/browser.ts b/packages/preview2-shim/test/browser.ts index df8e27d94..bfb51941a 100644 --- a/packages/preview2-shim/test/browser.ts +++ b/packages/preview2-shim/test/browser.ts @@ -1,5 +1,8 @@ -import { writeFile, mkdir } from "node:fs/promises"; +import { writeFile, mkdir, readFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { createSecureServer } from "node:http2"; import { dirname } from "node:path"; +import { promisify } from "node:util"; import { suite, test, assert } from "vitest"; import { componentize, ComponentizeOptions } from "@bytecodealliance/componentize-js"; @@ -8,6 +11,7 @@ import { transpile } from "@bytecodealliance/jco"; import { getTmpDir, FIXTURES_WIT_DIR, startTestServer, runBasicHarnessPageTest } from "./common.js"; type TranspileOutput = { files: { [filename: string]: Uint8Array } }; +const execFileAsync = promisify(execFile); suite("browser", () => { test("native-fetch", async () => { @@ -38,6 +42,128 @@ suite("browser", () => { await cleanup(); }); + test("native-fetch-request-streaming", async () => { + const outDir = await getTmpDir(); + const keyPath = `${outDir}/localhost.key`; + const certPath = `${outDir}/localhost.crt`; + await execFileAsync("openssl", [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + keyPath, + "-out", + certPath, + "-subj", + "/CN=localhost", + "-addext", + "subjectAltName=DNS:localhost", + "-days", + "1", + ]); + const streamingServer = createSecureServer({ + key: await readFile(keyPath), + cert: await readFile(certPath), + }); + streamingServer.on("stream", (stream, headers) => { + if (headers[":method"] === "OPTIONS") { + stream.respond({ + ":status": 204, + "access-control-allow-origin": "*", + "access-control-allow-methods": "POST, OPTIONS", + "access-control-allow-headers": "*", + }); + stream.end(); + return; + } + const chunks: Uint8Array[] = []; + stream.on("data", (chunk) => chunks.push(chunk)); + stream.on("end", () => { + stream.respond({ + ":status": 200, + "content-type": "application/octet-stream", + "access-control-allow-origin": "*", + }); + stream.end(Buffer.concat(chunks)); + }); + }); + await new Promise((resolve) => streamingServer.listen(0, "localhost", resolve)); + const address = streamingServer.address(); + if (!address || typeof address === "string") { + throw new Error("unexpected HTTP/2 server address"); + } + const { baseURL, browser, cleanup } = await startTestServer({ + transpiledOutputDir: outDir, + }); + + const page = await browser.newPage(); + await page.goto(`${baseURL}/index.html`); + const result = await page.evaluate(async (serverPort) => { + const http = ( + globalThis as typeof globalThis & { + preview2ShimHttp: typeof import("../src/browser/http.js"); + } + ).preview2ShimHttp; + http._setRequestStreaming(true); + try { + const request = new http.types.OutgoingRequest(new http.types.Fields()); + request.setMethod({ tag: "post" }); + request.setScheme({ tag: "HTTPS" }); + request.setAuthority(`localhost:${serverPort}`); + request.setPathWithQuery("/post"); + const body = request.body(); + const stream = body.write(); + stream.checkWrite(); + stream.write(new TextEncoder().encode("before ")); + + const responseFuture = http.outgoingHandler.handle(request, undefined); + await Promise.resolve(); + stream.checkWrite(); + stream.write(new TextEncoder().encode("after")); + http.types.OutgoingBody.finish(body, undefined); + + await responseFuture.subscribe().block(); + const result = responseFuture.get(); + if (!result || result.tag === "err" || result.val.tag === "err") { + throw new Error(`streaming request failed: ${JSON.stringify(result)}`); + } + const response = result.val.val; + const incoming = response.consume(); + const input = incoming.stream(); + const chunks: Uint8Array[] = []; + try { + while (true) { + chunks.push(await input.blockingRead(65_536n)); + } + } catch (error) { + if ((error as { tag?: string }).tag !== "closed") { + throw error; + } + } + const length = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return { + status: response.status(), + body: new TextDecoder().decode(bytes), + }; + } finally { + http._setRequestStreaming(false); + } + }, address.port); + + assert.deepStrictEqual(result, { status: 200, body: "before after" }); + await page.close(); + await new Promise((resolve) => streamingServer.close(() => resolve())); + await cleanup(); + }); + test("http-fetch", async () => { const outDir = await getTmpDir(); diff --git a/packages/preview2-shim/test/common.ts b/packages/preview2-shim/test/common.ts index f15bffe80..5e436dfa0 100644 --- a/packages/preview2-shim/test/common.ts +++ b/packages/preview2-shim/test/common.ts @@ -246,6 +246,7 @@ export async function startTestServer(args: StartTestServerArgs): Promise Date: Mon, 24 Aug 2026 08:50:48 +0000 Subject: [PATCH 21/22] feat(p2-shim): delegate custom filesystem preopens --- packages/preview2-shim/README.md | 5 ++-- .../preview2-shim/src/common/instantiation.ts | 18 ++++++++---- packages/preview2-shim/test/test.ts | 29 +++++++++++++++++-- .../preview2-shim/types/instantiation.d.ts | 8 +++-- 4 files changed, 48 insertions(+), 12 deletions(-) diff --git a/packages/preview2-shim/README.md b/packages/preview2-shim/README.md index f7c55d1c7..4367db5f4 100644 --- a/packages/preview2-shim/README.md +++ b/packages/preview2-shim/README.md @@ -207,8 +207,9 @@ const component = await instantiate(loader, sandboxedShim.getImportObject()); explicitly injects filesystem namespaces or selects the ephemeral file-data adapter. - `sandbox.preopens` maps guest paths to Node.js host paths on Node.js. With `browserFilesystem`, the same option maps guest paths to capabilities understood by its adapter - and overrides `browserFilesystem.preopens`. A fully custom `filesystem` namespace owns its - preopens directly and cannot be combined with `sandbox.preopens`. + and overrides `browserFilesystem.preopens`. A custom `filesystem` can implement + `createPreopens(preopens)` to interpret the provided properties and return its own + `wasi:filesystem/preopens` namespace. The shim passes those properties through unchanged. - 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/common/instantiation.ts b/packages/preview2-shim/src/common/instantiation.ts index 8d1a1d439..e6265cc9f 100644 --- a/packages/preview2-shim/src/common/instantiation.ts +++ b/packages/preview2-shim/src/common/instantiation.ts @@ -134,16 +134,22 @@ export class WASIShim { }) : defaultCli); const defaultFilesystem = wasi.filesystem as any; - if (shims?.filesystem && sandbox?.preopens !== undefined) { - throw new TypeError( - "sandbox.preopens cannot configure an application-provided filesystem; provide its preopens namespace directly", - ); - } if (shims?.browserFilesystem && !defaultFilesystem.createFilesystem) { throw new TypeError("the selected filesystem does not support browser adapters"); } this.#filesystem = shims?.filesystem ?? defaultFilesystem; - if (shims?.browserFilesystem) { + if (shims?.filesystem && sandbox?.preopens !== undefined) { + if (!shims.filesystem.createPreopens) { + throw new TypeError( + "an application-provided filesystem must implement createPreopens to use sandbox.preopens", + ); + } + this.#filesystem = { + types: shims.filesystem.types, + preopens: shims.filesystem.createPreopens(sandbox.preopens), + dispose: shims.filesystem.dispose?.bind(shims.filesystem), + }; + } else if (shims?.browserFilesystem) { this.#filesystem = defaultFilesystem.createFilesystem({ adapter: shims.browserFilesystem.adapter, preopens: sandbox?.preopens ?? shims.browserFilesystem.preopens, diff --git a/packages/preview2-shim/test/test.ts b/packages/preview2-shim/test/test.ts index 13a296a6d..ac1f33f54 100644 --- a/packages/preview2-shim/test/test.ts +++ b/packages/preview2-shim/test/test.ts @@ -857,7 +857,32 @@ suite("Instantiation", () => { assert.strictEqual(imports["wasi:filesystem/preopens"], customPreopens); }); - test("WASIShim does not reinterpret custom filesystem preopens", async () => { + test("WASIShim delegates sandbox preopens to a custom filesystem", async () => { + const { WASIShim } = await import("@bytecodealliance/preview2-shim/instantiation"); + const { filesystem } = await import("@bytecodealliance/preview2-shim"); + const capability = { root: "application-owned" }; + const configuredPreopens = { "/guest": capability }; + const customPreopens = { getDirectories: () => [] }; + let receivedPreopens: Record | undefined; + const shim = new WASIShim({ + filesystem: { + types: filesystem.types, + preopens: { getDirectories: () => assert.fail("default preopens used") }, + createPreopens(preopens) { + receivedPreopens = preopens; + return customPreopens; + }, + }, + sandbox: { preopens: configuredPreopens }, + }); + const imports = shim.getImportObject(); + assert.strictEqual(receivedPreopens, configuredPreopens); + assert.strictEqual(receivedPreopens["/guest"], capability); + assert.strictEqual(imports["wasi:filesystem/types"], filesystem.types); + assert.strictEqual(imports["wasi:filesystem/preopens"], customPreopens); + }); + + test("WASIShim requires custom filesystems to interpret sandbox preopens", async () => { const { WASIShim } = await import("@bytecodealliance/preview2-shim/instantiation"); const { filesystem } = await import("@bytecodealliance/preview2-shim"); assert.throws( @@ -866,7 +891,7 @@ suite("Instantiation", () => { filesystem, sandbox: { preopens: { "/guest": "/host" } }, }), - /provide its preopens namespace directly/, + /must implement createPreopens/, ); }); }); diff --git a/packages/preview2-shim/types/instantiation.d.ts b/packages/preview2-shim/types/instantiation.d.ts index b9abf024f..d3cf0e7af 100644 --- a/packages/preview2-shim/types/instantiation.d.ts +++ b/packages/preview2-shim/types/instantiation.d.ts @@ -66,8 +66,8 @@ type AppendVersion * Sandbox configuration options for WASIShim */ interface SandboxConfig { - /** Node.js filesystem preopens mapping (virtual path -> host path). */ - preopens?: Record; + /** Filesystem-specific preopens mapping (virtual path -> host path or capability). */ + preopens?: Record; /** Environment variables visible to the guest */ env?: Record; /** Command-line arguments */ @@ -84,6 +84,10 @@ interface SandboxConfig { export interface FilesystemShim { preopens: typeof import('./interfaces/wasi-filesystem-preopens.d.ts'); types: typeof import('./interfaces/wasi-filesystem-types.d.ts'); + /** Interpret sandbox preopen properties and return this filesystem's preopens namespace. */ + createPreopens?( + preopens: Record, + ): typeof import('./interfaces/wasi-filesystem-preopens.d.ts'); dispose?(): void; } From 86f1adc48ef41690c77555203093609084f7a213 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 24 Aug 2026 09:23:08 +0000 Subject: [PATCH 22/22] test(p2-shim): add Map filesystem example --- packages/preview2-shim/README.md | 7 + .../test/filesystem-conformance.ts | 133 ++++++++++++++++++ .../fixtures/filesystem-shim/in-memory-map.ts | 65 +++++++++ .../preview2-shim/test/map-filesystem.test.ts | 9 ++ 4 files changed, 214 insertions(+) create mode 100644 packages/preview2-shim/test/filesystem-conformance.ts create mode 100644 packages/preview2-shim/test/fixtures/filesystem-shim/in-memory-map.ts create mode 100644 packages/preview2-shim/test/map-filesystem.test.ts diff --git a/packages/preview2-shim/README.md b/packages/preview2-shim/README.md index 4367db5f4..5eb3252c9 100644 --- a/packages/preview2-shim/README.md +++ b/packages/preview2-shim/README.md @@ -109,6 +109,13 @@ This keeps permission prompts, handle acquisition, persistence, and synchronizat application code. Raw TCP, UDP, and DNS are denied by default; outbound HTTP remains a separate `fetch` capability. +For a small application-owned implementation, see the +[Map-backed browser filesystem test shim](./test/fixtures/filesystem-shim/in-memory-map.ts). It keeps named +roots in an in-memory `Map`, implements `createPreopens`, and is intentionally example code rather +than a published or supported filesystem package. The example is exercised through the reusable +[filesystem implementation test suite](./test/filesystem-conformance.ts), which can also be pointed +at other implementations. + Browser filesystem adapters own the capabilities passed in `preopens` and the roots returned from `getRoot`. A root may be shared by multiple descriptors and preopen names; the adapter is therefore responsible for persistence and synchronization of shared mutations. Calling `dispose` on the diff --git a/packages/preview2-shim/test/filesystem-conformance.ts b/packages/preview2-shim/test/filesystem-conformance.ts new file mode 100644 index 000000000..c94aee584 --- /dev/null +++ b/packages/preview2-shim/test/filesystem-conformance.ts @@ -0,0 +1,133 @@ +import { assert, suite, test } from "vitest"; + +import { WASIShim } from "../src/common/instantiation.js"; +import type { FilesystemShim } from "../types/instantiation.js"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +export interface FilesystemTestSubject { + filesystem: FilesystemShim; + preopens: Record; +} + +export function testFilesystemImplementation( + name: string, + createSubject: () => FilesystemTestSubject, +) { + const createRoot = () => { + const subject = createSubject(); + const shim = new WASIShim({ + filesystem: subject.filesystem, + sandbox: { preopens: subject.preopens }, + }); + const directories = shim.getImportObject()["wasi:filesystem/preopens"].getDirectories(); + assert.strictEqual(directories.length, 1); + assert.strictEqual(directories[0][1], "/data"); + return directories[0][0]; + }; + + const readText = (descriptor: any) => decoder.decode(descriptor.read(1_000_000n, 0n)[0]); + + suite(name, () => { + test("preopens, stats, and file reads", () => { + const root = createRoot(); + + assert.strictEqual(root.getType(), "directory"); + assert.strictEqual(root.statAt({}, "hello.txt").type, "regular-file"); + const file = root.openAt({}, "hello.txt", {}, { read: true }); + assert.strictEqual(readText(file), "hello from a Map"); + }); + + test("file creation, writes, streams, truncation, and reopening", () => { + const root = createRoot(); + + const file = root.openAt( + {}, + "created.txt", + { create: true }, + { read: true, write: true }, + ); + assert.strictEqual(file.write(encoder.encode("first"), 0n), 5n); + let output = file.writeViaStream(5n); + output.checkWrite(); + output.write(encoder.encode(" second")); + output.blockingFlush(); + assert.strictEqual(readText(file), "first second"); + + file.setSize(5n); + assert.strictEqual(file.stat().size, 5n); + assert.strictEqual(readText(file), "first"); + + const reopened = root.openAt({}, "created.txt", {}, { read: true }); + assert.strictEqual(readText(reopened), "first"); + root.openAt({}, "created.txt", { truncate: true }, { write: true }); + assert.strictEqual(reopened.stat().size, 0n); + }); + + test("directory creation, traversal, entries, and removal", () => { + const root = createRoot(); + + root.createDirectoryAt("nested"); + const nested = root.openAt( + {}, + "nested", + { directory: true }, + { mutateDirectory: true }, + ); + nested.openAt({}, "b.txt", { create: true }, { write: true }); + nested.openAt({}, "a.txt", { create: true }, { write: true }); + + const entries = nested.readDirectory(); + const names: string[] = []; + for ( + let entry = entries.readDirectoryEntry(); + entry; + entry = entries.readDirectoryEntry() + ) { + names.push(entry.name); + } + assert.deepStrictEqual(names, ["a.txt", "b.txt"]); + + nested.unlinkFileAt("a.txt"); + nested.unlinkFileAt("b.txt"); + root.removeDirectoryAt("nested"); + assert.throws(() => root.statAt({}, "nested")); + }); + + test("renames, hard links, identity, and link counts", () => { + const root = createRoot(); + + root.renameAt("hello.txt", root, "renamed.txt"); + const renamed = root.openAt({}, "renamed.txt", {}, { read: true }); + assert.strictEqual(readText(renamed), "hello from a Map"); + assert.throws(() => root.statAt({}, "hello.txt")); + + root.linkAt({}, "renamed.txt", root, "linked.txt"); + const linked = root.openAt({}, "linked.txt", {}, { read: true }); + assert.strictEqual(renamed.isSameObject(linked), true); + assert.strictEqual(renamed.stat().linkCount, 2n); + assert.deepStrictEqual(renamed.metadataHash(), linked.metadataHash()); + + root.unlinkFileAt("renamed.txt"); + assert.strictEqual(linked.stat().linkCount, 1n); + assert.strictEqual(readText(linked), "hello from a Map"); + }); + + test("metadata changes and path validation", () => { + const root = createRoot(); + const file = root.openAt({}, "hello.txt", {}, { read: true, write: true }); + const before = file.metadataHash(); + + file.setTimes({ tag: "now" }, { tag: "now" }); + assert.notDeepEqual(file.metadataHash(), before); + assert.deepStrictEqual(root.metadataHashAt({}, "hello.txt"), file.metadataHash()); + + assert.throws(() => + root.openAt({}, "missing/child", { create: true }, { write: true }), + ); + assert.throws(() => root.createDirectoryAt("scratch")); + assert.throws(() => root.removeDirectoryAt("hello.txt")); + }); + }); +} diff --git a/packages/preview2-shim/test/fixtures/filesystem-shim/in-memory-map.ts b/packages/preview2-shim/test/fixtures/filesystem-shim/in-memory-map.ts new file mode 100644 index 000000000..3edfec764 --- /dev/null +++ b/packages/preview2-shim/test/fixtures/filesystem-shim/in-memory-map.ts @@ -0,0 +1,65 @@ +import type { FilesystemShim } from "../../../types/instantiation.js"; +import type { FileData } from "../../../src/browser/filesystem.js"; + +type BrowserFilesystemModule = Pick< + typeof import("../../../src/browser/filesystem.js"), + "createFilesystem" +>; + +/** + * Minimal example of an application-owned browser filesystem shim. + * + * Named roots and all of their file data live only in this Map. The preview2 + * browser filesystem supplies the WASI descriptor implementation; this shim + * decides what application capability each sandbox preopen property names. + */ +export class MapFilesystemShim implements FilesystemShim { + readonly roots = new Map(); + readonly types; + readonly preopens; + readonly #browserFilesystem: BrowserFilesystemModule; + + constructor(browserFilesystem: BrowserFilesystemModule) { + this.#browserFilesystem = browserFilesystem; + const empty = this.#createFilesystem({}); + this.types = empty.types; + this.preopens = empty.preopens; + } + + createPreopens(preopens: Record) { + const capabilities: Record = {}; + for (const [guestPath, property] of Object.entries(preopens)) { + if (typeof property !== "string") { + throw new TypeError(`Map filesystem preopen ${guestPath} must name a root`); + } + capabilities[guestPath] = property; + } + return this.#createFilesystem(capabilities).preopens; + } + + #createFilesystem(preopens: Record) { + return this.#browserFilesystem.createFilesystem({ + adapter: { + getRoot: (name: string) => { + const root = this.roots.get(name); + if (!root) { + throw new TypeError(`unknown Map filesystem root ${JSON.stringify(name)}`); + } + return root; + }, + }, + preopens, + }); + } +} + +export function createMapFilesystemShim(browserFilesystem: BrowserFilesystemModule) { + const filesystem = new MapFilesystemShim(browserFilesystem); + filesystem.roots.set("data", { + dir: { + "hello.txt": { source: "hello from a Map" }, + scratch: { dir: {} }, + }, + }); + return filesystem; +} diff --git a/packages/preview2-shim/test/map-filesystem.test.ts b/packages/preview2-shim/test/map-filesystem.test.ts new file mode 100644 index 000000000..10841dc84 --- /dev/null +++ b/packages/preview2-shim/test/map-filesystem.test.ts @@ -0,0 +1,9 @@ +import { createMapFilesystemShim } from "./fixtures/filesystem-shim/in-memory-map.js"; +import { testFilesystemImplementation } from "./filesystem-conformance.js"; + +const browserFilesystem = await import("../src/browser/filesystem.js"); + +testFilesystemImplementation("Map-backed browser filesystem example", () => ({ + filesystem: createMapFilesystemShim(browserFilesystem), + preopens: { "/data": "data" }, +}));