From ac16f5fbacc5fce2aac19ee3b1fd672111727f3e Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Fri, 7 Aug 2026 21:31:50 +0800 Subject: [PATCH] feat(wasm): update WASI build system with explicit browser/component profiles - Change default TYPEPHP_WASM_BROWSER from 1 to 0 in build script - Add PHP extensions panel to WASM hello example UI with dynamic loading - Update project.yml schema to require explicit 'browser' or 'component' values - Modify help text to reflect component as default WASI output - Update documentation to clarify component vs browser build profiles - Remove legacy boolean and array config formats for wasm option - Add validation for unsupported profile aliases like 'web' - Refactor profile normalization to only accept 'browser' or 'component' - Update tests to verify new configuration behavior and error handling --- docs/TYPEPHP_WASI_BUILD.md | 14 +++-- docs/wasm.md | 8 ++- examples/wasm-hello/README.md | 4 +- examples/wasm-hello/index.html | 11 ++++ examples/wasm-hello/main.js | 7 +++ examples/wasm-hello/project.yml | 3 +- examples/wasm-hello/src/WasiDemo.php | 1 + examples/wasm-hello/style.css | 6 ++- phpunit/src/Build/WasiProjectConfigTest.php | 58 +++++++++++++++++++-- src/Build/WasiProjectConfig.php | 29 +++-------- src/Translator.php | 2 +- wasm/build-typephp-program.sh | 2 +- 12 files changed, 100 insertions(+), 45 deletions(-) diff --git a/docs/TYPEPHP_WASI_BUILD.md b/docs/TYPEPHP_WASI_BUILD.md index 4a4dc7bd..daf5d77a 100644 --- a/docs/TYPEPHP_WASI_BUILD.md +++ b/docs/TYPEPHP_WASI_BUILD.md @@ -38,30 +38,28 @@ function main(): void php bin/tpc.php --wasm hello.php ``` -单文件输入默认生成当前目录下的 `hello.wasm` 和 `hello.browser/` Jco 模块。生成的 `.cc` 与 host 模式使用相同的 build 目录规则,默认位于 TypePHP 根目录的 `build/`;可以使用 `--build-dir ` 覆盖。 +单文件输入默认只生成当前目录下可由 Wasmtime 执行的 `hello.wasm` Component,不要求安装 Jco。生成的 `.cc` 与 host 模式使用相同的 build 目录规则,默认位于 TypePHP 根目录的 `build/`;可以使用 `--build-dir ` 覆盖。 项目可以直接使用 `project.yml`: ```yaml name: wasm-hello mode: bin -target-platform: wasm32-wasip2 -wasm: true +wasm: component build-dir: build output: component/wasm-hello.wasm sources: - src -wasm-browser-dir: generated ``` -配置 `wasm: true` 后,直接执行 `php bin/tpc.php project.yml` 即可进入 WASI browser 构建,无需重复传入 `--wasm`。`build-dir`、`output` 和 `wasm-browser-dir` 都相对于项目文件解析。完整浏览器应用见 `examples/wasm-hello/`。 +`wasm` 只接受 `component` 或 `browser`,不接受布尔值。配置后直接执行 `php bin/tpc.php project.yml` 即可进入 WASI 构建,无需重复传入 `--wasm`。WASM 项目未配置 `target-platform` 时默认使用 `wasm32-wasip2`;`build-dir`、`output` 和 `wasm-browser-dir` 都相对于项目文件解析。完整浏览器应用见 `examples/wasm-hello/`,它显式使用 `wasm: browser`。 -若项目永久只生成 Component,也可以直接配置 `wasm: component`。 +需要生成浏览器模块时,配置 `wasm: browser` 和 `wasm-browser-dir`,并确保 Jco 位于 `PATH`。 命令行也可以显式选择产物: -- `--wasm` 或 `--wasm=browser`:生成 Component 和 Jco 浏览器模块,需要 `jco` 位于 `PATH`。 -- `--wasm=component`:仅生成可由 Wasmtime 运行的 Component,不检测 Jco。 +- `--wasm` 或 `--wasm=component`:仅生成可由 Wasmtime 运行的 Component,不检测 Jco。 +- `--wasm=browser`:生成 Component 和 Jco 浏览器模块,需要 `jco` 位于 `PATH`。 路径、sources 等详细配置继续放在 `project.yml`,不通过 `--wasm=` 传递。 diff --git a/docs/wasm.md b/docs/wasm.md index 49fd64e4..534598be 100644 --- a/docs/wasm.md +++ b/docs/wasm.md @@ -4,7 +4,7 @@ ./tpc --wasm test.php ``` -编译成功后生成 WASI 0.2 Component `test.wasm` 和 `test.browser/` Jco 模块。WASI 0.1 不受支持。 +编译成功后默认只生成可由 Wasmtime 执行的 WASI 0.2 Component `test.wasm`。WASI 0.1 不受支持。 生成的 C++ 源码默认写入 `build/`,也可以通过 `--build-dir ` 指定。 ## 执行 @@ -16,9 +16,7 @@ wasmtime test.wasm ## Chrome ```shell -cd test.browser -npm install -npm run dev +./tpc --wasm=browser test.php ``` -完整浏览器 Demo 位于仓库 `examples/wasm-hello/`,并使用 `project.yml` 构建。TypePHP 在专用 Worker 中执行;默认文件系统驻留内存,可显式启用 OPFS 快照持久化。网络 socket、进程、shell 和信号在 WASI 目标下明确不支持。 +浏览器模式额外生成 `test.browser/` Jco 模块并要求 `jco` 位于 `PATH`。完整浏览器 Demo 位于仓库 `examples/wasm-hello/`,并使用 `wasm: browser` 的 `project.yml` 构建。TypePHP 在专用 Worker 中执行;默认文件系统驻留内存,可显式启用 OPFS 快照持久化。网络 socket、进程、shell 和信号在 WASI 目标下明确不支持。 diff --git a/examples/wasm-hello/README.md b/examples/wasm-hello/README.md index 86f17d2c..a0756833 100644 --- a/examples/wasm-hello/README.md +++ b/examples/wasm-hello/README.md @@ -10,6 +10,7 @@ Demo 展示以下已支持能力: - 内存文件系统,以及可选的 OPFS 快照持久化 - 通过同步 `file_get_contents()` 发起 HTTP/HTTPS GET;浏览器等待期间由 JSPI 挂起 Wasm 调用栈 - PHP 8.5 runtime 信息 +- 由 `get_loaded_extensions()` 动态读取的 PHP/WASI 内置扩展列表 - TypePHP 语言级 BigInt、Decimal、BigFloat 高精度计算 原始 socket、进程、shell、信号、Fiber 和 Generator 明确不支持。 @@ -39,7 +40,8 @@ php bin/tpc.php examples/wasm-hello/project.yml - `sources: src`:TypePHP 源码 - `build-dir: build`:生成的 C++ 与目标文件 - `output: component/wasm-hello.wasm`:WASI 0.2 Component -- `wasm: true`:项目自动进入 WASI browser 构建,无需命令行 `--wasm` +- `wasm: browser`:显式生成浏览器模块;简单项目使用 `wasm: component` 只生成 Component +- 未配置 `target-platform` 时,WASM 项目默认使用 `wasm32-wasip2` - `wasm-browser-dir: generated`:Jco 浏览器模块 Jco 是本项目的开发依赖。先执行 `npm ci`,之后通过 `npm run wasm` 构建时,npm 会自动把本地 `node_modules/.bin/jco` 加入 `PATH`。 diff --git a/examples/wasm-hello/index.html b/examples/wasm-hello/index.html index ee34cd52..fa5dd807 100644 --- a/examples/wasm-hello/index.html +++ b/examples/wasm-hello/index.html @@ -102,6 +102,17 @@ +
+
+ +

