feat(compiler): add WASI 0.2 browser and component compilation support

- Add WasiProjectConfig class for managing WASI project configurations
- Implement --wasm=[browser|component] option parsing in compiler CLI
- Add automatic WASI detection based on project.yml files
- Integrate Jco for browser component generation
- Update documentation to reflect WASI 0.2 (Preview 2) support
- Add example wasm-hello project with browser demo
- Create index.html and main.js for browser-based WASI execution
- Implement OPFS persistence for virtual filesystem in browser
- Add type-safe environment variable handling for WASI builds
- Update compiler help text to show new WASM options
- Replace WASI Preview 1 references with WASI 0.2 terminology
pull/46/head
韩天峰 3 weeks ago
parent 9992793a02
commit 07c7d4b84a
  1. 2
      .gitignore
  2. 55
      docs/TYPEPHP_WASI_BUILD.md
  3. 25
      docs/TYPEPHP_WASM_IMPLEMENTATION_PLAN.md
  4. 24
      docs/wasm.md
  5. 5
      examples/wasm-hello/.gitignore
  6. 61
      examples/wasm-hello/README.md
  7. 114
      examples/wasm-hello/index.html
  8. 115
      examples/wasm-hello/main.js
  9. 4497
      examples/wasm-hello/package-lock.json
  10. 18
      examples/wasm-hello/package.json
  11. 9
      examples/wasm-hello/project.yml
  12. 97
      examples/wasm-hello/src/WasiDemo.php
  13. 10
      examples/wasm-hello/src/main.php
  14. 65
      examples/wasm-hello/style.css
  15. 135
      examples/wasm-hello/typephp-worker.mjs
  16. 10
      examples/wasm-hello/vite.config.js
  17. 6
      phpunit/code/wasi-process.php
  18. 6
      phpunit/code/wasi-signal.php
  19. 6
      phpunit/code/wasi-socket.php
  20. 106
      phpunit/src/Build/WasiProjectConfigTest.php
  21. 13
      phpunit/src/Build/WasiToolchainTest.php
  22. 144
      src/Build/WasiProjectConfig.php
  23. 15
      src/Build/WasiToolchain.php
  24. 2
      src/Translator.php
  25. 88
      src/compiler.php

2
.gitignore vendored

