From 6c9adbccad807a2823b0137475408e47511212e8 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Wed, 12 Aug 2026 20:55:23 +0800 Subject: [PATCH] feat(wasm): upgrade preview2-shim and add mutable filesystem support - Upgrade @bytecodealliance/preview2-shim from 0.18.0 to 0.20.1 - Update import paths from /deps/lib/ to /deps/dist/ - Add _getPreopens and filesystemTypes imports for new functionality - Implement installMutableFilesystem function with directory operations - Add unlinkFileAt and removeDirectoryAt methods to Descriptor prototype - Install mutable filesystem in both browser runner and worker - Add ENOENT error handling for extensionless ESM imports - Introduce TYPEPHP_WASM_TEST_STAGE global for debugging - Enhance error reporting with stage information in test harness --- examples/wasm-hello/package-lock.json | 18 +------ examples/wasm-hello/package.json | 2 +- examples/wasm-hello/typephp-worker.mjs | 50 ++++++++++++++++- tests/wasm/harness/browser-runner.mjs | 74 +++++++++++++++++++++++--- tests/wasm/harness/package-lock.json | 1 + tests/wasm/harness/package.json | 1 + 6 files changed, 120 insertions(+), 26 deletions(-) diff --git a/examples/wasm-hello/package-lock.json b/examples/wasm-hello/package-lock.json index 7def2885..8306c072 100644 --- a/examples/wasm-hello/package-lock.json +++ b/examples/wasm-hello/package-lock.json @@ -8,7 +8,7 @@ "name": "typephp-wasm-hello", "version": "0.1.0", "dependencies": { - "@bytecodealliance/preview2-shim": "0.18.0" + "@bytecodealliance/preview2-shim": "0.20.1" }, "devDependencies": { "@bytecodealliance/jco": "^1.27.0", @@ -225,24 +225,10 @@ "oxc-minify": "^0.136.0" } }, - "node_modules/@bytecodealliance/jco-transpile/node_modules/@bytecodealliance/preview2-shim": { - "version": "0.20.1", - "resolved": "https://registry.npmmirror.com/@bytecodealliance/preview2-shim/-/preview2-shim-0.20.1.tgz", - "integrity": "sha512-gvQP6DWfeQIYKHBb+nPykku1DRtfXQjM685+e75YYr8ZF2ZxisE4+nWrpwf84+NaWJRfuL5HvoLKQFbup1iwkQ==", - "dev": true, - "license": "(Apache-2.0 WITH LLVM-exception)" - }, - "node_modules/@bytecodealliance/jco/node_modules/@bytecodealliance/preview2-shim": { + "node_modules/@bytecodealliance/preview2-shim": { "version": "0.20.1", "resolved": "https://registry.npmmirror.com/@bytecodealliance/preview2-shim/-/preview2-shim-0.20.1.tgz", "integrity": "sha512-gvQP6DWfeQIYKHBb+nPykku1DRtfXQjM685+e75YYr8ZF2ZxisE4+nWrpwf84+NaWJRfuL5HvoLKQFbup1iwkQ==", - "dev": true, - "license": "(Apache-2.0 WITH LLVM-exception)" - }, - "node_modules/@bytecodealliance/preview2-shim": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@bytecodealliance/preview2-shim/-/preview2-shim-0.18.0.tgz", - "integrity": "sha512-f7+HY98GBrfdKjXb2QT6ycbz+NR+Ocn3Aq87IZ8aVf0lThJocQ4E6dyCC9Y/5l0oOgS9nJ0Z1edeYfmTU87EdA==", "license": "(Apache-2.0 WITH LLVM-exception)" }, "node_modules/@bytecodealliance/preview3-shim": { diff --git a/examples/wasm-hello/package.json b/examples/wasm-hello/package.json index 74c865a9..6e8038ee 100644 --- a/examples/wasm-hello/package.json +++ b/examples/wasm-hello/package.json @@ -9,7 +9,7 @@ "build": "vite build" }, "dependencies": { - "@bytecodealliance/preview2-shim": "0.18.0" + "@bytecodealliance/preview2-shim": "0.20.1" }, "devDependencies": { "@bytecodealliance/jco": "^1.27.0", diff --git a/examples/wasm-hello/typephp-worker.mjs b/examples/wasm-hello/typephp-worker.mjs index 9f03e122..1af6381f 100644 --- a/examples/wasm-hello/typephp-worker.mjs +++ b/examples/wasm-hello/typephp-worker.mjs @@ -3,7 +3,11 @@ import { _setStdin, _setStdout, } from '@bytecodealliance/preview2-shim/cli'; -import { _setFileData } from '@bytecodealliance/preview2-shim/filesystem'; +import { + _getPreopens, + _setFileData, + types as filesystemTypes, +} from '@bytecodealliance/preview2-shim/filesystem'; import { WASIShim } from '@bytecodealliance/preview2-shim/instantiation'; const encoder = new TextEncoder(); @@ -14,6 +18,49 @@ let persistent = false; let storageName = 'typephp-wasi-filesystem.json'; let extensionQueue = Promise.resolve(); +function installMutableFilesystem(data) { + const descriptorEntries = new WeakMap(); + for (const [descriptor] of _getPreopens()) descriptorEntries.set(descriptor, data); + + function resolve(entry, guestPath) { + for (const segment of String(guestPath).split('/')) { + if (segment === '' || segment === '.') continue; + if (segment === '..' || !entry?.dir?.[segment]) throw { tag: 'no-entry' }; + entry = entry.dir[segment]; + } + return entry; + } + + function remove(descriptor, guestPath, directory) { + const root = descriptorEntries.get(descriptor); + if (!root) throw { tag: 'bad-descriptor' }; + const segments = String(guestPath).split('/').filter((segment) => segment !== '' && segment !== '.'); + const name = segments.pop(); + if (!name || name === '..' || segments.includes('..')) throw { tag: 'no-entry' }; + const parent = resolve(root, segments.join('/')); + const entry = parent?.dir?.[name]; + if (!entry) throw { tag: 'no-entry' }; + if (directory ? !entry.dir : entry.dir) throw { tag: directory ? 'not-directory' : 'is-directory' }; + if (directory && Object.keys(entry.dir).length !== 0) throw { tag: 'not-empty' }; + delete parent.dir[name]; + } + + const descriptor = filesystemTypes.Descriptor.prototype; + const openAt = descriptor.openAt; + descriptor.openAt = function (...args) { + const opened = openAt.apply(this, args); + const parent = descriptorEntries.get(this); + if (parent) descriptorEntries.set(opened, resolve(parent, args[1])); + return opened; + }; + descriptor.unlinkFileAt = function (guestPath) { + remove(this, guestPath, false); + }; + descriptor.removeDirectoryAt = function (guestPath) { + remove(this, guestPath, true); + }; +} + function outputHandler(stream) { return { write(bytes) { @@ -101,6 +148,7 @@ async function start(data) { storageName = String(data.storageName || 'typephp-wasi-filesystem.json'); fileData = persistent ? await loadFileData(storageName) : { dir: {} }; _setFileData(fileData); + installMutableFilesystem(fileData); _setStdin(inputHandler(String(data.stdin || ''))); _setStdout(outputHandler('stdout')); _setStderr(outputHandler('stderr')); diff --git a/tests/wasm/harness/browser-runner.mjs b/tests/wasm/harness/browser-runner.mjs index f3fe0708..df14355d 100644 --- a/tests/wasm/harness/browser-runner.mjs +++ b/tests/wasm/harness/browser-runner.mjs @@ -39,21 +39,22 @@ const pageOptions = JSON.stringify({ ...options, stdin }).replace(/ `; const harnessSource = ` -import { _setStderr, _setStdin, _setStdout } from '/deps/lib/browser/cli.js'; -import { _setFileData } from '/deps/lib/browser/filesystem.js'; -import { WASIShim } from '/deps/lib/common/instantiation.js'; +import { _setStderr, _setStdin, _setStdout } from '/deps/dist/browser/cli.js'; +import { _getPreopens, _setFileData, types as filesystemTypes } from '/deps/dist/browser/filesystem.js'; +import { WASIShim } from '/deps/dist/common/instantiation.js'; import { instantiate } from '/artifact/program.js'; const decoder = new TextDecoder(); const encoder = new TextEncoder(); const options = globalThis.TYPEPHP_WASM_TEST_OPTIONS; let output = ''; +globalThis.TYPEPHP_WASM_TEST_STAGE = 'module-loaded'; function outputHandler() { return { @@ -79,11 +80,56 @@ function inputHandler(text) { }; } +function installMutableFilesystem(fileData) { + const descriptorEntries = new WeakMap(); + for (const [descriptor] of _getPreopens()) descriptorEntries.set(descriptor, fileData); + + function resolve(entry, guestPath) { + for (const segment of String(guestPath).split('/')) { + if (segment === '' || segment === '.') continue; + if (segment === '..' || !entry?.dir?.[segment]) throw { tag: 'no-entry' }; + entry = entry.dir[segment]; + } + return entry; + } + + function remove(descriptor, guestPath, directory) { + const root = descriptorEntries.get(descriptor); + if (!root) throw { tag: 'bad-descriptor' }; + const segments = String(guestPath).split('/').filter((segment) => segment !== '' && segment !== '.'); + const name = segments.pop(); + if (!name || name === '..' || segments.includes('..')) throw { tag: 'no-entry' }; + const parent = resolve(root, segments.join('/')); + const entry = parent?.dir?.[name]; + if (!entry) throw { tag: 'no-entry' }; + if (directory ? !entry.dir : entry.dir) throw { tag: directory ? 'not-directory' : 'is-directory' }; + if (directory && Object.keys(entry.dir).length !== 0) throw { tag: 'not-empty' }; + delete parent.dir[name]; + } + + const descriptor = filesystemTypes.Descriptor.prototype; + const openAt = descriptor.openAt; + descriptor.openAt = function (...args) { + const opened = openAt.apply(this, args); + const parent = descriptorEntries.get(this); + if (parent) descriptorEntries.set(opened, resolve(parent, args[1])); + return opened; + }; + descriptor.unlinkFileAt = function (guestPath) { + remove(this, guestPath, false); + }; + descriptor.removeDirectoryAt = function (guestPath) { + remove(this, guestPath, true); + }; +} + try { if (typeof WebAssembly.Suspending !== 'function' || typeof WebAssembly.promising !== 'function') { throw new Error('Chrome does not provide WebAssembly JSPI'); } - _setFileData({ dir: { sandbox: { dir: {} } } }); + const fileData = { dir: { sandbox: { dir: {} } } }; + _setFileData(fileData); + installMutableFilesystem(fileData); _setStdin(inputHandler(String(options.stdin || ''))); _setStdout(outputHandler()); _setStderr(outputHandler()); @@ -92,7 +138,9 @@ try { env: { ...(options.env || {}) }, enableNetwork: true, }}); + globalThis.TYPEPHP_WASM_TEST_STAGE = 'instantiating'; const component = await instantiate(null, wasi.getImportObject()); + globalThis.TYPEPHP_WASM_TEST_STAGE = 'running'; const result = await component.run.run(); globalThis.TYPEPHP_WASM_TEST_RESULT = { output, result }; } catch (error) { @@ -126,12 +174,21 @@ const server = http.createServer(async (request, response) => { response.writeHead(404).end(); return; } - const filename = safePath(mapping[0], mapping[1]); + let filename = safePath(mapping[0], mapping[1]); if (!filename) { response.writeHead(403).end(); return; } - const data = await fs.readFile(filename); + let data; + try { + data = await fs.readFile(filename); + } catch (error) { + // The browser shim publishes Node-style extensionless ESM imports. + // Resolve those within the already validated static root only. + if (error?.code !== 'ENOENT' || path.extname(filename) !== '') throw error; + filename += '.js'; + data = await fs.readFile(filename); + } response.writeHead(200, { 'content-type': contentTypes.get(path.extname(filename)) || 'application/octet-stream' }); response.end(data); } catch (error) { @@ -163,7 +220,8 @@ try { try { await page.waitForFunction(() => globalThis.TYPEPHP_WASM_TEST_RESULT !== undefined, { timeout: 120000 }); } catch (error) { - throw new Error(`${error.message}\n${diagnostics.join('\n')}`); + const stage = await page.evaluate(() => globalThis.TYPEPHP_WASM_TEST_STAGE || 'page-loading'); + throw new Error(`${error.message}\nstage: ${stage}\n${diagnostics.join('\n')}`); } const result = await page.evaluate(() => globalThis.TYPEPHP_WASM_TEST_RESULT); process.stdout.write(result.output || ''); diff --git a/tests/wasm/harness/package-lock.json b/tests/wasm/harness/package-lock.json index 90948fbf..a4cd30cb 100644 --- a/tests/wasm/harness/package-lock.json +++ b/tests/wasm/harness/package-lock.json @@ -7,6 +7,7 @@ "name": "typephp-wasm-test-harness", "dependencies": { "@bytecodealliance/jco": "^1.27.0", + "@bytecodealliance/preview2-shim": "0.20.1", "puppeteer-core": "^24.16.0" } }, diff --git a/tests/wasm/harness/package.json b/tests/wasm/harness/package.json index 37f6310c..cf922f74 100644 --- a/tests/wasm/harness/package.json +++ b/tests/wasm/harness/package.json @@ -4,6 +4,7 @@ "type": "module", "dependencies": { "@bytecodealliance/jco": "^1.27.0", + "@bytecodealliance/preview2-shim": "0.20.1", "puppeteer-core": "^24.16.0" } }