静态编译的 PHP 扩展

+
+ 等待运行 +
+ 由 get_loaded_extensions() 动态读取 +
+
+
Raw component outputstdout / stderr
尚未运行。
diff --git a/examples/wasm-hello/main.js b/examples/wasm-hello/main.js index 108b9d15..9bffb482 100644 --- a/examples/wasm-hello/main.js +++ b/examples/wasm-hello/main.js @@ -4,6 +4,7 @@ const elements = Object.fromEntries([ 'runtime-value', 'platform-value', 'clock-value', 'random-value', 'token-value', 'filesystem-value', 'files-value', 'argv-value', 'env-value', 'stdin-value', 'http-value', 'http-detail', 'bigint-value', 'decimal-value', 'bigfloat-value', + 'extension-count', 'extension-list', ].map((id) => [id, document.getElementById(id)])); let worker = null; @@ -36,6 +37,12 @@ function value(id, content) { function renderReport(report) { value('runtime-value', `PHP ${report.runtime.php}`); value('platform-value', report.runtime.platform); + value('extension-count', `${report.runtime.extensions.length} 个内置扩展`); + elements['extension-list'].replaceChildren(...report.runtime.extensions.map((extension) => { + const badge = document.createElement('span'); + badge.textContent = extension; + return badge; + })); value('clock-value', report.clock.iso8601); value('random-value', report.random.integer); value('token-value', report.random.token); diff --git a/examples/wasm-hello/project.yml b/examples/wasm-hello/project.yml index 372e360f..ce2dc428 100644 --- a/examples/wasm-hello/project.yml +++ b/examples/wasm-hello/project.yml @@ -1,9 +1,8 @@ name: wasm-hello mode: bin -target-platform: wasm32-wasip2 build-dir: build output: component/wasm-hello.wasm sources: - src -wasm: true +wasm: browser wasm-browser-dir: generated diff --git a/examples/wasm-hello/src/WasiDemo.php b/examples/wasm-hello/src/WasiDemo.php index f87af540..71d3a290 100644 --- a/examples/wasm-hello/src/WasiDemo.php +++ b/examples/wasm-hello/src/WasiDemo.php @@ -18,6 +18,7 @@ final class WasiDemo 'php' => PHP_VERSION, 'platform' => php_uname(), 'integerBits' => PHP_INT_SIZE * 8, + 'extensions' => get_loaded_extensions(), ], 'clock' => [ 'timestamp' => time(), diff --git a/examples/wasm-hello/style.css b/examples/wasm-hello/style.css index 3903b197..8b168a43 100644 --- a/examples/wasm-hello/style.css +++ b/examples/wasm-hello/style.css @@ -58,7 +58,11 @@ label small { display: block; margin-top: 7px; color: #62758e; font-size: .7rem; .feature-card p { margin: 22px 0 7px; color: #70839c; font-size: .7rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }.feature-card h3 { margin: 0; overflow: hidden; color: #e8f1fe; font: 650 1.02rem ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }.feature-card small { display: block; margin-top: 8px; overflow: hidden; color: #667991; font-size: .68rem; text-overflow: ellipsis; white-space: nowrap; } .detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }.data-panel, .precision-panel { padding: 27px; } dl { margin: 22px 0 0; } dl div { display: grid; grid-template-columns: 92px 1fr; gap: 14px; padding: 13px 0; border-top: 1px solid #ffffff0b; } dt { color: #71849d; font: 700 .71rem ui-monospace, monospace; text-transform: uppercase; } dd { margin: 0; overflow-wrap: anywhere; color: #bfd0e5; font: .78rem/1.55 ui-monospace, monospace; } +.extensions-panel { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 22px; margin-top: 16px; padding: 27px; } +.extensions-panel > strong { color: #79e8ff; font: 700 .75rem ui-monospace, monospace; } +.extension-list { display: flex; grid-column: 1 / -1; flex-wrap: wrap; gap: 8px; padding-top: 20px; border-top: 1px solid #ffffff0b; } +.extension-list span { padding: 7px 11px; border: 1px solid #62daf329; border-radius: 99px; background: #38c5e70c; color: #a9c9dc; font: 650 .7rem ui-monospace, monospace; } .console { margin-top: 16px; padding: 0; overflow: hidden; } .console summary { display: flex; justify-content: space-between; padding: 19px 24px; cursor: pointer; color: #a9bad0; font-size: .8rem; } kbd { border: 1px solid #ffffff12; border-radius: 6px; padding: 3px 7px; background: #ffffff08; color: #63778f; font: .65rem ui-monospace, monospace; }.console pre { max-height: 420px; margin: 0; padding: 23px; overflow: auto; border-top: 1px solid #ffffff0b; background: #02081099; color: #8fe9c0; font: .73rem/1.6 ui-monospace, monospace; } footer { display: flex; justify-content: space-between; gap: 20px; padding: 28px 4px 0; color: #50647d; font-size: .68rem; } @media (max-width: 820px) { .hero { grid-template-columns: 1fr; padding-bottom: 40px; }.runtime-orbit { display: none; }.results { grid-template-columns: 1fr 1fr; }.detail-grid { grid-template-columns: 1fr; } } -@media (max-width: 560px) { .shell { width: min(100% - 24px, 1180px); padding-top: 38px; }h1 { font-size: 3.6rem; }.control-panel { padding: 21px; }.form-grid, .results { grid-template-columns: 1fr; }.wide { grid-column: auto !important; }.actions, .panel-heading, footer { align-items: stretch; flex-direction: column; }.button-row { width: 100%; }.button { flex: 1; } } +@media (max-width: 560px) { .shell { width: min(100% - 24px, 1180px); padding-top: 38px; }h1 { font-size: 3.6rem; }.control-panel { padding: 21px; }.form-grid, .results, .extensions-panel { grid-template-columns: 1fr; }.wide { grid-column: auto !important; }.actions, .panel-heading, footer { align-items: stretch; flex-direction: column; }.extension-list { grid-column: auto; }.button-row { width: 100%; }.button { flex: 1; } } diff --git a/phpunit/src/Build/WasiProjectConfigTest.php b/phpunit/src/Build/WasiProjectConfigTest.php index b8b549a5..db09f59a 100644 --- a/phpunit/src/Build/WasiProjectConfigTest.php +++ b/phpunit/src/Build/WasiProjectConfigTest.php @@ -35,7 +35,7 @@ build-dir: cache/build output: dist/demo.wasm sources: - src -wasm: true +wasm: browser wasm-browser-dir: web/generated YAML); @@ -66,14 +66,14 @@ YAML); self::assertSame($this->directory . '/custom-build', $config->buildDir); self::assertNull($config->output); self::assertNull($config->browserDir); - self::assertSame('browser', $config->profile); + self::assertSame('component', $config->profile); } - public function testCliCanSelectComponentOnlyOutput(): void + public function testComponentDoesNotRequireTargetPlatform(): void { file_put_contents($this->directory . '/project.yml', <<<'YAML' name: demo -wasm: true +wasm: component wasm-browser-dir: generated sources: - src @@ -84,13 +84,34 @@ YAML); null, $this->directory, '/default-build', - 'component', ); self::assertSame('component', $config->profile); self::assertNull($config->browserDir); } + public function testCliCanSelectBrowserOutput(): void + { + file_put_contents($this->directory . '/project.yml', <<<'YAML' +name: demo +wasm: component +wasm-browser-dir: generated +sources: + - src +YAML); + + $config = WasiProjectConfig::load( + 'project.yml', + null, + $this->directory, + '/default-build', + 'browser', + ); + + self::assertSame('browser', $config->profile); + self::assertSame($this->directory . '/generated', $config->browserDir); + } + public function testPreviewOneProjectIsRejected(): void { file_put_contents($this->directory . '/project.yml', <<<'YAML' @@ -103,4 +124,31 @@ YAML); $this->expectExceptionMessage('must target wasm32-wasip2'); WasiProjectConfig::load('project.yml', null, $this->directory, '/default-build'); } + + public function testBooleanWasmProfileIsRejected(): void + { + file_put_contents($this->directory . '/project.yml', <<<'YAML' +wasm: true +sources: + - src +YAML); + + self::assertTrue(WasiProjectConfig::isWasmEnabled($this->directory . '/project.yml')); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('must be `component` or `browser`'); + WasiProjectConfig::load('project.yml', null, $this->directory, '/default-build'); + } + + public function testUnsupportedWasmProfileAliasIsRejected(): void + { + file_put_contents($this->directory . '/project.yml', <<<'YAML' +wasm: web +sources: + - src +YAML); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('expected browser or component'); + WasiProjectConfig::load('project.yml', null, $this->directory, '/default-build'); + } } diff --git a/src/Build/WasiProjectConfig.php b/src/Build/WasiProjectConfig.php index f364d3ac..55970b1f 100644 --- a/src/Build/WasiProjectConfig.php +++ b/src/Build/WasiProjectConfig.php @@ -47,7 +47,7 @@ final readonly class WasiProjectConfig $buildDir = self::absolutePath($buildDir, $workingDirectory); if (!is_array($config)) { - return new self($realInput, $buildDir, null, null, self::normalizeProfile($cliProfile ?? 'browser')); + return new self($realInput, $buildDir, null, null, self::normalizeProfile($cliProfile ?? 'component')); } $target = (string) ($config['target-platform'] ?? 'wasm32-wasip2'); @@ -76,21 +76,16 @@ final readonly class WasiProjectConfig $output = $projectDir . DIRECTORY_SEPARATOR . $name . '.wasm'; } - $wasm = $config['wasm'] ?? true; - $configProfile = 'browser'; - if (is_string($wasm)) { - $configProfile = $wasm; - } elseif (is_array($wasm) && !empty($wasm['profile'])) { - $configProfile = (string) $wasm['profile']; - } elseif (!is_bool($wasm) && !is_array($wasm)) { - throw new RuntimeException('The `wasm` project option must be a boolean, profile name, or map'); + $configProfile = 'component'; + if (array_key_exists('wasm', $config)) { + if (!is_string($config['wasm'])) { + throw new RuntimeException('The `wasm` project option must be `component` or `browser`'); + } + $configProfile = $config['wasm']; } $profile = self::normalizeProfile($cliProfile ?? $configProfile); $browserPath = $config['wasm-browser-dir'] ?? null; - if (is_array($wasm) && !empty($wasm['browser-dir'])) { - $browserPath = $wasm['browser-dir']; - } $browserDir = $profile === 'browser' && !empty($browserPath) ? self::absolutePath((string) $browserPath, $projectDir) : null; @@ -111,20 +106,12 @@ final readonly class WasiProjectConfig if (!is_array($config) || !array_key_exists('wasm', $config)) { return false; } - $wasm = $config['wasm']; - if ($wasm === false || $wasm === null) { - return false; - } - return !is_array($wasm) || ($wasm['enabled'] ?? true) !== false; + return true; } private static function normalizeProfile(string $profile): string { $profile = strtolower(trim($profile)); - $profile = match ($profile) { - '', 'web' => 'browser', - default => $profile, - }; if (!in_array($profile, ['browser', 'component'], true)) { throw new RuntimeException("Unsupported WASI output profile `{$profile}`; expected browser or component"); } diff --git a/src/Translator.php b/src/Translator.php index eaec98f8..28203e38 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -267,7 +267,7 @@ class Translator extends Preprocessor $climate->tab()->out('--cxx-std C++ standard version (c++17, c++20, etc., default: c++17)'); $climate->tab()->out('--march Target CPU instruction set (e.g. native, x86-64-v3, armv8-a)'); $climate->tab()->out('--target-platform Cross-compilation target triple (e.g. aarch64-linux-gnu)'); - $climate->tab()->out('--wasm[=profile] Build WASI browser (default) or component output'); + $climate->tab()->out('--wasm[=profile] Build WASI component (default) or browser output'); $climate->tab()->out('--lto Enable Link Time Optimization (-flto)'); $climate->tab()->out('--no-literal-strings Disable literal strings optimization'); $climate->tab()->out('--php-version PHP language version to accept (8.2-8.5, default: 8.5)'); diff --git a/wasm/build-typephp-program.sh b/wasm/build-typephp-program.sh index bcbda9e0..cb6d5adf 100755 --- a/wasm/build-typephp-program.sh +++ b/wasm/build-typephp-program.sh @@ -171,7 +171,7 @@ done echo "Built TypePHP/WASI program: ${output}" -if [[ "${TYPEPHP_WASM_BROWSER:-1}" == 1 ]]; then +if [[ "${TYPEPHP_WASM_BROWSER:-0}" == 1 ]]; then # Chrome does not yet load components natively, so Jco lowers the same # WASI 0.2 component to core Wasm + ESM. jco_bin=${TYPEPHP_JCO:-jco}