@ -29,4 +29,4 @@ tests/**/*.log
tests/**/*.out
tests/**/*.php
tests/**/*.sh
/browser/node_modules/
/*.browser/

@ -1,6 +1,6 @@
# 构建 TypePHP WASI 程序
TypePHP 已有一个可运行的 WASI Preview 1 原型。它将 TypePHP 生成的 C++、PHPX 核心、精简的 PHP 8.5 NTS、GMP、MPFR 和 mpdecimal 静态链接为单个 `.wasm` command 模块
TypePHP 使用稳定的 WASI 0.2(Preview 2)和 Component Model。TypePHP 生成的 C++、PHPX 核心、精简的 PHP 8.5 NTS、GMP、MPFR 和 mpdecimal 静态链接为单个 `.wasm` command component。WASI 0.1(Preview 1)不受支持
## 环境要求
@ -8,17 +8,18 @@ TypePHP 已有一个可运行的 WASI Preview 1 原型。它将 TypePHP 生成
- PHP 8.4 或更高版本,用于运行 TypePHP 编译器
- Autoconf、Automake、Libtool、Bison、re2c 和常规 C/C++ 构建工具
- Wasmtime 47 或更高版本,用于运行和测试产物
- Jco 1 或更高版本,用于 browser profile;component profile 不需要 Jco
WASI SDK 的 `bin` 目录和 Wasmtime 必须加入系统 `PATH`。编译器不会探测或使用 `/opt` 等约定安装目录,也不接受专用的工具目录配置。可以继续通过环境变量覆盖缓存位置:
```bash
export PATH="<wasi-sdk-bin>:<wasmtime-bin>:$PATH"
export PHP_WASM_BUILD_DIR=/tmp/php-8.5.9-wasm-build
export TYPEPHP_WASM_DEPS_PREFIX=/tmp/typephp-wasm-numeric/prefix
export TYPEPHP_WASM_RUNTIME_PREFIX=/tmp/typephp-wasm-runtime
export PHP_WASM_BUILD_DIR=/tmp/php-8.5.9-wasip2-build
export TYPEPHP_WASM_DEPS_PREFIX=/tmp/typephp-wasip2-numeric/prefix
export TYPEPHP_WASM_RUNTIME_PREFIX=/tmp/typephp-wasip2-runtime
```
执行 `--wasm` 时会先检查 `clang`、`clang++`、`llvm-ar`、`llvm-ranlib`、`llvm-nm`、`wasm-ld` 和 `wasmtime` 是否都能从 `PATH` 找到,检查最低版本,并确认 `clang++` 的默认目标是 `wasm32-wasi`。检测失败时不会进入代码生成或编译阶段
WASI 构建会检查 `wasm32-wasip2-clang`、`wasm32-wasip2-clang++`、`llvm-ar`、`llvm-ranlib`、`llvm-nm`、`wasm-component-ld` 和 `wasmtime`,并确认目标是 `wasm32-unknown-wasip2`。browser profile 另外检查 `jco`。所有工具只从 `PATH` 查找;npm script 会自动将项目本地的 `node_modules/.bin` 加入 `PATH`
## 一条命令构建
@ -38,7 +39,32 @@ function main(): void
php bin/tpc.php --wasm hello.php
```
输出文件默认为当前目录下的 `hello.wasm`。生成的 `.cc` 与 host 模式使用相同的 build 目录规则,默认位于 TypePHP 根目录的 `build/`;可以使用 `--build-dir <directory>` 覆盖。每个 `.o` 与对应 `.cc` 放在同一目录并在下次构建时直接覆盖,不建立 WASM 专用的深层对象目录。编译器用于传递本次源码列表的临时清单会在链接结束后自动清理。
单文件输入默认生成当前目录下的 `hello.wasm``hello.browser/` Jco 模块。生成的 `.cc` 与 host 模式使用相同的 build 目录规则,默认位于 TypePHP 根目录的 `build/`;可以使用 `--build-dir <directory>` 覆盖。
项目可以直接使用 `project.yml`
```yaml
name: wasm-hello
mode: bin
target-platform: wasm32-wasip2
wasm: true
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/`
若项目永久只生成 Component,也可以直接配置 `wasm: component`
命令行也可以显式选择产物:
- `--wasm``--wasm=browser`:生成 Component 和 Jco 浏览器模块,需要 `jco` 位于 `PATH`
- `--wasm=component`:仅生成可由 Wasmtime 运行的 Component,不检测 Jco。
路径、sources 等详细配置继续放在 `project.yml`,不通过 `--wasm=` 传递。
PHP、PHPX、TypePHP runtime、GMP、MPFR 和 mpdecimal 会预编译并缓存为 WASI 静态库;正常的应用构建只编译 TypePHP 为当前程序生成的 C++,然后链接这些 `.a`。源码开发环境首次执行时会自动建立缺失的运行时缓存,发行包可直接携带预编译静态库。
@ -50,6 +76,17 @@ PHP、PHPX、TypePHP runtime、GMP、MPFR 和 mpdecimal 会预编译并缓存为
wasmtime hello.wasm
```
Chrome Demo:
```bash
cd examples/wasm-hello
npm run wasm
npm ci
npm run dev
```
浏览器端始终在专用 Worker 中执行 Component。默认使用内存文件系统;发送给 Worker 的启动消息设置 `persistent: true` 后,会在启动和退出时通过 OPFS 恢复、保存文件系统快照。程序执行期间仍使用同步内存文件系统,避免每次 PHP 文件访问跨越异步 JS 边界。
## 高精度类型
WASI 产物包含 TypePHP 的三种语言级高精度类型:
@ -78,11 +115,11 @@ wasm32 使用 32 位指针,但 PHP 的 `zend_long` 保持 64 位,以维持 T
## 当前平台边界
- 仅支持 NTS、单线程。
- Fiber 和 TypePHP Generator 被禁用;编译器在发现 `yield` 时直接报致命错误。
- Fiber 和 Generator 被禁用;编译器在发现 `yield` 时直接报致命错误。
- PHPX Facade API 在 `__wasi__` 下整体禁用。PHPX 核心类型和 `phpx_std` 仍可使用。
- 不支持动态扩展、网络 socket、进程控制和依赖操作系统服务的扩展
- 不支持动态扩展、网络 socket、进程、shell 和信号。静态可识别的调用会在编译期报致命错误
- 保留 PHP stream 框架、本地文件能力以及由 WASI host 提供的时间和随机数能力。
- 当前产物是 WASI command module,适用于 Wasmtime 等 WASI 运行时,不能不经宿主适配直接放入浏览器运行
- `.wasm` 是同一份 WASI 0.2 command component:Wasmtime 直接运行;Chrome 使用 Jco 生成的 ESM 和 `examples/wasm-hello/typephp-worker.mjs` 中的 Worker host
PHPX Facade 只是为 PHP 可选扩展生成的便捷包装,并非 TypePHP ABI 的组成部分。WASI 下整体关闭它,可以避免把不存在的 curl、socket、Swoole、PDO 等 API 暴露为“可编译但链接失败”的接口。

@ -1,8 +1,8 @@
# TypePHP WASM 技术方案与实施计划
> 状态:WASI 命令行原型已实现,浏览器阶段待实现
> 状态:WASI 0.2 Component 与 Chrome Worker 原型已实现
> 调研日期:2026-08-07
> 当前目标:WASI Preview 1,NTS,单线程;后续适配浏览器
> 当前目标:WASI 0.2(Preview 2),NTS,单线程;不支持 WASI 0.1
## 1. 文档目的
@ -24,17 +24,17 @@ PHP 源码
+ TypePHP runtime
+ GMP / MPFR / mpdecimal
+ PHP embed/WASI 运行时
-> typephp.wasm(WASI command module
-> typephp.wasm(WASI 0.2 command component
```
具体决策如下:
1. 第一版复用当前 C++/Zend 后端,不直接生成 WAT/WASM,也不重新实现 PHP 运行时。
2. 先使用 WASI SDK 建立最小、可测试的命令行目标;浏览器阶段再选择 Emscripten 或 WASI adapter
2. 使用 WASI SDK 的 `wasm32-wasip2` sysroot 直接生成 Component;Chrome 由 Jco 转译为 ESM,不维护第二套 Emscripten ABI
3. PHP、PHPX、TypePHP 生成代码和高精度库全部静态链接到一个 `.wasm` 模块。
4. 当前由 WASI host 提供 stdout、clocks、随机数和受控文件系统;浏览器阶段也保持 WASI 接口,由浏览器中的 WASI host/adapter 实现这些能力
4. Wasmtime 和 Chrome 共同提供 CLI、stdio、exit、clocks、random 和受控文件系统;Chrome host 固定运行在 Worker 中
5. 仅支持 PHP NTS,不支持线程。
6. 禁用 Fiber 和 TypePHP Generator。
6. 禁用 Fiber 和 Generator。
7. 必须支持 C++ 异常以及 Zend bailout 所需的 `setjmp/longjmp`
8. 保留 PHP stream 框架和本地 stream,禁用网络 transport 和依赖操作系统进程能力的功能。
9. WordPress Playground 和其他 PHP-WASM 项目只作为补丁与移植经验来源,不作为 TypePHP 的依赖或代码基础。
@ -82,7 +82,7 @@ TypePHP 无法直接复用 Playground 发布的 PHP-WASM 二进制,因为 Type
- 无宿主适配的浏览器直接运行。
- pthread、Web Worker 并行 PHP 或共享内存。
- Fiber 和 TypePHP Generator。
- Fiber 和 Generator。
- 动态扩展加载。
- PHP 源码的运行时编译或通用 `eval()`
- TCP、UDP、Unix socket 和监听端口。
@ -107,14 +107,9 @@ TypePHP 无法直接复用 Playground 发布的 PHP-WASM 二进制,因为 Type
PHP、PHPX 和所有 TypePHP C++ 翻译单元必须使用一致的 Wasm EH/SJLJ 参数。链接器必须将函数签名不一致视为致命错误。
### 5.2 浏览器阶段
### 5.2 Chrome Component host
WASI command module 不能不经适配直接在浏览器运行。后续浏览器阶段需要在以下两种路线中选择:
- 使用 Emscripten 重建同一套静态运行时;
- 为已验证的 WASI 模块提供浏览器 WASI adapter/component host。
选择应以异常语义、文件系统、模块体积和 JavaScript 宿主接口的实测结果为依据。浏览器适配不得改变 TypePHP、PHPX 或高精度类型的语言语义。
Chrome 当前不能原生实例化 Component。构建器使用 Jco 将同一份 WASI 0.2 Component 转译为 core Wasm 与 ESM,并由 `examples/wasm-hello/typephp-worker.mjs` 演示宿主入口。浏览器适配不包含 PHP、PHPX 或高精度类型语义。
## 6. 产物和运行模型
@ -234,7 +229,7 @@ PHP 标准库大量依赖 stream。完全关闭 stream 会破坏文件读写、`
| 信号 | 不支持 |
| 用户、组、权限 | 固定值或明确报错 |
| 文件锁 | 首期不支持跨实例锁;单实例内按需降级 |
| 持久化 | 首期不提供;后续可选 IDBFS/OPFS |
| 持久化 | 默认关闭;Chrome 可显式启用 OPFS 文件系统快照 |
编译器应逐步增加 WASM target capability 检查:静态可识别的不支持函数在编译期报错;动态调用无法静态判断时,由运行时返回确定错误。禁止让这些调用表现为链接期缺失符号、空函数或未定义行为。

@ -0,0 +1,24 @@
## 编译
```shell
./tpc --wasm test.php
```
编译成功后生成 WASI 0.2 Component `test.wasm``test.browser/` Jco 模块。WASI 0.1 不受支持。
生成的 C++ 源码默认写入 `build/`,也可以通过 `--build-dir <directory>` 指定。
## 执行
```shell
wasmtime test.wasm
```
## Chrome
```shell
cd test.browser
npm install
npm run dev
```
完整浏览器 Demo 位于仓库 `examples/wasm-hello/`,并使用 `project.yml` 构建。TypePHP 在专用 Worker 中执行;默认文件系统驻留内存,可显式启用 OPFS 快照持久化。网络 socket、进程、shell 和信号在 WASI 目标下明确不支持。

@ -0,0 +1,5 @@
/build/
/component/
/dist/
/generated/
/node_modules/

@ -0,0 +1,61 @@
# TypePHP WASI Browser Lab
这是一个由 `project.yml` 构建的完整 TypePHP/WASI 0.2 浏览器应用。PHP 代码编译成一个自包含的 Component,Jco 将同一个 Component 转译为浏览器 ESM,页面通过 module Worker 加载它。
Demo 展示以下已支持能力:
- 命令行参数、环境变量与标准输入/输出
- WASI wall clock 和 PHP `time()` / `date()`
- 安全随机数 `random_int()` / `random_bytes()`
- 内存文件系统,以及可选的 OPFS 快照持久化
- PHP 8.5 runtime 信息
- TypePHP 语言级 BigInt、Decimal、BigFloat 高精度计算
网络、进程、shell、信号、Fiber 和 Generator 明确不支持。
## 构建
先确保 WASI SDK 和 Wasmtime 已加入 `PATH`,然后在本目录执行:
```bash
npm ci
npm run wasm
```
等价的仓库根目录命令是:
```bash
php bin/tpc.php examples/wasm-hello/project.yml
```
`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-dir: generated`:Jco 浏览器模块
Jco 是本项目的开发依赖。先执行 `npm ci`,之后通过 `npm run wasm` 构建时,npm 会自动把本地 `node_modules/.bin/jco` 加入 `PATH`
若只需要供 Wasmtime 使用的 Component,可以绕过 Jco:
```bash
php ../../bin/tpc.php project.yml --wasm=component
```
## 浏览器运行
```bash
npm run dev
```
打开终端显示的本地地址。可以修改参数、环境变量和 stdin 后重复运行;勾选 OPFS 后,PHP 写入虚拟文件系统的运行次数会跨页面刷新保存。
生产构建:
```bash
npm run build
```
`src/` 是 TypePHP 应用,`typephp-worker.mjs` 是浏览器 WASI host,`main.js` 和 `style.css` 负责交互界面。`build/`、`component/`、`dist/`、`generated/` 均为可重新生成的输出。

@ -0,0 +1,114 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#07111f">
<title>TypePHP · WASI 0.2 Browser Lab</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="ambient ambient-one"></div>
<div class="ambient ambient-two"></div>
<main class="shell">
<header class="hero">
<div>
<p class="eyebrow"><span class="pulse"></span> WASI 0.2 · Preview 2</p>
<h1>TypePHP<br><span>Browser Lab</span></h1>
<p class="lede">同一个由 PHP、PHPX 与 TypePHP 静态链接的 Component,在浏览器 Worker 中直接执行。</p>
</div>
<div class="runtime-orbit" aria-hidden="true">
<div class="orbit-ring"></div>
<div class="runtime-core">PHP<br><strong>AOT</strong></div>
<span class="satellite one">WASI</span>
<span class="satellite two">Wasm</span>
<span class="satellite three">C++17</span>
</div>
</header>
<section class="control-panel panel">
<div class="panel-heading">
<div>
<p class="section-label">Launch configuration</p>
<h2>配置本次运行</h2>
</div>
<div id="status" class="status idle"><span></span>Ready</div>
</div>
<div class="form-grid">
<label>
<span>命令行参数</span>
<input id="args" value="Ada 42 --browser" autocomplete="off">
<small>空格分隔,支持单引号和双引号</small>
</label>
<label>
<span>环境变量</span>
<input id="env" value="DEMO_GREETING=Hello from Chrome" autocomplete="off">
<small>每行一个 KEY=VALUE</small>
</label>
<label class="wide">
<span>标准输入</span>
<textarea id="stdin" rows="2">A message sent through WASI stdin.</textarea>
</label>
</div>
<div class="actions">
<label class="switch-row">
<input id="persistent" type="checkbox" checked>
<span class="switch"><i></i></span>
<span><strong>OPFS 持久化</strong><small>刷新页面后保留虚拟文件系统</small></span>
</label>
<div class="button-row">
<button id="reset" class="button secondary" type="button">清空存储</button>
<button id="run" class="button primary" type="button"><span>运行 TypePHP</span><b></b></button>
</div>
</div>
</section>
<section class="results">
<article class="feature-card accent-cyan">
<div class="icon"></div><p>Runtime</p><h3 id="runtime-value">等待运行</h3><small id="platform-value"></small>
</article>
<article class="feature-card accent-violet">
<div class="icon"></div><p>Clock</p><h3 id="clock-value"></h3><small>WASI wall clock</small>
</article>
<article class="feature-card accent-amber">
<div class="icon"></div><p>Secure random</p><h3 id="random-value"></h3><small id="token-value">WASI random</small>
</article>
<article class="feature-card accent-green">
<div class="icon"></div><p>Filesystem</p><h3 id="filesystem-value"></h3><small id="files-value">Memory FS + OPFS snapshot</small>
</article>
</section>
<section class="detail-grid">
<article class="panel data-panel">
<p class="section-label">Guest inputs</p>
<h2>参数、环境与标准流</h2>
<dl>
<div><dt>argv</dt><dd id="argv-value"></dd></div>
<div><dt>env</dt><dd id="env-value"></dd></div>
<div><dt>stdin</dt><dd id="stdin-value"></dd></div>
</dl>
</article>
<article class="panel precision-panel">
<p class="section-label">Language-level numeric types</p>
<h2>高精度计算</h2>
<dl>
<div><dt>BigInt</dt><dd id="bigint-value"></dd></div>
<div><dt>Decimal</dt><dd id="decimal-value"></dd></div>
<div><dt>BigFloat</dt><dd id="bigfloat-value"></dd></div>
</dl>
</article>
</section>
<details class="console panel">
<summary><span>Raw component output</span><kbd>stdout / stderr</kbd></summary>
<pre id="output">尚未运行。</pre>
</details>
<footer>
<span>TypePHP → C++17 → WASI 0.2 Component</span>
<span>运行于独立 ES module Worker · 网络能力关闭</span>
</footer>
</main>
<script type="module" src="/main.js"></script>
</body>
</html>

@ -0,0 +1,115 @@
const storageName = 'typephp-wasi-demo-filesystem.json';
const elements = Object.fromEntries([
'args', 'env', 'stdin', 'persistent', 'run', 'reset', 'status', 'output',
'runtime-value', 'platform-value', 'clock-value', 'random-value', 'token-value',
'filesystem-value', 'files-value', 'argv-value', 'env-value', 'stdin-value',
'bigint-value', 'decimal-value', 'bigfloat-value',
].map((id) => [id, document.getElementById(id)]));
let worker = null;
function parseArguments(source) {
const args = [];
const pattern = /"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^\s]+)/g;
for (const match of source.matchAll(pattern)) {
args.push((match[1] ?? match[2] ?? match[3]).replace(/\\([\\"'])/g, '$1'));
}
return args;
}
function parseEnvironment(source) {
return Object.fromEntries(source.split(/\r?\n/).flatMap((line) => {
const separator = line.indexOf('=');
return separator > 0 ? [[line.slice(0, separator).trim(), line.slice(separator + 1)]] : [];
}));
}
function setStatus(kind, label) {
elements.status.className = `status ${kind}`;
elements.status.lastChild.textContent = label;
}
function value(id, content) {
elements[id].textContent = content === '' ? '(空)' : String(content ?? '—');
}
function renderReport(report) {
value('runtime-value', `PHP ${report.runtime.php}`);
value('platform-value', report.runtime.platform);
value('clock-value', report.clock.iso8601);
value('random-value', report.random.integer);
value('token-value', report.random.token);
value('filesystem-value', `${report.filesystem.run} 次运行`);
value('files-value', report.filesystem.files.join(' · '));
value('argv-value', report.input.argv.join(' '));
value('env-value', report.input.greeting);
value('stdin-value', report.input.stdin);
value('bigint-value', report.precision.bigint);
value('decimal-value', report.precision.decimal);
value('bigfloat-value', report.precision.bigfloat);
}
function run() {
worker?.terminate();
worker = new Worker(new URL('./typephp-worker.mjs', import.meta.url), { type: 'module' });
let stdout = '';
let stderr = '';
elements.run.disabled = true;
elements.output.textContent = '正在实例化 WASI 0.2 Component…';
setStatus('running', 'Running');
worker.onmessage = ({ data }) => {
if (data.type === 'stdout') {
stdout += data.data;
} else if (data.type === 'stderr') {
stderr += data.data;
} else if (data.type === 'error') {
stderr += `${data.error}\n`;
} else if (data.type === 'exit') {
elements.run.disabled = false;
elements.output.textContent = [stdout, stderr].filter(Boolean).join('\n--- stderr ---\n');
try {
renderReport(JSON.parse(stdout));
setStatus(data.code === 0 ? 'success' : 'error', data.code === 0 ? 'Completed' : `Exit ${data.code}`);
} catch (error) {
setStatus('error', `Invalid output · ${data.code}`);
elements.output.textContent += `\n\nUI parse error: ${error.message}`;
}
worker.terminate();
worker = null;
}
};
worker.onerror = (event) => {
elements.run.disabled = false;
setStatus('error', 'Worker error');
elements.output.textContent = event.message;
};
worker.postMessage({
type: 'run',
args: parseArguments(elements.args.value),
env: parseEnvironment(elements.env.value),
stdin: elements.stdin.value,
persistent: elements.persistent.checked,
storageName,
});
}
async function resetStorage() {
if (!navigator.storage?.getDirectory) {
setStatus('error', 'OPFS unavailable');
return;
}
const root = await navigator.storage.getDirectory();
await root.removeEntry(storageName).catch((error) => {
if (error.name !== 'NotFoundError') throw error;
});
setStatus('idle', 'Storage cleared');
value('filesystem-value', '等待重新运行');
}
elements.run.addEventListener('click', run);
elements.reset.addEventListener('click', () => resetStorage().catch((error) => setStatus('error', error.message)));
run();

File diff suppressed because it is too large Load Diff

@ -0,0 +1,18 @@
{
"name": "typephp-wasm-hello",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"wasm": "php ../../bin/tpc.php project.yml",
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"@bytecodealliance/preview2-shim": "0.18.0"
},
"devDependencies": {
"@bytecodealliance/jco": "^1.27.0",
"vite": "^7.1.0"
}
}

@ -0,0 +1,9 @@
name: wasm-hello
mode: bin
target-platform: wasm32-wasip2
build-dir: build
output: component/wasm-hello.wasm
sources:
- src
wasm: true
wasm-browser-dir: generated

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
use native_types;
final class WasiDemo
{
public static function report(int $argc, array $argv, string $stdin): array
{
$greeting = getenv('DEMO_GREETING');
if ($greeting === false) {
$greeting = 'Hello from the WASI environment';
}
return [
'runtime' => [
'php' => PHP_VERSION,
'platform' => php_uname(),
'integerBits' => PHP_INT_SIZE * 8,
],
'clock' => [
'timestamp' => time(),
'iso8601' => date('Y-m-d H:i:s T'),
'microtime' => microtime(true),
],
'random' => [
'integer' => random_int(100000, 999999),
'token' => bin2hex(random_bytes(8)),
],
'input' => [
'argc' => $argc,
'argv' => $argv,
'greeting' => $greeting,
'stdin' => trim($stdin),
],
'filesystem' => self::filesystemReport(),
'precision' => self::precisionReport(),
'capabilities' => [
'supported' => ['arguments', 'environment', 'stdin/stdout/stderr', 'clock', 'random', 'filesystem'],
'disabled' => ['network', 'process', 'signals', 'shell', 'Fiber', 'Generator'],
],
];
}
private static function filesystemReport(): array
{
$directory = '/workspace';
if (!is_dir($directory)) {
mkdir($directory);
}
$counterFile = $directory . '/run-count.txt';
$counter = 0;
if (file_exists($counterFile)) {
$counter = (int) trim((string) file_get_contents($counterFile));
}
$counter++;
file_put_contents($counterFile, (string) $counter);
$messageFile = $directory . '/hello.txt';
$message = 'TypePHP wrote this file during browser run #' . $counter;
file_put_contents($messageFile, $message);
$files = scandir($directory);
if ($files === false) {
$files = [];
}
$visibleFiles = array_values(array_diff($files, ['.', '..']));
return [
'run' => $counter,
'readback' => (string) file_get_contents($messageFile),
'files' => $visibleFiles,
];
}
private static function precisionReport(): array
{
$big = std::bigInt('123456789012345678901234567890');
$bigResult = ($big * std::bigInt(1000000) + std::bigInt(42))->toString();
$price = std::decimal('199.95');
$taxRate = std::decimal('0.0825');
$decimalResult = ($price * (std::decimal(1) + $taxRate))->toString();
$pi = std::bigFloat('3.141592653589793238462643383279502884197');
$radius = std::bigFloat(12);
$bigFloatResult = ($pi * $radius * $radius)->toString();
return [
'bigint' => $bigResult,
'decimal' => $decimalResult,
'bigfloat' => $bigFloatResult,
];
}
}

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
function main(int $argc, array $argv): void
{
$stdin = stream_get_contents(STDIN);
$report = WasiDemo::report($argc, $argv, $stdin === false ? '' : $stdin);
echo json_encode($report, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
}

@ -0,0 +1,65 @@
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #07111f;
color: #eaf2ff;
font-synthesis: none;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; overflow-x: hidden; background: radial-gradient(circle at 50% -20%, #16345a 0, #07111f 42%, #040a13 100%); }
button, input, textarea { font: inherit; }
.ambient { position: fixed; width: 34rem; height: 34rem; border-radius: 50%; filter: blur(110px); opacity: .13; pointer-events: none; }
.ambient-one { top: -12rem; right: -8rem; background: #35e0ff; }
.ambient-two { bottom: -16rem; left: -10rem; background: #9d6cff; }
.shell { position: relative; width: min(1180px, calc(100% - 40px)); margin: 0 auto; padding: 64px 0 36px; }
.hero { display: grid; grid-template-columns: 1.2fr .8fr; align-items: center; min-height: 370px; gap: 40px; }
.eyebrow, .section-label { margin: 0 0 12px; color: #79e8ff; font-size: .73rem; font-weight: 800; letter-spacing: .17em; text-transform: uppercase; }
.pulse { display: inline-block; width: 8px; height: 8px; margin-right: 9px; border-radius: 50%; background: #6df5bd; box-shadow: 0 0 0 6px #6df5bd18; }
h1 { margin: 0; font-size: clamp(4rem, 8vw, 7rem); font-weight: 760; letter-spacing: -.07em; line-height: .82; }
h1 span { color: transparent; background: linear-gradient(90deg, #77eaff, #a789ff 62%, #f7a95c); background-clip: text; }
.lede { max-width: 620px; margin: 28px 0 0; color: #9fb1ca; font-size: 1.08rem; line-height: 1.8; }
.runtime-orbit { position: relative; width: 280px; height: 280px; margin: auto; display: grid; place-items: center; }
.orbit-ring { position: absolute; inset: 18px; border: 1px solid #83e9ff40; border-radius: 50%; box-shadow: inset 0 0 60px #558cff0c; animation: spin 22s linear infinite; }
.orbit-ring::before, .orbit-ring::after { content: ""; position: absolute; inset: 34px; border: 1px dashed #a789ff38; border-radius: 50%; }
.orbit-ring::after { inset: -15px; border-style: solid; border-color: #ffffff0d; }
.runtime-core { z-index: 1; display: grid; place-items: center; width: 120px; height: 120px; border: 1px solid #79e8ff55; border-radius: 32px; background: linear-gradient(145deg, #163552, #0b1b2c); color: #87ecff; text-align: center; box-shadow: 0 26px 80px #0008, inset 0 1px #ffffff18; transform: rotate(-5deg); }
.runtime-core strong { color: #fff; font-size: 2rem; }
.satellite { position: absolute; padding: 7px 11px; border: 1px solid #ffffff18; border-radius: 99px; background: #0c1929dd; color: #aebed3; font: 700 .66rem ui-monospace, monospace; }
.satellite.one { top: 20px; left: 31px; }.satellite.two { right: 3px; top: 108px; }.satellite.three { bottom: 22px; left: 16px; }
@keyframes spin { to { transform: rotate(360deg); } }
.panel { border: 1px solid #ffffff12; border-radius: 24px; background: linear-gradient(145deg, #0d1c2dcc, #091522d9); box-shadow: 0 25px 70px #00000035, inset 0 1px #ffffff09; backdrop-filter: blur(18px); }
.control-panel { padding: 30px; }
.panel-heading, .actions { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
h2 { margin: 0; font-size: 1.35rem; letter-spacing: -.025em; }
.status { display: flex; align-items: center; gap: 9px; padding: 8px 13px; border-radius: 99px; background: #ffffff08; color: #9cafc6; font-size: .76rem; font-weight: 750; text-transform: uppercase; letter-spacing: .08em; }
.status span { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.status.running { color: #ffd67a; }.status.running span { animation: blink .8s ease-in-out infinite alternate; }.status.success { color: #62edb2; }.status.error { color: #ff7d8d; }
@keyframes blink { to { opacity: .25; } }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin: 26px 0; }
label > span:first-child { display: block; margin: 0 0 8px; color: #c7d5e7; font-size: .82rem; font-weight: 680; }
label.wide { grid-column: 1 / -1; }
input, textarea { width: 100%; border: 1px solid #ffffff13; border-radius: 12px; outline: 0; background: #030b14a8; color: #eaf2ff; padding: 13px 15px; transition: border .2s, box-shadow .2s; }
input:focus, textarea:focus { border-color: #66ddff70; box-shadow: 0 0 0 3px #56dfff0d; }
textarea { resize: vertical; }
label small { display: block; margin-top: 7px; color: #62758e; font-size: .7rem; }
.actions { padding-top: 23px; border-top: 1px solid #ffffff0c; }
.switch-row { display: flex; align-items: center; gap: 12px; cursor: pointer; }
.switch-row input { position: absolute; opacity: 0; pointer-events: none; }
.switch { position: relative; width: 44px; height: 24px; border-radius: 20px; background: #28384b; transition: .2s; }
.switch i { position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; border-radius: 50%; background: #8292a7; transition: .2s; }
.switch-row input:checked + .switch { background: #27bb88; }.switch-row input:checked + .switch i { transform: translateX(20px); background: #fff; }
.switch-row > span:last-child strong, .switch-row > span:last-child small { display: block; }.switch-row strong { font-size: .8rem; }.switch-row small { margin-top: 3px; color: #687b94; font-size: .68rem; }
.button-row { display: flex; gap: 10px; }.button { border: 0; border-radius: 12px; padding: 12px 17px; color: #dce9f8; cursor: pointer; font-size: .8rem; font-weight: 750; transition: transform .18s, opacity .18s; }.button:hover { transform: translateY(-1px); }.button:disabled { opacity: .45; cursor: wait; transform: none; }.button.secondary { background: #ffffff0a; border: 1px solid #ffffff12; }.button.primary { min-width: 150px; display: flex; justify-content: space-between; background: linear-gradient(100deg, #25b9dc, #725ee8); box-shadow: 0 9px 30px #4584df2e; color: #fff; }
.results { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin: 16px 0; }
.feature-card { position: relative; min-height: 178px; padding: 21px; overflow: hidden; border: 1px solid #ffffff10; border-radius: 20px; background: #0a1725d9; }
.feature-card::after { content: ""; position: absolute; width: 110px; height: 110px; right: -42px; bottom: -52px; border-radius: 50%; background: var(--accent); filter: blur(45px); opacity: .18; }
.accent-cyan { --accent: #45defd; }.accent-violet { --accent: #9a78ff; }.accent-amber { --accent: #ffb860; }.accent-green { --accent: #4ee1a3; }
.icon { display: grid; place-items: center; width: 35px; height: 35px; border-radius: 10px; background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); font-size: 1.15rem; }
.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; }
.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; } }

@ -0,0 +1,135 @@
import {
_setStderr,
_setStdin,
_setStdout,
} from '@bytecodealliance/preview2-shim/cli';
import { _setFileData } from '@bytecodealliance/preview2-shim/filesystem';
import { WASIShim } from '@bytecodealliance/preview2-shim/instantiation';
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function outputHandler(stream) {
return {
write(bytes) {
self.postMessage({ type: stream, data: decoder.decode(bytes, { stream: true }) });
return BigInt(bytes.byteLength);
},
blockingFlush() {},
};
}
function inputHandler(text) {
const bytes = encoder.encode(text);
let offset = 0;
return {
blockingRead(length) {
if (offset >= bytes.byteLength) {
throw { tag: 'closed' };
}
const end = Math.min(offset + Number(length), bytes.byteLength);
const chunk = bytes.slice(offset, end);
offset = end;
return chunk;
},
};
}
function encodeFileData(value) {
return JSON.stringify(value, (_key, item) => item instanceof Uint8Array
? { typephpBytes: Array.from(item) }
: item);
}
function decodeFileData(value) {
return JSON.parse(value, (_key, item) => item && Array.isArray(item.typephpBytes)
? new Uint8Array(item.typephpBytes)
: item);
}
async function openPersistentFile(name) {
if (!navigator.storage?.getDirectory) {
throw new Error('OPFS is not available in this browser');
}
const root = await navigator.storage.getDirectory();
const handle = await root.getFileHandle(name, { create: true });
if (typeof handle.createSyncAccessHandle !== 'function') {
throw new Error('OPFS synchronous access requires a dedicated Worker');
}
return handle.createSyncAccessHandle();
}
async function loadFileData(storageName) {
const access = await openPersistentFile(storageName);
try {
const size = access.getSize();
if (size === 0) {
return { dir: {} };
}
const bytes = new Uint8Array(size);
access.read(bytes, { at: 0 });
return decodeFileData(decoder.decode(bytes));
} finally {
access.close();
}
}
async function saveFileData(storageName, fileData) {
const access = await openPersistentFile(storageName);
try {
const bytes = encoder.encode(encodeFileData(fileData));
access.truncate(0);
access.write(bytes, { at: 0 });
access.flush();
} finally {
access.close();
}
}
self.onmessage = async ({ data }) => {
if (data?.type !== 'run') {
return;
}
let exitCode = 0;
let fileData;
const persistent = data.persistent === true;
const storageName = String(data.storageName || 'typephp-wasi-filesystem.json');
try {
fileData = persistent ? await loadFileData(storageName) : { dir: {} };
_setFileData(fileData);
_setStdin(inputHandler(String(data.stdin || '')));
_setStdout(outputHandler('stdout'));
_setStderr(outputHandler('stderr'));
const args = ['typephp.wasm', ...(Array.isArray(data.args) ? data.args.map(String) : [])];
const env = data.env && typeof data.env === 'object' ? data.env : {};
const wasi = new WASIShim({
sandbox: {
args,
env,
enableNetwork: false,
},
});
const { instantiate } = await import('./generated/program.js');
const component = await instantiate(null, wasi.getImportObject());
await component.run.run();
} catch (error) {
if (error?.exitError) {
exitCode = Number(error.code || 0);
} else {
self.postMessage({ type: 'error', error: error?.stack || String(error) });
exitCode = 1;
}
} finally {
if (persistent && fileData) {
try {
await saveFileData(storageName, fileData);
} catch (error) {
self.postMessage({ type: 'error', error: error?.stack || String(error) });
exitCode = 1;
}
}
self.postMessage({ type: 'exit', code: exitCode });
}
};

@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: {
host: '127.0.0.1',
},
worker: {
format: 'es',
},
});

@ -0,0 +1,6 @@
<?php
function main(): void
{
proc_open('true', [], $pipes);
}

@ -0,0 +1,6 @@
<?php
function main(): void
{
pcntl_signal(SIGTERM, static function (): void {});
}

@ -0,0 +1,6 @@
<?php
function main(): void
{
stream_socket_server('tcp://127.0.0.1:0');
}

@ -0,0 +1,106 @@
<?php
namespace TypePhpTest\Build;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use TypePhp\Build\WasiProjectConfig;
final class WasiProjectConfigTest extends TestCase
{
private string $directory;
protected function setUp(): void
{
$this->directory = sys_get_temp_dir() . '/typephp-wasi-project-' . bin2hex(random_bytes(6));
mkdir($this->directory . '/src', 0777, true);
file_put_contents($this->directory . '/src/main.php', '<?php function main(): void {}');
}
protected function tearDown(): void
{
@unlink($this->directory . '/src/main.php');
@unlink($this->directory . '/project.yml');
@rmdir($this->directory . '/src');
@rmdir($this->directory);
}
public function testProjectPathsAreResolvedRelativeToYaml(): void
{
file_put_contents($this->directory . '/project.yml', <<<'YAML'
name: demo
mode: bin
target-platform: wasm32-wasip2
build-dir: cache/build
output: dist/demo.wasm
sources:
- src
wasm: true
wasm-browser-dir: web/generated
YAML);
$config = WasiProjectConfig::load(
'project.yml',
null,
$this->directory,
'/default-build',
);
self::assertSame(realpath($this->directory . '/project.yml'), $config->input);
self::assertSame($this->directory . '/cache/build', $config->buildDir);
self::assertSame($this->directory . '/dist/demo.wasm', $config->output);
self::assertSame($this->directory . '/web/generated', $config->browserDir);
self::assertSame('browser', $config->profile);
self::assertTrue(WasiProjectConfig::isWasmEnabled($this->directory . '/project.yml'));
}
public function testSingleFileKeepsBuilderOutputDefaults(): void
{
$config = WasiProjectConfig::load(
'src/main.php',
'custom-build',
$this->directory,
'/default-build',
);
self::assertSame($this->directory . '/custom-build', $config->buildDir);
self::assertNull($config->output);
self::assertNull($config->browserDir);
self::assertSame('browser', $config->profile);
}
public function testCliCanSelectComponentOnlyOutput(): void
{
file_put_contents($this->directory . '/project.yml', <<<'YAML'
name: demo
wasm: true
wasm-browser-dir: generated
sources:
- src
YAML);
$config = WasiProjectConfig::load(
'project.yml',
null,
$this->directory,
'/default-build',
'component',
);
self::assertSame('component', $config->profile);
self::assertNull($config->browserDir);
}
public function testPreviewOneProjectIsRejected(): void
{
file_put_contents($this->directory . '/project.yml', <<<'YAML'
target-platform: wasm32-wasi
sources:
- src
YAML);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('must target wasm32-wasip2');
WasiProjectConfig::load('project.yml', null, $this->directory, '/default-build');
}
}

@ -52,6 +52,19 @@ final class WasiToolchainTest extends TestCase
(new WasiToolchain())->detect();
}
public function testComponentOnlyProfileDoesNotRequireJco(): void
{
$this->installFakeTools(22, 47, 1, 'wasm32-unknown-wasip2');
unlink($this->directory . '/jco');
putenv('PATH=' . $this->directory);
$tools = (new WasiToolchain())->detect(false);
$this->assertArrayNotHasKey('jco', $tools);
$this->assertArrayNotHasKey('jco-version', $tools);
$this->assertSame('wasm32-unknown-wasip2', $tools['target']);
}
public function testRejectsOldLlvm(): void
{
$this->installFakeTools(21, 47, 1, 'wasm32-unknown-wasip2');

@ -0,0 +1,144 @@
<?php
namespace TypePhp\Build;
use RuntimeException;
use Symfony\Component\Yaml\Yaml;
final readonly class WasiProjectConfig
{
private function __construct(
public string $input,
public string $buildDir,
public ?string $output,
public ?string $browserDir,
public string $profile,
) {
}
public static function load(
string $input,
?string $cliBuildDir,
string $workingDirectory,
string $defaultBuildDir,
?string $cliProfile = null,
): self {
$input = self::absolutePath($input, $workingDirectory);
$realInput = realpath($input);
if ($realInput === false || !is_file($realInput)) {
throw new RuntimeException("WASI input does not exist: {$input}");
}
$projectDir = dirname($realInput);
$config = null;
if (preg_match('/\.ya?ml$/i', $realInput) === 1) {
$config = Yaml::parseFile($realInput);
if (!is_array($config)) {
throw new RuntimeException('WASI project YAML root must be a map');
}
}
$buildDir = $cliBuildDir;
if ($buildDir === null && is_array($config) && !empty($config['build-dir'])) {
$buildDir = (string) $config['build-dir'];
$buildDir = self::absolutePath($buildDir, $projectDir);
}
$buildDir ??= $defaultBuildDir;
$buildDir = self::absolutePath($buildDir, $workingDirectory);
if (!is_array($config)) {
return new self($realInput, $buildDir, null, null, self::normalizeProfile($cliProfile ?? 'browser'));
}
$target = (string) ($config['target-platform'] ?? 'wasm32-wasip2');
if (!in_array($target, ['wasm32-wasip2', 'wasm32-unknown-wasip2'], true)) {
throw new RuntimeException('A WASI project must target wasm32-wasip2');
}
$mode = strtolower((string) ($config['mode'] ?? $config['build-mode'] ?? $config['type'] ?? 'bin'));
if (!in_array($mode, ['bin', 'binary', 'cli'], true)) {
throw new RuntimeException('A WASI project must use bin mode');
}
if (!empty($config['output'])) {
$output = self::absolutePath((string) $config['output'], $projectDir);
$extension = pathinfo($output, PATHINFO_EXTENSION);
if ($extension === '') {
$output .= '.wasm';
} elseif (strcasecmp($extension, 'wasm') !== 0) {
throw new RuntimeException('A WASI project output must use the .wasm extension');
}
} else {
$name = trim((string) ($config['name'] ?? 'app'));
if ($name === '' || str_contains($name, '/') || str_contains($name, '\\')) {
throw new RuntimeException('A WASI project name must be a non-empty file name');
}
$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');
}
$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;
return new self($realInput, $buildDir, $output, $browserDir, $profile);
}
public static function isWasmEnabled(string $path): bool
{
if (!is_file($path) || preg_match('/\.ya?ml$/i', $path) !== 1) {
return false;
}
try {
$config = Yaml::parseFile($path);
} catch (\Throwable) {
return false;
}
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;
}
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");
}
return $profile;
}
private static function absolutePath(string $path, string $baseDirectory): string
{
if ($path === '') {
throw new RuntimeException('WASI project paths must not be empty');
}
if ($path[0] === '/' || $path[0] === '\\' || preg_match('/^[A-Za-z]:[\\\\\/]/', $path) === 1) {
return $path;
}
return rtrim($baseDirectory, '/\\') . DIRECTORY_SEPARATOR . $path;
}
}

@ -11,10 +11,10 @@ final class WasiToolchain
public const MIN_JCO_MAJOR = 1;
/** @return array<string, string> */
public function detect(): array
public function detect(bool $requireBrowserTools = true): array
{
$tools = [];
foreach ([
$requiredTools = [
'wasm32-wasip2-clang',
'wasm32-wasip2-clang++',
'llvm-ar',
@ -22,8 +22,11 @@ final class WasiToolchain
'llvm-nm',
'wasm-component-ld',
'wasmtime',
'jco',
] as $name) {
];
if ($requireBrowserTools) {
$requiredTools[] = 'jco';
}
foreach ($requiredTools as $name) {
$tools[$name] = $this->findExecutable($name);
}
@ -33,7 +36,9 @@ final class WasiToolchain
}
$this->requireVersion('wasm-component-ld', $tools['wasm-component-ld'], 0);
$versions['wasmtime'] = $this->requireVersion('wasmtime', $tools['wasmtime'], self::MIN_WASMTIME_MAJOR);
if ($requireBrowserTools) {
$versions['jco'] = $this->requireVersion('jco', $tools['jco'], self::MIN_JCO_MAJOR);
}
[$exitCode, $target, $error] = $this->run([$tools['wasm32-wasip2-clang++'], '--print-target-triple']);
$target = trim($target);
@ -51,7 +56,9 @@ final class WasiToolchain
$tools['target'] = $target;
$tools['clang-version'] = $versions['wasm32-wasip2-clang++'];
$tools['wasmtime-version'] = $versions['wasmtime'];
if ($requireBrowserTools) {
$tools['jco-version'] = $versions['jco'];
}
return $tools;
}

