diff --git a/docs/src/introduction.md b/docs/src/introduction.md
index 090da62ae..edb5a0567 100644
--- a/docs/src/introduction.md
+++ b/docs/src/introduction.md
@@ -5,7 +5,7 @@
## Features
- [Transpiling](./transpiling.md) Wasm Component binaries into [ECMAScript modules](https://nodejs.org/api/esm.html#modules-ecmascript-modules) that can run in any JavaScript environment.
-- WASI Preview2 support in Node.js & browsers (experimental).
+- WASI Preview2 support in Node.js & browsers.
- Component builds of Wasm Tools helpers, available for use as a library or CLI commands for use in native JS environments
- Optimization helper for Components via Binaryen.
- `componentize` command to easily create components written in JavaScript (wrapper of [ComponentizeJS](https://github.com/bytecodealliance/ComponentizeJS)).
diff --git a/docs/src/transpiling.md b/docs/src/transpiling.md
index bcef19cf3..b94cf224c 100644
--- a/docs/src/transpiling.md
+++ b/docs/src/transpiling.md
@@ -179,7 +179,8 @@ For all subsystems - `cli`, `clocks`, `filesystem`, `http`, `io`, `random` and `
To disable this automatic WASI handling the `--no-wasi-shim` flag can be provided and WASI will be treated like any other import without special handling.
-Note that browser support for WASI is currently experimental.
+Browser WASI support is subject to web-platform capability limitations; some interfaces require
+application-provided adapters.
### Interface Implementation Example
diff --git a/examples/components/fs-write-file/README.md b/examples/components/fs-write-file/README.md
index 525fc2ef7..2f5dae439 100644
--- a/examples/components/fs-write-file/README.md
+++ b/examples/components/fs-write-file/README.md
@@ -29,7 +29,7 @@ pnpm install
At this point, since this project is *just* NodeJS, you could use the module from any NodeJS project or browser project where appropriate.
That said, we'll be focusing on building the JS code we've written so far into a WebAssembly binary, which can run *anywhere*
-WebAssembly runtimes are supported, including in other languages, and the browser (experimental support).
+WebAssembly runtimes are supported, including in other languages and the browser.
## Building the WebAssembly component
diff --git a/examples/transpile/README.md b/examples/transpile/README.md
index 5dbb2a095..424ab6ef5 100644
--- a/examples/transpile/README.md
+++ b/examples/transpile/README.md
@@ -4,9 +4,6 @@ This folder contains examples of how to use `@bytecodealliance/jco-transpile` di
converting a WebAssembly component into a Javascript ES module that can be run from JS
runtimes like [NodeJS][nodejs] and the browser.
-> [!WARNING]
-> Browser support is still experimental
-
Most (if not all) individual example projects are standard Javascript projects, and since we are focused on
transpiling existing components, they may contain a pre-built WebAssembly binary that is transpiled.
diff --git a/packages/jco-std/README.md b/packages/jco-std/README.md
index 9dbe2b5de..7b5c4c132 100644
--- a/packages/jco-std/README.md
+++ b/packages/jco-std/README.md
@@ -9,9 +9,6 @@ an evolving architecture for interoperabl WebAssembly libraries, aplications and
WebAssembly components can be used from server side applications _and_ in the browser, and
`@bytecodealliance/jco-std` contains shared functionality and helpers for both environments.
-> [!WARNING]
-> Browser support is considered experimental, and not currently suitable for production applications.
-
[cm-book]: https://component-model.bytecodealliance.org/
[jco]: https://www.npmjs.com/package/@bytecodealliance/jco
diff --git a/packages/jco-transpile/README.md b/packages/jco-transpile/README.md
index d73e03569..7840ce497 100644
--- a/packages/jco-transpile/README.md
+++ b/packages/jco-transpile/README.md
@@ -1,14 +1,11 @@
# `@bytecodealliance/jco-transpile`
This [`@bytecodealliance/jco`][jco] sub-project enables transpilation of [WebAssembly Components][cm-book] into ES modules
-that can be run in Javascript environments like NodeJS and the browser (experimental).
+that can be run in Javascript environments like NodeJS and the browser.
`@bytecodealliance/jco-transpile` is used primarily when only transpilation functionality of `jco` is needed,
and `jco` derives it's use of transpilation from this library.
-> [!WARNING]
-> Browser support is considered experimental, and not currently suitable for production applications.
-
[cm-book]: https://component-model.bytecodealliance.org/
[jco]: https://www.npmjs.com/package/@bytecodealliance/jco
diff --git a/packages/jco-transpile/test/browser/cases.js b/packages/jco-transpile/test/browser/cases.js
new file mode 100644
index 000000000..f04770bec
--- /dev/null
+++ b/packages/jco-transpile/test/browser/cases.js
@@ -0,0 +1,59 @@
+import { $init, generate as _generate } from '../../vendor/js-component-bindgen-component.js';
+
+await $init;
+
+const wasiMap = [
+ ['wasi:cli/*', '@bytecodealliance/preview2-shim/cli#*'],
+ ['wasi:clocks/*', '@bytecodealliance/preview2-shim/clocks#*'],
+ ['wasi:filesystem/*', '@bytecodealliance/preview2-shim/filesystem#*'],
+ ['wasi:http/*', '@bytecodealliance/preview2-shim/http#*'],
+ ['wasi:io/*', '@bytecodealliance/preview2-shim/io#*'],
+ ['wasi:random/*', '@bytecodealliance/preview2-shim/random#*'],
+ ['wasi:sockets/*', '@bytecodealliance/preview2-shim/sockets#*'],
+];
+
+export async function transpile() {
+ const componentUrl = new URL('../fixtures/components/runtime/lists.component.wasm', import.meta.url);
+ const component = await (await fetch(componentUrl)).arrayBuffer();
+ const output = await _generate(component, {
+ name: 'test',
+ noTypescript: true,
+ noNodejsCompat: true,
+ instantiation: { tag: 'async' },
+ base64Cutoff: 1_000_000,
+ map: wasiMap,
+ });
+ const source = output.files.find(([name]) => name === 'test.js')?.[1];
+ if (!source) {
+ throw new Error(`transpile output did not contain test.js: ${output.files.map(([name]) => name)}`);
+ }
+
+ const url = URL.createObjectURL(new Blob([source], { type: 'text/javascript' }));
+ try {
+ await import(url);
+ } finally {
+ URL.revokeObjectURL(url);
+ }
+}
+
+export async function jspi(modulePath) {
+ const module = await import(modulePath);
+ const instance = await module.instantiate(undefined, {
+ 'something:test/test-interface': {
+ callAsync: () => new Promise((resolve) => setTimeout(() => resolve('callAsync'), 50)),
+ callSync: () => 'callSync',
+ },
+ });
+
+ let ticks = 0;
+ const interval = setInterval(() => ticks++, 5);
+ try {
+ const responseText = await instance.runAsync();
+ if (ticks < 2) {
+ throw new Error(`event loop was blocked during JSPI call; observed only ${ticks} ticks`);
+ }
+ return { responseText };
+ } finally {
+ clearInterval(interval);
+ }
+}
diff --git a/packages/jco-transpile/test/browser/general.ts b/packages/jco-transpile/test/browser/general.ts
deleted file mode 100644
index 27d2ed928..000000000
--- a/packages/jco-transpile/test/browser/general.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-import { mkdir, readFile, rm, symlink } from 'node:fs/promises';
-import { createServer, Server } from 'node:http';
-import { resolve, extname, join } from 'node:path';
-import { env } from 'node:process';
-import { fileURLToPath, pathToFileURL } from 'node:url';
-
-import puppeteer from 'puppeteer';
-import type { Browser } from 'puppeteer';
-import mime from 'mime';
-
-import { suite, test, beforeAll, afterAll, afterEach, assert, vi } from 'vitest';
-
-import { transpileBytes, writeFiles } from '../../src/index.js';
-import { componentize } from '@bytecodealliance/componentize-js';
-
-import { getRandomPort, getTmpDir } from '../helpers.js';
-import { WEBIDL_FIXTURES_DIR } from '../common.js';
-
-suite('Browser', () => {
- let tmpDir: string;
- let outDir: string;
- let outFile: string;
- let outDirUrl: URL;
- let server: Server;
- let port: number;
- let browser: Browser;
-
- beforeAll(async function () {
- tmpDir = await getTmpDir();
- outDir = resolve(tmpDir, 'out-component-dir');
- outDirUrl = pathToFileURL(outDir + '/');
- outFile = resolve(tmpDir, 'out-component-file');
- port = await getRandomPort();
-
- const modulesDir = resolve(tmpDir, 'node_modules', '@bytecodealliance');
- await mkdir(modulesDir, { recursive: true });
- await symlink(
- fileURLToPath(new URL('../../preview2-shim', import.meta.url)),
- resolve(modulesDir, 'preview2-shim'),
- 'dir',
- );
-
- // run a local server on some port
- server = createServer(async (req, res) => {
- let fileUrl;
- if (req.url === undefined) {
- throw new Error('undefined url');
- }
- if (req.url.startsWith('/tmpdir/')) {
- fileUrl = new URL(`.${req.url.slice(7)}`, outDirUrl);
- } else {
- fileUrl = new URL(`../../../${req.url}`, import.meta.url);
- }
- try {
- const html = await readFile(fileUrl);
- res.writeHead(200, {
- 'content-type': mime.getType(extname(req.url)),
- });
- res.end(html);
- } catch (e) {
- if (e.code === 'ENOENT') {
- res.writeHead(404);
- res.end(e.message);
- } else {
- res.writeHead(500);
- res.end(e.message);
- }
- }
- }).listen(port);
-
- // Wait until the server is ready
- await vi.waitUntil(
- async () => {
- try {
- // NOTE: we only need the request to succeed to know the server is running
- // requesting the root will actually return a 500 due to attempt to open a dir
- await fetch(`http://localhost:${port}`);
- return true;
- } catch (err) {
- console.log('ERROR:', err);
- return false;
- }
- },
- {
- timeout: 30_000,
- interval: 500,
- },
- );
-
- browser = await puppeteer.launch({
- executablePath: env.PUPPETEER_PATH,
- });
- });
-
- afterAll(async function () {
- try {
- await rm(tmpDir, { recursive: true });
- } catch {}
- await browser.close();
- await new Promise((resolve) => server.close(resolve));
- });
-
- afterEach(async function () {
- try {
- await rm(outDir, { recursive: true });
- await rm(outFile);
- } catch {}
- });
-
- test('basic transpile', { retry: 3 }, async () => {
- await testPage({ browser, port, hash: 'transpile' });
- });
-
- test('IDL window', async () => {
- const { component } = await componentize({
- sourcePath: join(WEBIDL_FIXTURES_DIR, 'dom.test.js'),
- disableFeatures: ['clocks', 'random', 'stdio'],
- witPath: join(WEBIDL_FIXTURES_DIR, 'dom.wit'),
- worldName: 'window-test',
- });
-
- // Transpile the test component
- const { files } = await transpileBytes(component, { name: 'dom' });
- await writeFiles(files, { baseDir: outDir });
-
- // Run the test function in the browser from the generated tmpdir
- await testPage({ browser, port, hash: 'test:dom.js' });
- });
-
- test('IDL console', async () => {
- const { component } = await componentize({
- sourcePath: join(WEBIDL_FIXTURES_DIR, 'console.test.js'),
- disableFeatures: ['clocks', 'random', 'stdio'],
- witPath: join(WEBIDL_FIXTURES_DIR, 'console.wit'),
- worldName: 'console-test',
- });
-
- // Transpile the test component
- const { files } = await transpileBytes(component, { name: 'console' });
- await writeFiles(files, { baseDir: outDir });
-
- await testPage({ browser, port, hash: 'test:console.js' });
- });
-});
-
-/**
- * Test an individual browser page that is set up to do a transpilation
- * (see browser.html)
- *
- * NOTE: This test can fail if you are missing built outputs (e.g. in packages/jco/obj)
- * or transpilation output, or have bad code coming out of component bindgen.
- *
- * If you find no output at all from the page, it's likely that loading a script
- * has failed (something as simple as an incorrect path in the importmap, or missing
- * dependency, dependency with bad code, etc.), and you should likely enable puppeteer
- * logging.
- *
- * Consider setting JCO_DEBUG=true to see output from the headless browser.
- *
- */
-export async function testPage(args) {
- const { browser, port, hash, expectedBodyContent } = args;
- const page = await browser.newPage();
- if (env.JCO_DEBUG) {
- page.on('console', (msg) => console.log('[browser]', msg.text()));
- }
-
- assert.ok((await page.goto(`http://localhost:${port}/jco-transpile/test/browser/index.html#${hash}`)).ok());
-
- const body = await page.locator('body').waitHandle();
-
- let bodyHtml = await body.evaluate((el) => el.innerHTML);
- while (bodyHtml === '
Running
') {
- bodyHtml = await body.evaluate((el) => el.innerHTML);
- }
- const expectedContent = expectedBodyContent ?? 'OK
';
- assert.strictEqual(bodyHtml, expectedContent);
- await page.close();
-}
diff --git a/packages/jco-transpile/test/browser/harness.html b/packages/jco-transpile/test/browser/harness.html
new file mode 100644
index 000000000..b23fb7882
--- /dev/null
+++ b/packages/jco-transpile/test/browser/harness.html
@@ -0,0 +1,52 @@
+
+
+jco browser test
+
+
+ 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;
}
diff --git a/packages/jco/README.md b/packages/jco/README.md
index 952266c26..fc792f9d1 100644
--- a/packages/jco/README.md
+++ b/packages/jco/README.md
@@ -25,7 +25,7 @@ Jco provides a fully native JS toolchain for working with [WebAssembly Component
Features include:
- "Transpiling" Wasm Component binaries into ES modules that can run in any JS environment.
-- WASI Preview2 support in Node.js & browsers (experimental).
+- WASI Preview2 support in Node.js & browsers.
- Component builds of [Wasm Tools](https://github.com/bytecodealliance/wasm-tools) helpers, available for use as a library or CLI commands for use in native JS environments, as well as optimization helper for Components via Binaryen.
- Run and serve commands like Wasmtime, as JS implementations of the Command and HTTP Proxy worlds.
- "Componentize" command to easily create components written in JavaScript (wrapper of [ComponentizeJS](https://github.com/bytecodealliance/ComponentizeJS)).
diff --git a/packages/preview2-shim/README.md b/packages/preview2-shim/README.md
index 17fd1d4c9..5eb3252c9 100644
--- a/packages/preview2-shim/README.md
+++ b/packages/preview2-shim/README.md
@@ -4,12 +4,127 @@ WASI Preview2 implementations for Node.js & browsers.
Node.js support is fully tested and conformant against the Wasmtime test suite.
-Browser support is considered experimental, and not currently suitable for production applications.
+Browser support is available with the platform limitations documented below.
The Node.js implementation owns its worker artifact. Direct package use and supported downstream
bundlers should resolve it through the public shim imports; applications do not need to import or
copy files from `dist/io`.
+## Browser support matrix
+
+Browser defaults are capability-safe: clocks and secure randomness use Web APIs, stdout and stderr
+write to the console, stdin is closed, outbound HTTP uses `fetch`, filesystem preopens must be
+configured explicitly, and raw sockets are unavailable unless an embedding supplies an adapter.
+
+| WASI area | Browser status | Default capability |
+| ----------------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------- |
+| CLI environment and arguments | Configurable per `WASIShim`; compatibility setters are global | Empty snapshots unless configured |
+| CLI stdin | Adapter-backed | Closed stream |
+| CLI stdout and stderr | Web API | Console-backed, preserving split UTF-8 writes until flush/newline |
+| CLI terminals | Adapter-backed | No terminal resource |
+| Clocks | Web API | `performance.now`, `Date.now`, and timer-backed pollables |
+| Random | Web API | `crypto.getRandomValues`, including requests larger than 64 KiB |
+| I/O streams and poll | Implemented browser resources | Non-blocking streams depend on their injected handlers |
+| Filesystem | Adapter-backed; opt-in in-memory compatibility implementation | No persistent storage is selected implicitly |
+| Outbound HTTP | Web API | Delegates to `fetch` |
+| Incoming HTTP | Host adapter required | Browsers cannot listen for arbitrary inbound HTTP |
+| TCP, UDP, and DNS | Host adapter required | Raw sockets are not exposed by standard browsers |
+| `WASIShim` instantiation | Implemented | Interface namespaces can be overridden per instance |
+
+An operation is not considered supported merely because its interface shape exists. Adapter-backed
+rows require the embedding application to provide that capability; unavailable operations fail with
+a WASI-domain error instead of logging or returning a placeholder resource.
+
+### 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.
+
+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:
+
+```js
+import { filesystem } from "@bytecodealliance/preview2-shim";
+import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation";
+
+const shim = new WASIShim({
+ environment: { MODE: "browser" },
+ arguments: ["component"],
+ stdout: { write: (bytes) => terminal.write(bytes) },
+ browserFilesystem: {
+ adapter: new filesystem.InMemoryFilesystemAdapter(),
+ preopens: { "/data": { dir: {} } },
+ },
+ sandbox: { enableNetwork: false },
+});
+```
+
+The browser shim does not request File System Access permissions or choose IndexedDB/OPFS on an
+application's behalf. Applications that need another storage model implement the generated
+`wasi:filesystem/types` and `wasi:filesystem/preopens` namespaces and inject them through the
+`filesystem` option:
+
+```js
+const shim = new WASIShim({
+ filesystem: {
+ types: applicationFilesystemTypes,
+ preopens: applicationFilesystemPreopens,
+ },
+});
+```
+
+This keeps permission prompts, handle acquisition, persistence, and synchronization policy in
+application code. Raw TCP, UDP, and DNS are denied by default; outbound HTTP remains a separate
+`fetch` capability.
+
+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
+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
@@ -57,9 +172,9 @@ const component = await instantiate(loader, new WASIShim().getImportObject());
## Sandboxing
-By default, the preview2-shim provides full access to the host filesystem, environment variables,
-and network - matching the default behavior of Node.js libraries. However, you can configure
-sandboxing to restrict what guests can access.
+On Node.js, the preview2-shim provides host filesystem, environment, and network access by default,
+matching the usual behavior of Node.js libraries. Browser defaults expose no filesystem preopens or
+raw sockets. Both platforms can configure which capabilities a guest receives.
### Using WASIShim for sandboxing
@@ -78,7 +193,7 @@ const sandboxedShim = new WASIShim({
},
});
-// Limited filesystem access - map virtual paths to host paths
+// Node.js only: map virtual paths to host paths
const limitedShim = new WASIShim({
sandbox: {
preopens: {
@@ -95,13 +210,21 @@ const component = await instantiate(loader, sandboxedShim.getImportObject());
### Notes on sandboxing
- By default (when no options are passed), the shim is providing full access to match typical
- Node.js library behavior.
+ Node.js library behavior. In browsers, filesystem preopens remain empty until the application
+ explicitly injects filesystem namespaces or selects the ephemeral file-data adapter.
+- `sandbox.preopens` maps guest paths to Node.js host paths on Node.js. With
+ `browserFilesystem`, the same option maps guest paths to capabilities understood by its adapter
+ 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
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/src/browser/cli.ts b/packages/preview2-shim/src/browser/cli.ts
index 5f191ad9d..b050aa336 100644
--- a/packages/preview2-shim/src/browser/cli.ts
+++ b/packages/preview2-shim/src/browser/cli.ts
@@ -1,4 +1,5 @@
import type {
+ environment as EnvironmentNamespace,
exit as ExitNamespace,
stderr as StderrNamespace,
stdin as StdinNamespace,
@@ -52,45 +53,61 @@ export function _setStdout(handler: OutputStreamHandler): void {
stdoutStream.handler = handler;
}
+export interface BrowserCliConfig {
+ environment?: Record;
+ arguments?: string[];
+ initialCwd?: string;
+ stdin?: InputStreamHandler;
+ stdout?: OutputStreamHandler;
+ stderr?: OutputStreamHandler;
+}
+
const stdinStream = inputStreamCreate({
- blockingRead(_len: bigint) {
- // TODO
- return new Uint8Array(0);
+ blockingRead() {
+ throw { tag: "closed" };
},
subscribe() {
- // TODO
return pollableCreate();
},
- [symbolDispose]() {
- // TODO
- },
+ [symbolDispose]() {},
});
-const textDecoder = new TextDecoder();
+function consoleStream(writeLine: (line: string) => void): OutputStreamHandler {
+ const decoder = new TextDecoder();
+ let pending = "";
-const stdoutStream = outputStreamCreate({
- write(contents: Uint8Array): void {
- if (contents.at(-1) == 10) {
- // console.log already appends a new line
- contents = contents.subarray(0, -1);
+ const emitCompleteLines = () => {
+ const lines = pending.split("\n");
+ pending = lines.pop()!;
+ for (const line of lines) {
+ writeLine(line.endsWith("\r") ? line.slice(0, -1) : line);
}
- console.log(textDecoder.decode(contents));
- },
- blockingFlush() {},
- [symbolDispose]() {},
-});
+ };
+
+ return {
+ write(contents: Uint8Array) {
+ pending += decoder.decode(contents, { stream: true });
+ emitCompleteLines();
+ },
+ flush() {
+ pending += decoder.decode();
+ if (pending) {
+ writeLine(pending);
+ }
+ pending = "";
+ },
+ blockingFlush() {
+ this.flush?.();
+ },
+ drop() {
+ this.flush?.();
+ },
+ };
+}
-const stderrStream = outputStreamCreate({
- write(contents: Uint8Array): void {
- if (contents.at(-1) == 10) {
- // console.error already appends a new line
- contents = contents.subarray(0, -1);
- }
- console.error(textDecoder.decode(contents));
- },
- blockingFlush() {},
- [symbolDispose]() {},
-});
+const stdoutStream = outputStreamCreate(consoleStream((line) => console.log(line)));
+
+const stderrStream = outputStreamCreate(consoleStream((line) => console.error(line)));
export const stdin: typeof StdinNamespace = {
getStdin() {
@@ -113,10 +130,6 @@ export const stderr: typeof StderrNamespace = {
class TerminalInput implements TerminalInputNamespace.TerminalInput {}
class TerminalOutput implements TerminalOutputNamespace.TerminalOutput {}
-const terminalStdoutInstance = new TerminalOutput();
-const terminalStderrInstance = new TerminalOutput();
-const terminalStdinInstance = new TerminalInput();
-
export const terminalInput: typeof TerminalInputNamespace = {
TerminalInput,
};
@@ -127,18 +140,67 @@ export const terminalOutput: typeof TerminalOutputNamespace = {
export const terminalStderr: typeof TerminalStderrNamespace = {
getTerminalStderr() {
- return terminalStderrInstance;
+ return undefined;
},
};
export const terminalStdin: typeof TerminalStdinNamespace = {
getTerminalStdin() {
- return terminalStdinInstance;
+ return undefined;
},
};
export const terminalStdout: typeof TerminalStdoutNamespace = {
getTerminalStdout() {
- return terminalStdoutInstance;
+ return undefined;
},
};
+
+/** Create isolated browser CLI interfaces without changing compatibility globals. */
+export function createCli(config: BrowserCliConfig = {}): {
+ environment: typeof EnvironmentNamespace;
+ exit: typeof ExitNamespace;
+ stdin: typeof StdinNamespace;
+ stdout: typeof StdoutNamespace;
+ stderr: typeof StderrNamespace;
+ terminalInput: typeof TerminalInputNamespace;
+ terminalOutput: typeof TerminalOutputNamespace;
+ terminalStdin: typeof TerminalStdinNamespace;
+ terminalStdout: typeof TerminalStdoutNamespace;
+ terminalStderr: typeof TerminalStderrNamespace;
+} {
+ const stdinInstance = inputStreamCreate(
+ config.stdin ?? {
+ blockingRead() {
+ throw { tag: "closed" };
+ },
+ subscribe: () => pollableCreate(),
+ },
+ );
+ const stdoutInstance = outputStreamCreate(
+ config.stdout ?? consoleStream((line) => console.log(line)),
+ );
+ const stderrInstance = outputStreamCreate(
+ config.stderr ?? consoleStream((line) => console.error(line)),
+ );
+ const env = Object.entries(config.environment ?? {});
+ const args = [...(config.arguments ?? [])];
+ const cwd = config.initialCwd ?? "/";
+
+ return {
+ environment: {
+ getEnvironment: () => env.map(([key, value]) => [key, value] as [string, string]),
+ getArguments: () => [...args],
+ initialCwd: () => cwd,
+ },
+ exit,
+ stdin: { getStdin: () => stdinInstance },
+ stdout: { getStdout: () => stdoutInstance },
+ stderr: { getStderr: () => stderrInstance },
+ terminalInput,
+ terminalOutput,
+ terminalStdin,
+ terminalStdout,
+ terminalStderr,
+ };
+}
diff --git a/packages/preview2-shim/src/browser/clocks.ts b/packages/preview2-shim/src/browser/clocks.ts
index d8128cb9e..59737cf5b 100644
--- a/packages/preview2-shim/src/browser/clocks.ts
+++ b/packages/preview2-shim/src/browser/clocks.ts
@@ -4,6 +4,32 @@ import type {
} from "../../types/clocks.js";
import { pollableCreate } from "./io.js";
+const MAX_TIMEOUT_MS = 0x7fffffff;
+const MAX_U64 = (1n << 64n) - 1n;
+
+function checkedInstant(value: bigint, name: string): bigint {
+ if (typeof value !== "bigint" || value < 0n || value > MAX_U64) {
+ throw new TypeError(`${name} must be a valid u64`);
+ }
+ return value;
+}
+
+function timeout(durationNs: bigint): Promise {
+ let remainingMs = Number((durationNs + 999_999n) / 1_000_000n);
+ return new Promise((resolve) => {
+ const next = () => {
+ if (remainingMs <= 0) {
+ resolve();
+ return;
+ }
+ const delay = Math.min(remainingMs, MAX_TIMEOUT_MS);
+ remainingMs -= delay;
+ setTimeout(next, delay);
+ };
+ next();
+ });
+}
+
export const monotonicClock: typeof MonotonicClockNamespace = {
resolution(): bigint {
// usually we dont get sub-millisecond accuracy in the browser
@@ -15,17 +41,19 @@ export const monotonicClock: typeof MonotonicClockNamespace = {
return BigInt(Math.floor(performance.now() * 1e6));
},
subscribeInstant(instant: bigint) {
- instant = BigInt(instant);
+ instant = checkedInstant(instant, "instant");
const now = monotonicClock.now();
if (instant <= now) {
- return pollableCreate(new Promise((resolve) => setTimeout(resolve, 0)));
+ return pollableCreate();
}
return monotonicClock.subscribeDuration(instant - now);
},
subscribeDuration(duration: bigint) {
- duration = BigInt(duration);
- const ms = duration <= 0n ? 0 : Number(duration / 1_000_000n);
- return pollableCreate(new Promise((resolve) => setTimeout(resolve, ms)));
+ duration = checkedInstant(duration, "duration");
+ if (duration === 0n) {
+ return pollableCreate();
+ }
+ return pollableCreate(timeout(duration));
},
};
diff --git a/packages/preview2-shim/src/browser/filesystem.ts b/packages/preview2-shim/src/browser/filesystem.ts
index 7095c5199..e39baf78a 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 || "/");
}
@@ -68,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 !== "/") {
@@ -91,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);
@@ -106,6 +126,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);
@@ -113,9 +156,38 @@ 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();
+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,
@@ -165,7 +237,11 @@ delete DirectoryEntryStream._create;
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;
@@ -216,26 +292,25 @@ 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;
}
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 +327,24 @@ 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;
+ touch(this.#entry);
}
setTimes(dataAccessTimestamp: any, dataModificationTimestamp: any) {
- console.log(`[filesystem] SET TIMES`, dataAccessTimestamp, dataModificationTimestamp);
+ if (
+ dataAccessTimestamp?.tag !== "no-change" ||
+ dataModificationTimestamp?.tag !== "no-change"
+ ) {
+ touch(this.#entry);
+ }
}
read(length: bigint, offset: bigint) {
@@ -271,10 +359,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;
+ touch(this.#entry);
return BigInt(buffer.byteLength);
}
@@ -287,18 +385,20 @@ class Descriptor implements TypesNamespace.Descriptor {
);
}
- sync() {
- console.log(`[filesystem] SYNC`);
- }
+ 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: {} };
+ touch(parent);
}
stat() {
@@ -313,7 +413,7 @@ class Descriptor implements TypesNamespace.Descriptor {
}
return {
type,
- linkCount: 0n,
+ linkCount: metadata(this.#entry).linkCount,
size,
dataAccessTimestamp: timeZero,
dataModificationTimestamp: timeZero,
@@ -322,10 +422,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) {
@@ -337,7 +434,7 @@ class Descriptor implements TypesNamespace.Descriptor {
}
return {
type,
- linkCount: 0n,
+ linkCount: metadata(entry).linkCount,
size,
dataAccessTimestamp: timeZero,
dataModificationTimestamp: timeZero,
@@ -345,12 +442,36 @@ 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);
+ 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);
+ touch(entry);
+ }
}
- linkAt() {
- console.log(`[filesystem] LINK AT`);
+ linkAt(
+ _pathFlags: PathFlags,
+ oldPath: string,
+ newDescriptor: TypesNamespace.Descriptor,
+ newPath: string,
+ ) {
+ const entry = getChildEntry(this.#entry, oldPath);
+ 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;
+ metadata(entry).linkCount++;
+ touch(newParent);
}
openAt(
@@ -359,43 +480,123 @@ 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);
+ if (openFlags.create && openFlags.exclusive) {
+ throw "exist";
+ }
+ } catch (error) {
+ if (error !== "no-entry" || !openFlags.create) {
+ throw error;
+ }
+ const [parent, name] = getParentEntry(this.#entry, path);
+ childEntry = parent.dir![name] = openFlags.directory
+ ? { dir: {} }
+ : { source: new Uint8Array() };
+ touch(parent);
+ }
+ if (openFlags.directory && !childEntry.dir) {
+ throw "not-directory";
+ }
+ if (openFlags.truncate) {
+ if (childEntry.dir) {
+ throw "is-directory";
+ }
+ childEntry.source = new Uint8Array();
+ touch(childEntry);
+ }
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];
+ metadata(entry).linkCount--;
+ touch(parent);
}
- 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,
+ );
+ 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";
+ }
+ metadata(replaced).linkCount--;
+ }
+ newParent.dir![newName] = entry;
+ delete oldParent.dir![oldName];
+ touch(oldParent);
+ if (newParent !== oldParent) {
+ touch(newParent);
+ }
}
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];
+ 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 };
}
}
@@ -406,8 +607,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 +616,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 +663,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 +696,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..7ce0a1bdc 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;
@@ -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)) {
@@ -167,16 +169,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 +188,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 +199,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;
@@ -209,6 +211,11 @@ class OutgoingBody implements TypesNamespace.OutgoingBody {
#outputStream: any = null;
#chunks: Uint8Array[] = [];
#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;
@@ -227,31 +234,69 @@ class OutgoingBody implements TypesNamespace.OutgoingBody {
throw { tag: "internal-error", val: "body already finished" };
}
body.#finished = true;
+ if (!body.#requestStreamCancelled) {
+ body.#requestStreamController?.close();
+ }
+ body.#resolveFinished();
}
- static _bodyData(outgoingBody: OutgoingBody): Uint8Array | null {
- if (outgoingBody.#chunks.length === 0) {
+ #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 _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 {
- chunks.push(new Uint8Array(buf));
+ if (outgoingBody.#finished || outgoingBody.#requestStreamCancelled) {
+ throw { tag: "closed" };
+ }
+ const chunk = new Uint8Array(buf);
+ if (outgoingBody.#requestStreamController) {
+ outgoingBody.#requestStreamController.enqueue(chunk);
+ } else {
+ chunks.push(chunk);
+ }
},
blockingFlush() {},
subscribe(): any {
@@ -266,9 +311,16 @@ class OutgoingBody implements TypesNamespace.OutgoingBody {
const outgoingBodyCreate = OutgoingBody._create;
// @ts-expect-error - Deleting static method
delete OutgoingBody._create;
-const outgoingBodyData = OutgoingBody._bodyData;
+const outgoingBodyFinishedData = OutgoingBody._finishedBodyData;
// @ts-expect-error - Deleting static method
-delete OutgoingBody._bodyData;
+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;
@@ -334,14 +386,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(BRACKETED_IPV6_AUTHORITY_RE)
+ : authority.match(DNS_OR_IPV4_AUTHORITY_RE);
+ 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;
}
}
@@ -354,7 +411,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;
@@ -368,11 +429,17 @@ 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));
}
}
- const bodyData = outgoingBodyData(request.#body);
+ // 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
+ ? config.streamingRequestBodies
+ ? outgoingBodyRequestStream(request.#body)
+ : outgoingBodyFinishedData(request.#body)
+ : null;
let timeoutMs = Number(DEFAULT_HTTP_TIMEOUT_NS / 1_000_000n);
if (options) {
@@ -425,6 +492,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 +519,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 +558,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 +579,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 +601,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 +668,157 @@ 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 async _toResponse(response: OutgoingResponse) {
+ const headers = new Headers();
+ for (const [name, value] of response.#headers.entries()) {
+ headers.append(name, utf8Decoder.decode(value));
+ }
+ const body = response.#bodyRequested
+ ? await outgoingBodyFinishedData(response.#body)
+ : null;
+ return new 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;
+ #reject!: (cause: unknown) => void;
+
+ static set(
+ param: ResponseOutparam,
+ response: Result,
+ ) {
+ if (param.#used) {
+ throw new Error("response outparam already set");
+ }
+ param.#used = true;
+ if (response.tag === "ok") {
+ void outgoingResponseToResponse(response.val as OutgoingResponse).then(
+ param.#resolve,
+ param.#reject,
+ );
+ } else {
+ 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, 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;
@@ -617,15 +854,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 +876,8 @@ class FutureIncomingResponse implements TypesNamespace.FutureIncomingResponse {
}
[symbolDispose]() {
+ this.#controller?.abort();
+ this.#controller = null;
this.#promise = null;
}
@@ -648,52 +885,59 @@ class FutureIncomingResponse implements TypesNamespace.FutureIncomingResponse {
url: string,
method: string,
headers: Headers,
- bodyData: Uint8Array | null,
+ bodyData: Promise | ReadableStream | null,
timeoutMs: number,
): FutureIncomingResponse {
const future = new FutureIncomingResponse();
const controller = new AbortController();
+ future.#controller = controller;
let timer: ReturnType | undefined;
if (timeoutMs < Infinity) {
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 = Promise.resolve(bodyData)
+ .then((bodyData) => {
+ const init: RequestInit & { duplex?: "half" } = {
+ method,
+ headers,
+ signal: controller.signal,
};
- },
- (err) => {
- if (timer) {
- clearTimeout(timer);
+ if (bodyData && method !== "GET" && method !== "HEAD") {
+ init.body = bodyData as BodyInit;
+ if (bodyData instanceof ReadableStream) {
+ init.duplex = "half";
+ }
}
- 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;
}
@@ -727,30 +971,66 @@ 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 = {
- // Not implemented
- handle() {},
+ handle() {
+ throw "not-supported";
+ },
};
+export type BrowserIncomingHandler = (
+ request: TypesNamespace.IncomingRequest,
+ 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,
+ handler: BrowserIncomingHandler,
+): 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;
+}
+
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..43b985a7b 100644
--- a/packages/preview2-shim/src/browser/io.ts
+++ b/packages/preview2-shim/src/browser/io.ts
@@ -6,6 +6,8 @@ import type {
let id = 0;
+const MAX_U64 = (1n << 64n) - 1n;
+
const symbolDispose = Symbol.dispose || Symbol.for("dispose");
type IInputStream = StreamsNamespace.InputStream;
@@ -19,6 +21,25 @@ export type InputStreamHandler = Partial &
drop?: () => void;
};
+export interface PollableSource {
+ ready(): boolean;
+ wait(): Promise;
+}
+
+function checkedLength(len: bigint, name = "length"): number {
+ if (typeof len !== "bigint" || len < 0n || len > MAX_U64) {
+ throw new TypeError(`${name} must be a valid u64`);
+ }
+ if (len > BigInt(Number.MAX_SAFE_INTEGER)) {
+ throw new RangeError(`${name} exceeds JavaScript's safe integer range`);
+ }
+ return Number(len);
+}
+
+function closed(): never {
+ throw { tag: "closed" } satisfies StreamsNamespace.StreamError;
+}
+
/**
* Handler interface for creating custom output streams
*/
@@ -33,9 +54,13 @@ class IoError extends Error implements ErrorNamespace.Error {
}
}
+export const ioErrorCreate = (message: string): ErrorNamespace.Error => new IoError(message);
+
class InputStream implements IInputStream {
id!: number;
handler!: InputStreamHandler;
+ #open = true;
+ #children = new Set();
static _create(handler: InputStreamHandler) {
const stream = new InputStream();
@@ -48,17 +73,29 @@ class InputStream implements IInputStream {
}
read(len: bigint) {
+ checkedLength(len);
+ if (!this.#open) {
+ closed();
+ }
if (this.handler.read) {
- return this.handler.read(len);
+ return this.handler.read.call(this, len);
}
return this.handler.blockingRead.call(this, len);
}
blockingRead(len: bigint) {
+ checkedLength(len);
+ if (!this.#open) {
+ closed();
+ }
return this.handler.blockingRead.call(this, len);
}
skip(len: bigint) {
+ checkedLength(len);
+ if (!this.#open) {
+ closed();
+ }
if (this.handler.skip) {
return this.handler.skip.call(this, len);
}
@@ -70,6 +107,10 @@ class InputStream implements IInputStream {
}
blockingSkip(len: bigint) {
+ checkedLength(len);
+ if (!this.#open) {
+ closed();
+ }
if (this.handler.blockingSkip) {
return this.handler.blockingSkip.call(this, len);
}
@@ -78,13 +119,28 @@ class InputStream implements IInputStream {
}
subscribe() {
- if (this.handler.subscribe) {
- return this.handler.subscribe();
+ if (!this.#open) {
+ return pollableCreate();
+ }
+ const pollable = this.handler.subscribe
+ ? this.handler.subscribe.call(this)
+ : pollableCreate();
+ if (pollable instanceof Pollable) {
+ this.#children.add(pollable);
+ pollable._onDispose(() => this.#children.delete(pollable));
}
- return new Pollable();
+ return pollable;
}
[symbolDispose]() {
+ if (!this.#open) {
+ return;
+ }
+ this.#open = false;
+ for (const child of this.#children) {
+ child._invalidate();
+ }
+ this.#children.clear();
if (this.handler.drop) {
this.handler.drop.call(this);
}
@@ -99,6 +155,8 @@ class OutputStream implements IOutputStream {
id!: number;
open!: boolean;
handler!: OutputStreamHandler;
+ #permit = 0n;
+ #children = new Set();
static _create(handler: OutputStreamHandler) {
const stream = new OutputStream();
@@ -113,66 +171,124 @@ class OutputStream implements IOutputStream {
checkWrite() {
if (!this.open) {
- return 0n;
+ closed();
}
if (this.handler.checkWrite) {
- return this.handler.checkWrite.call(this);
+ const permit = this.handler.checkWrite.call(this);
+ checkedLength(permit, "write permit");
+ this.#permit = permit;
+ return permit;
}
- return 1_000_000n;
+ this.#permit = 1_000_000n;
+ return this.#permit;
}
write(buf: Uint8Array) {
+ if (!this.open) {
+ closed();
+ }
+ if (BigInt(buf.byteLength) > this.#permit) {
+ throw new Error("write exceeds the permit returned by checkWrite");
+ }
+ this.#permit -= BigInt(buf.byteLength);
this.handler.write.call(this, buf);
}
blockingWriteAndFlush(buf: Uint8Array) {
+ if (!this.open) {
+ closed();
+ }
+ if (buf.byteLength > 4096) {
+ throw new RangeError("blockingWriteAndFlush accepts at most 4096 bytes");
+ }
if (this.handler.blockingWriteAndFlush) {
return this.handler.blockingWriteAndFlush.call(this, buf);
}
this.handler.write.call(this, buf);
+ if (this.handler.blockingFlush) {
+ this.handler.blockingFlush.call(this);
+ } else {
+ this.handler.flush?.call(this);
+ }
}
flush() {
+ if (!this.open) {
+ closed();
+ }
+ this.#permit = 0n;
if (this.handler.flush) {
this.handler.flush.call(this);
}
}
blockingFlush() {
- this.open = true;
+ if (!this.open) {
+ closed();
+ }
if (this.handler.blockingFlush) {
this.handler.blockingFlush.call(this);
+ } else {
+ this.handler.flush?.call(this);
}
}
writeZeroes(len: bigint) {
- this.write.call(this, new Uint8Array(Number(len)));
+ const length = checkedLength(len);
+ if (len > this.#permit) {
+ throw new Error("write exceeds the permit returned by checkWrite");
+ }
+ this.write.call(this, new Uint8Array(length));
}
blockingWriteZeroesAndFlush(len: bigint) {
- this.blockingWriteAndFlush.call(this, new Uint8Array(Number(len)));
+ const length = checkedLength(len);
+ if (length > 4096) {
+ throw new RangeError("blockingWriteZeroesAndFlush accepts at most 4096 bytes");
+ }
+ this.blockingWriteAndFlush.call(this, new Uint8Array(length));
}
splice(src: InputStream, len: bigint) {
- const spliceLen = Math.min(Number(len), Number(this.checkWrite.call(this)));
+ const spliceLen = Math.min(checkedLength(len), Number(this.checkWrite.call(this)));
const bytes = src.read(BigInt(spliceLen));
this.write.call(this, bytes);
return BigInt(bytes.byteLength);
}
- blockingSplice(_src: InputStream, _len: bigint) {
- console.log(`[streams] Blocking splice ${this.id}`);
- return 0n;
+ blockingSplice(src: InputStream, len: bigint) {
+ const spliceLen = Math.min(checkedLength(len), Number(this.checkWrite.call(this)));
+ const bytes = src.blockingRead(BigInt(spliceLen));
+ this.write.call(this, bytes);
+ return BigInt(bytes.byteLength);
}
subscribe() {
- if (this.handler.subscribe) {
- return this.handler.subscribe();
+ if (!this.open) {
+ return pollableCreate();
}
- return new Pollable();
+ const pollable = this.handler.subscribe
+ ? this.handler.subscribe.call(this)
+ : pollableCreate();
+ if (pollable instanceof Pollable) {
+ this.#children.add(pollable);
+ pollable._onDispose(() => this.#children.delete(pollable));
+ }
+ return pollable;
}
- [symbolDispose]() {}
+ [symbolDispose]() {
+ if (!this.open) {
+ return;
+ }
+ this.open = false;
+ this.#permit = 0n;
+ for (const child of this.#children) {
+ child._invalidate();
+ }
+ this.#children.clear();
+ this.handler.drop?.call(this);
+ }
}
export const outputStreamCreate = OutputStream._create;
@@ -186,39 +302,90 @@ export const error: typeof ErrorNamespace = {
export const streams: typeof StreamsNamespace = { InputStream, OutputStream };
class Pollable implements PollNamespace.Pollable {
- #ready = false;
- #promise: Promise | null = null;
-
- static _create(promise?: Promise) {
+ #source: PollableSource = { ready: () => true, wait: () => Promise.resolve() };
+ #invalid = false;
+ #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();
- 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.race([
+ Promise.resolve(this.#source.wait()),
+ this.#unusable.then(() => this.#assertUsable()),
+ ]).finally(() => {
+ this.#wait = null;
+ });
+ }
+ return this.#wait;
+ }
+
+ _onDispose(callback: () => void) {
+ if (this.#disposed) {
+ callback();
+ } else {
+ this.#disposeCallbacks.push(callback);
+ }
+ }
+
+ _invalidate() {
+ if (this.#invalid || this.#disposed) {
+ return;
+ }
+ this.#invalid = true;
+ this.#wakeUnusable();
+ }
+
+ #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.#wakeUnusable();
+ for (const callback of this.#disposeCallbacks.splice(0)) {
+ callback();
+ }
}
}
@@ -240,23 +407,32 @@ 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.
- 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..e6265cc9f 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,
@@ -66,7 +65,7 @@ import type {
* }
* });
*
- * // Limited filesystem access
+ * // Node.js only: limited host filesystem access
* const limitedShim = new WASIShim({
* sandbox: {
* preopens: {
@@ -77,6 +76,9 @@ import type {
* });
* ```
*
+ * Browser applications should inject `filesystem` namespaces or explicitly
+ * configure `browserFilesystem`; browser preopens never interpret host paths.
+ *
* Note that this object is similar but not identical to the Node `WASI` object --
* it is solely concerned with shimming of preview2 when dealing with a WebAssembly
* component transpiled by Jco. While this object *does* work with Node (and the browser)
@@ -99,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;
@@ -112,41 +112,73 @@ export class WASIShim {
constructor(config?: WASIShimConfig) {
// Support both old 'shims' parameter name and new 'config' style
const shims = config;
+ const sandbox = shims?.sandbox;
- 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;
+ if (shims?.browserFilesystem && !defaultFilesystem.createFilesystem) {
+ throw new TypeError("the selected filesystem does not support browser adapters");
+ }
+ this.#filesystem = shims?.filesystem ?? defaultFilesystem;
+ 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,
+ });
+ } 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;
- 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
- 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);
}
-
- // 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();
- }
- }
}
/**
@@ -181,8 +213,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;
@@ -197,37 +228,13 @@ 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;
}
}
-/**
- * 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/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/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 84bd3795d..5e436dfa0 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";
@@ -246,6 +246,7 @@ 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 });
},
};
}
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/browser/basic-harness/index.html b/packages/preview2-shim/test/fixtures/browser/basic-harness/index.html
index bfab6d273..e42452646 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,22 @@