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
pull/46/head
韩天峰 3 weeks ago
parent 9e26887c03
commit ac16f5fbac
  1. 14
      docs/TYPEPHP_WASI_BUILD.md
  2. 8
      docs/wasm.md
  3. 4
      examples/wasm-hello/README.md
  4. 11
      examples/wasm-hello/index.html
  5. 7
      examples/wasm-hello/main.js
  6. 3
      examples/wasm-hello/project.yml
  7. 1
      examples/wasm-hello/src/WasiDemo.php
  8. 6
      examples/wasm-hello/style.css
  9. 58
      phpunit/src/Build/WasiProjectConfigTest.php
  10. 29
      src/Build/WasiProjectConfig.php
  11. 2
      src/Translator.php
  12. 2
      wasm/build-typephp-program.sh

@ -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 <directory>` 覆盖。
单文件输入默认只生成当前目录下可由 Wasmtime 执行的 `hello.wasm` Component,不要求安装 Jco。生成的 `.cc` 与 host 模式使用相同的 build 目录规则,默认位于 TypePHP 根目录的 `build/`;可以使用 `--build-dir <directory>` 覆盖。
项目可以直接使用 `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=` 传递。

@ -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 <directory>` 指定。
## 执行
@ -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 目标下明确不支持。

@ -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`

@ -102,6 +102,17 @@
</article>
</section>
<section class="panel extensions-panel">
<div>
<p class="section-label">PHP runtime</p>
<h2>静态编译的 PHP 扩展</h2>
</div>
<strong id="extension-count">等待运行</strong>
<div id="extension-list" class="extension-list" aria-live="polite">
<span>由 get_loaded_extensions() 动态读取</span>
</div>
</section>
<details class="console panel">
<summary><span>Raw component output</span><kbd>stdout / stderr</kbd></summary>
<pre id="output">尚未运行。</pre>

@ -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);

@ -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

@ -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(),

@ -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; } }

@ -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');
}
}

@ -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");
}

@ -267,7 +267,7 @@ class Translator extends Preprocessor
$climate->tab()->out('--cxx-std <ver> C++ standard version (c++17, c++20, etc., default: c++17)');
$climate->tab()->out('--march <arch> Target CPU instruction set (e.g. native, x86-64-v3, armv8-a)');
$climate->tab()->out('--target-platform <triple> 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 <ver> PHP language version to accept (8.2-8.5, default: 8.5)');

@ -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}

Loading…
Cancel
Save