@ -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 Build a self-contained WASI command module from one PHP file');
$climate->tab()->out('--wasm[=profile] Build WASI browser (default) or component 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)');

@ -1,6 +1,7 @@
<?php
use TypePhp\Translator;
use TypePhp\Build\WasiToolchain;
use TypePhp\Build\WasiProjectConfig;
function main(int $argc, array $argv): void
{
@ -12,7 +13,7 @@ function main(int $argc, array $argv): void
define("ROOT_PATH", getcwd());
}
if (in_array('--wasm', $argv, true)) {
if (getenv('TYPEPHP_WASM_INTERNAL_COMPILE') !== '1' && shouldCompileWasm($argv)) {
compileWasmProgram($argv);
return;
}
@ -62,7 +63,7 @@ function main(int $argc, array $argv): void
}
/**
* Build a self-contained WASI command module through the compiler's public CLI.
* Build a self-contained WASI 0.2 command component through the public CLI.
* The lower-level build scripts are implementation details and are not part of
* the user-facing workflow.
*/
@ -70,12 +71,26 @@ function compileWasmProgram(array $argv): void
{
$input = null;
$buildDir = null;
$profile = null;
$arguments = array_slice($argv, 1);
for ($i = 0, $count = count($arguments); $i < $count; $i++) {
$argument = $arguments[$i];
if ($argument === '--wasm') {
continue;
}
if (str_starts_with($argument, '--wasm=')) {
$value = substr($argument, strlen('--wasm='));
if ($value === '') {
fwrite(STDERR, "Option --wasm requires browser or component after `=`\n");
exit(1);
}
if ($profile !== null && $profile !== $value) {
fwrite(STDERR, "Option --wasm was specified with conflicting profiles\n");
exit(1);
}
$profile = $value;
continue;
}
if ($argument === '--build-dir') {
if (!isset($arguments[$i + 1]) || $arguments[$i + 1] === '') {
fwrite(STDERR, "Option --build-dir requires a directory\n");
@ -97,22 +112,29 @@ function compileWasmProgram(array $argv): void
exit(1);
}
if ($input !== null) {
fwrite(STDERR, "The --wasm mode accepts exactly one PHP input file\n");
fwrite(STDERR, "The --wasm mode accepts exactly one PHP file or project.yml\n");
exit(1);
}
$input = $argument;
}
if ($input === null) {
fwrite(STDERR, "Usage: php bin/tpc.php <program.php> --wasm [--build-dir <directory>]\n");
fwrite(STDERR, "Usage: php bin/tpc.php <program.php|project.yml> [--wasm[=browser|component]] [--build-dir <directory>]\n");
exit(1);
}
$workingDirectory = getcwd();
$buildDir ??= ROOT_PATH . DIRECTORY_SEPARATOR . 'build';
if (!str_starts_with($buildDir, DIRECTORY_SEPARATOR)
&& preg_match('/^[A-Za-z]:[\\\\\/]/', $buildDir) !== 1) {
$buildDir = $workingDirectory . DIRECTORY_SEPARATOR . $buildDir;
try {
$project = WasiProjectConfig::load(
$input,
$buildDir,
$workingDirectory,
ROOT_PATH . DIRECTORY_SEPARATOR . 'build',
$profile,
);
} catch (RuntimeException $exception) {
fwrite(STDERR, "Invalid WASI project: {$exception->getMessage()}\n");
exit(1);
}
$builder = dirname(__DIR__) . '/projects/php-8.5.9/wasm/build-typephp-program.sh';
@ -122,10 +144,15 @@ function compileWasmProgram(array $argv): void
}
try {
$tools = (new WasiToolchain())->detect();
$tools = (new WasiToolchain())->detect($project->profile === 'browser');
} catch (RuntimeException $exception) {
fwrite(STDERR, "WASI toolchain check failed: {$exception->getMessage()}\n");
fwrite(STDERR, "Add WASI SDK and Wasmtime bin directories to PATH, then try again.\n");
fwrite(STDERR, "Add WASI SDK and Wasmtime bin directories to PATH");
if ($project->profile === 'browser') {
fwrite(STDERR, ", and install Jco (`npm install -g @bytecodealliance/jco`)"
. " or use --wasm=component");
}
fwrite(STDERR, ", then try again.\n");
exit(1);
}
@ -140,15 +167,26 @@ function compileWasmProgram(array $argv): void
$environment['TYPEPHP_WASI_NM'] = $tools['llvm-nm'];
$environment['TYPEPHP_WASI_LD'] = $tools['wasm-ld'];
$environment['TYPEPHP_WASMTIME'] = $tools['wasmtime'];
$environment['TYPEPHP_WASM_BROWSER'] = $project->profile === 'browser' ? '1' : '0';
if ($project->profile === 'browser') {
$environment['TYPEPHP_JCO'] = $tools['jco'];
$environment['TYPEPHP_JCO_VERSION'] = $tools['jco-version'];
}
$environment['TYPEPHP_WASI_TARGET'] = $tools['target'];
$environment['TYPEPHP_WASI_CLANG_VERSION'] = $tools['clang-version'];
$environment['TYPEPHP_WASMTIME_VERSION'] = $tools['wasmtime-version'];
$environment['TYPEPHP_JCO_VERSION'] = $tools['jco-version'];
$environment['TYPEPHP_WASM_PROGRAM_BUILD_DIR'] = $buildDir;
$environment['TYPEPHP_WASM_PROGRAM_BUILD_DIR'] = $project->buildDir;
if ($project->browserDir !== null) {
$environment['TYPEPHP_WASM_BROWSER_DIR'] = $project->browserDir;
}
$command = [$builder, $project->input];
if ($project->output !== null) {
$command[] = $project->output;
}
$process = proc_open(
[$builder, $input],
$command,
[STDIN, STDOUT, STDERR],
$pipes,
getcwd(),
@ -165,6 +203,30 @@ function compileWasmProgram(array $argv): void
}
}
function shouldCompileWasm(array $argv): bool
{
foreach (array_slice($argv, 1) as $argument) {
if ($argument === '--wasm' || str_starts_with($argument, '--wasm=')) {
return true;
}
}
$workingDirectory = getcwd();
foreach (array_slice($argv, 1) as $argument) {
if ($argument === '' || $argument[0] === '-') {
continue;
}
$path = $argument;
if ($path[0] !== '/' && preg_match('/^[A-Za-z]:[\\\\\/]/', $path) !== 1) {
$path = $workingDirectory . DIRECTORY_SEPARATOR . $path;
}
if (WasiProjectConfig::isWasmEnabled($path)) {
return true;
}
}
return false;
}
function profileAnalyze(int $argc, array $argv): void
{
$profFile = $argv[1];

Loading…
Cancel
Save