Merge branch 'master' into fix-bool-ternary-expr

pull/45/head
韩天峰 3 weeks ago
commit 1baefe166f
  1. 3
      .gitignore
  2. 1
      docs/README.md
  3. 70
      docs/TYPEPHP_WASM_IMPLEMENTATION_PLAN.md
  4. 226
      docs/WASI_BUILD.md
  5. 22
      docs/wasm.md
  6. 3
      examples/hello.php
  7. 6
      examples/https.php
  8. 5
      examples/wasm-hello/.gitignore
  9. 73
      examples/wasm-hello/README.md
  10. 134
      examples/wasm-hello/index.html
  11. 231
      examples/wasm-hello/main.js
  12. 4497
      examples/wasm-hello/package-lock.json
  13. 18
      examples/wasm-hello/package.json
  14. 10
      examples/wasm-hello/project.yml
  15. 4
      examples/wasm-hello/public/fetch-demo.json
  16. 145
      examples/wasm-hello/src/WasiDemo.php
  17. 23
      examples/wasm-hello/src/main.php
  18. 88
      examples/wasm-hello/style.css
  19. 154
      examples/wasm-hello/typephp-worker.mjs
  20. 10
      examples/wasm-hello/vite.config.js
  21. 30
      package.php
  22. 7
      phpunit/code/wasi-backtick.php
  23. 7
      phpunit/code/wasi-generator-arrow.php
  24. 9
      phpunit/code/wasi-generator-closure.php
  25. 6
      phpunit/code/wasi-process.php
  26. 6
      phpunit/code/wasi-signal.php
  27. 6
      phpunit/code/wasi-socket.php
  28. 9
      phpunit/code/wasm-export-invalid-method.php
  29. 6
      phpunit/code/wasm-export-invalid-name.php
  30. 7
      phpunit/code/wasm-export-valid.php
  31. 47
      phpunit/src/Build/PhpxLocatorTest.php
  32. 177
      phpunit/src/Build/WasiProjectConfigTest.php
  33. 108
      phpunit/src/Build/WasiToolchainTest.php
  34. 89
      phpunit/src/Build/WasmInterfaceGeneratorTest.php
  35. 3
      phpunit/src/CompileTimeAttributeRegistryTest.php
  36. 13
      phpunit/src/CompilerBaseApiTest.php
  37. 13
      phpunit/src/Generator/FiberGeneratorTest.php
  38. 13
      phpunit/src/Platform/PlatformTest.php
  39. 54
      phpunit/src/WasiUnsupportedSyntaxTest.php
  40. 21
      phpunit/src/WasmExportAttributeTest.php
  41. 48
      src/Build/PhpxLocator.php
  42. 7
      src/Build/SourcePipelineTrait.php
  43. 158
      src/Build/WasiProjectConfig.php
  44. 119
      src/Build/WasiToolchain.php
  45. 470
      src/Build/WasmInterfaceGenerator.php
  46. 119
      src/CompilerBase.php
  47. 4
      src/Entity/FunctionDef.php
  48. 3
      src/Generator/ClosureGenerator.php
  49. 3
      src/Generator/FiberGenerator.php
  50. 3
      src/Parser/FunctionCallTrait.php
  51. 50
      src/Platform/Wasi.php
  52. 62
      src/Preprocessor.php
  53. 2
      src/Transform/CompileTimeAttributeRegistry.php
  54. 83
      src/Translator.php
  55. 242
      src/compiler.php
  56. 14
      src/polyfills.php
  57. 139
      wasm/README.md
  58. 136
      wasm/build-sdk.sh
  59. 278
      wasm/build-typephp-program.sh
  60. 15
      wasm/examples/high-precision.php
  61. 38
      wasm/link-numeric-smoke-test.sh
  62. 52
      wasm/numeric-smoke-test.cc
  63. 35
      wasm/test-typephp-program.sh

3
.gitignore vendored

@ -21,7 +21,7 @@
*.lib
*.exp
*.class
/swoole_compiler
*.wasm
/tpc
tests/**/*.diff
tests/**/*.exp
@ -29,3 +29,4 @@ tests/**/*.log
tests/**/*.out
tests/**/*.php
tests/**/*.sh
/*.browser/

@ -18,6 +18,7 @@
- [后端中立 IR](BACKEND_NEUTRAL_IR.md)
- [TypePHP WASM 技术方案与实施计划](TYPEPHP_WASM_IMPLEMENTATION_PLAN.md)
- [构建 TypePHP WASI 程序](WASI_BUILD.md)
- [核心重构计划](REFACTORING_PLAN.md)
- [构建速度研究](AOT_BUILD_SPEED_RESEARCH.md)
- [优化优先级](aot-optimization-priority.md)

@ -1,14 +1,14 @@
# TypePHP WASM 技术方案与实施计划
> 状态:技术预研结论,供后续实现使用
> 状态:WASI 0.2 Component 与 Chrome Worker 原型已实现
> 调研日期:2026-08-07
> 首期目标:浏览器中的 Emscripten/WASM,NTS,单线程
> 当前目标:WASI 0.2(Preview 2),NTS,单线程;不支持 WASI 0.1
## 1. 文档目的
本文记录 TypePHP 支持 WebAssembly 的技术决策、功能边界、运行时架构、主要风险、验证方法和分阶段实施计划。
本文不是当前功能说明。TypePHP 尚未承诺已经支持 WASM,后续实现应以本文作为设计起点,并根据原型验证结果更新决策
2026-08-07 的实现验证已经证明:精简 PHP 8.5、PHPX 核心、TypePHP 生成代码、GMP、MPFR 和 mpdecimal 可以通过 WASI SDK 静态链接为单个模块,并在 Wasmtime 中运行。可复现构建方法见 [构建 TypePHP WASI 程序](WASI_BUILD.md)。本文余下内容同时保留浏览器阶段的设计目标
## 2. 核心结论
@ -18,28 +18,28 @@
PHP 源码
-> TypePHP 编译器
-> TypePHP 生成的 C++
-> Emscripten 编译和静态链接
-> WASI SDK 编译和静态链接
+ PHP NTS
+ PHPX
+ TypePHP runtime
+ GMP / MPFR / mpdecimal
+ 最小 TypePHP WASM SAPI
-> typephp.wasm + 薄 JavaScript loader
+ PHP embed/WASI 运行时
-> typephp.wasm(WASI 0.2 command component)
```
具体决策如下:
1. 第一版复用当前 C++/Zend 后端,不直接生成 WAT/WASM,也不重新实现 PHP 运行时。
2. 使用 Emscripten,而不是把 WASI 作为首期目标
2. 使用 WASI SDK 的 `wasm32-wasip2` sysroot 直接生成 Component;Chrome 由 Jco 转译为 ESM,不维护第二套 Emscripten ABI
3. PHP、PHPX、TypePHP 生成代码和高精度库全部静态链接到一个 `.wasm` 模块。
4. 浏览器侧保留一个很薄的 JavaScript loader,用于实例化模块和提供必要的宿主能力
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 的依赖或代码基础。
本文描述的是最短可落地路径。长期的后端中立方案参见 [BACKEND_NEUTRAL_IR.md](BACKEND_NEUTRAL_IR.md)。在首期原型成功前,不应为了 WASM 重写 TypePHP 前端和语义层。
本文描述的是最短可落地路径。长期的后端中立方案参见 [BACKEND_NEUTRAL_IR.md](BACKEND_NEUTRAL_IR.md)。WASI 原型证明无需为了 WASM 重写 TypePHP 前端和语义层。
## 3. 为什么不采用 WordPress Playground
@ -67,22 +67,22 @@ TypePHP 无法直接复用 Playground 发布的 PHP-WASM 二进制,因为 Type
## 4. 目标与非目标
### 4.1 首期目标
### 4.1 当前 WASI 目标
- 在主流浏览器中加载 TypePHP 编译产物。
- 在 Wasmtime 等 WASI runtime 中加载 TypePHP 编译产物。
- 执行静态编译的 TypePHP 应用入口。
- 保持 TypePHP 当前基于 Zend 和 PHPX 的主要语言语义。
- 正确处理 PHP request 生命周期、C++ 异常和 Zend bailout。
- 支持 GMP、MPFR 和 mpdecimal 高精度类型。
- 支持内存文件系统和必要的本地 PHP stream。
- 支持 WASI 文件系统和必要的本地 PHP stream。
- 对不支持的功能给出确定、可测试的错误,而不是链接失败或运行时崩溃。
- 构建过程可复现,php-src 和 Emscripten 版本固定。
- 构建过程可复现,php-src、WASI SDK 和数值库版本固定。
### 4.2 首期非目标
- WASI 独立运行时
- 无宿主适配的浏览器直接运行
- pthread、Web Worker 并行 PHP 或共享内存。
- Fiber 和 TypePHP Generator。
- Fiber 和 Generator。
- 动态扩展加载。
- PHP 源码的运行时编译或通用 `eval()`
- TCP、UDP、Unix socket 和监听端口。
@ -95,31 +95,21 @@ TypePHP 无法直接复用 Playground 发布的 PHP-WASM 二进制,因为 Type
## 5. 目标平台选择
### 5.1 首期使用 Emscripten
### 5.1 当前使用 WASI SDK
首期目标是浏览器,且 PHP 依赖 `setjmp/longjmp`,PHPX 和 TypePHP 依赖 C++ 异常。Emscripten 已经提供:
当前先建立命令行可验证基线。WASI SDK 已经验证可以同时提供:
- C/C++ 到 WebAssembly 的完整工具链;
- C++ exception handling;
- `setjmp/longjmp` 支持;
- 浏览器文件系统;
- JavaScript import/export 和模块加载;
- libc、时间、随机数和常见 POSIX 接口的兼容层。
- 标准 Wasm C++ exception handling;
- Zend bailout 所需的 SJLJ;
- capability-based 文件系统;
- libc、时间和随机数接口。
这些能力可以显著减少 PHP 移植工作
PHP、PHPX 和所有 TypePHP C++ 翻译单元必须使用一致的 Wasm EH/SJLJ 参数。链接器必须将函数签名不一致视为致命错误
### 5.2 暂不以 WASI 为目标
### 5.2 Chrome Component host
WASI 更适合非浏览器运行时和能力安全模型,但首期采用 WASI 会同时引入以下变量:
- PHP 和 Zend bailout 的兼容性;
- C++ 异常实现;
- 浏览器侧 WASI shim;
- 文件系统和异步 I/O;
- GMP、MPFR、mpdecimal 的 WASI 构建;
- 不同 WASI runtime 的实现差异。
在 Emscripten 原型稳定前,不同时维护第二套平台。后续如需支持 Wasmtime、Wasmer 或边缘运行时,应建立独立的 WASI 可行性项目,而不是把兼容层混入首期实现。
Chrome 当前不能原生实例化 Component。构建器使用 Jco 将同一份 WASI 0.2 Component 转译为 core Wasm 与 ESM,并由 `examples/wasm-hello/typephp-worker.mjs` 演示宿主入口。浏览器适配不包含 PHP、PHPX 或高精度类型语义。
## 6. 产物和运行模型
@ -133,12 +123,12 @@ dist/
└── typephp-wasm.mjs
```
所有 C/C++ 代码进入 `typephp.wasm`。`typephp-wasm.mjs` 只负责:
所有 C/C++ 代码进入 `typephp.wasm`浏览器自身不会自动提供 WASI imports;`typephp-wasm.mjs` 作为 WASI host/adapter 的装载入口,只负责:
- 获取和实例化 `.wasm`
- 提供 stdout/stderr;
- 初始化内存文件系统;
- 注入时间、随机数等宿主能力;
- 实现或接入 WASI clocks、随机数等宿主能力;
- 调用导出的 TypePHP 生命周期接口;
- 把状态码和错误信息转换为 JavaScript 结果。
@ -232,14 +222,14 @@ PHP 标准库大量依赖 stream。完全关闭 stream 会破坏文件读写、`
| 文件系统 | MEMFS;可选只读预加载文件 |
| 当前目录和路径 | 虚拟根目录,禁止泄漏宿主路径 |
| 环境变量 | loader 注入白名单 |
| 时间 | Emscripten/JavaScript host clock |
| 随机数 | 浏览器安全随机源,不使用弱伪随机替代 |
| 时间 | WASI clocks;浏览器宿主使用浏览器时钟实现该接口 |
| 随机数 | WASI random;浏览器宿主使用安全随机源实现,不使用弱伪随机替代 |
| DNS、socket | 不支持 |
| 进程、shell | 不支持 |
| 信号 | 不支持 |
| 用户、组、权限 | 固定值或明确报错 |
| 文件锁 | 首期不支持跨实例锁;单实例内按需降级 |
| 持久化 | 首期不提供;后续可选 IDBFS/OPFS |
| 持久化 | 默认关闭;Chrome 可显式启用 OPFS 文件系统快照 |
编译器应逐步增加 WASM target capability 检查:静态可识别的不支持函数在编译期报错;动态调用无法静态判断时,由运行时返回确定错误。禁止让这些调用表现为链接期缺失符号、空函数或未定义行为。

@ -0,0 +1,226 @@
# 构建 TypePHP WASI 程序
TypePHP 使用稳定的 WASI 0.2(Preview 2)和 Component Model。TypePHP 生成的 C++、PHPX 核心、精简的 PHP 8.5 NTS、GMP、MPFR 和 mpdecimal 会静态链接为单个 `.wasm` command 或 library component。WASI 0.1(Preview 1)不受支持。
## 环境要求
- WASI SDK 33 或更高版本(LLVM/Clang/LLD 22 或更高)
- PHP 8.4 或更高版本,用于运行 TypePHP 编译器
- Wasmtime 47 或更高版本,用于运行和测试产物
- Jco 1 或更高版本,用于 browser profile;component profile 不需要 Jco
- 与当前 TypePHP 版本绑定的 `wasm32-wasip2` 集成 SDK
WASI SDK 的 `bin` 目录和 Wasmtime 必须加入系统 `PATH`。编译器不会探测或使用 `/opt` 等约定安装目录,也不接受专用的工具目录配置。WASI 静态库和头文件统一安装到 PHPX 的 `wasm/wasm32-wasip2/`
```bash
export PATH="<wasi-sdk-bin>:<wasmtime-bin>:$PATH"
```
TypePHP 使用现有的 PHPX 定位规则:优先读取 `PHPX_HOME`,其次读取 Composer 的 `swoole/phpx` 安装位置,最后使用 `vendor/swoole/phpx`。不新增 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`
## 一条命令构建
command 模式的源文件必须提供 `main(): void`
```php
<?php
function main(): void
{
echo "Hello from TypePHP/WASI\n";
}
```
执行:
```bash
php bin/tpc.php --wasm hello.php
```
单文件输入默认只生成当前目录下可由 Wasmtime 执行的 `hello.wasm` Component,不要求安装 Jco。生成的 `.cc` 与 host 模式使用相同的 build 目录规则,默认位于 TypePHP 根目录的 `build/`;可以使用 `--build-dir <directory>` 覆盖。
项目可以直接使用 `project.yml`
```yaml
name: wasm-hello
mode: bin
wasm: component
build-dir: build
output: component/wasm-hello.wasm
sources:
- src
```
`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`
需要生成浏览器模块时,配置 `wasm: browser``wasm-browser-dir`,并确保 Jco 位于 `PATH`
命令行也可以显式选择产物:
- `--wasm``--wasm=component`:仅生成可由 Wasmtime 运行的 Component,不检测 Jco。
- `--wasm=browser`:生成 Component 和 Jco 浏览器模块,需要 `jco` 位于 `PATH`
路径、sources 等详细配置继续放在 `project.yml`,不通过 `--wasm=` 传递。
PHP、PHPX、TypePHP runtime、GMP、MPFR 和 mpdecimal 由 SDK 发布阶段预编译为 WASI 静态库。应用构建只编译 TypePHP 为当前程序生成的 C++,然后链接这些 `.a`。`tpc --wasm` 不会下载源码,也不会调用 PHP、PHPX 或高精度库的构建脚本。library 模式会调用 PHPX 包内固定版本的 `wit-bindgen` 生成当前应用的 Canonical ABI 绑定;普通用户不需要从 `PATH` 安装它。
PHP/WASI 当前静态内建 `date`、`pcre`、`hash`、`json`、`lexbor`、`random`、`Reflection`、`SPL`、`standard`、`uri`、`ctype`、`calendar`、`bcmath`、`filter`、`tokenizer`、`mbstring`、`zlib`、`fileinfo`、`sodium`、`openssl`、`libxml`、`dom`、`SimpleXML`、`xml`、`xmlreader`、`xmlwriter`、`PDO`、`pdo_sqlite`、`zip`、`bz2` 和 `exif` 扩展。OpenSSL 采用 crypto-only 构建,不包含 TLS stream transport;HTTP/HTTPS 仍由 WASI HTTP Component 提供。
每个 C/C++ 翻译单元统一使用标准 Wasm C++ exceptions 和 WASI SJLJ;链接阶段将 ABI 警告视为错误,旧的 32 位 `zend_long` 缓存也会自动失效。
运行:
```bash
wasmtime hello.wasm
```
Chrome Demo:
```bash
cd examples/wasm-hello
npm ci
npm run wasm
npm run dev
```
浏览器端始终在专用 Worker 中执行 Component。默认使用内存文件系统;发送给 Worker 的启动消息设置 `persistent: true` 后,会在启动和退出时通过 OPFS 恢复、保存文件系统快照。程序执行期间仍使用同步内存文件系统,避免每次 PHP 文件访问跨越异步 JS 边界。
## Command 与 Library 的 ZendVM 生命周期
### Command 模式
command 模式具有生成的 C++ `main()` 入口。入口依次调用:
```text
typephp_runtime_init(argc, argv)
→ php_embed_init()
→ PHP/SAPI module startup 与 MINIT
→ PHP request startup 与 RINIT
→ 注册并启动当前 TypePHP 应用模块
→ 当前应用的 MINIT 与 RINIT
执行 TypePHP main()
typephp_runtime_shutdown()
→ 当前应用的 RSHUTDOWN 与模块清理
→ php_embed_shutdown()
→ PHP request/module/SAPI shutdown
```
调用者不需要感知这些步骤,因为生成的原生 `main()` 会自动包围整个程序生命周期。
### Library 模式必须先创建 runtime resource
library component 没有可自动执行的 `main()`,单纯实例化 `.wasm` 只完成 Component 和 C/C++ Runtime 的实例化,不代表 ZendVM request 已经可用。Host 必须先调用生成的 WIT 函数:
```wit
create-runtime: func() -> result<runtime, typephp-error>;
```
浏览器中对应的调用为:
```js
const component = await instantiate(null, wasi.getImportObject());
const runtime = await component.api.createRuntime();
try {
const result = await runtime.someExportedFunction();
} finally {
runtime[Symbol.dispose]();
}
```
`createRuntime()` 内部调用 `typephp_runtime_init(1, argv)`。Host 只需要调用这一层稳定接口,不应直接调用 `php_embed_init()`、MINIT、RINIT 或任何 Zend C API。
当前初始化顺序如下:
1. `php_embed_init()` 初始化 Embed SAPI、PHP 核心和静态扩展,并启动 PHP request;PHP 核心与已经注册的静态扩展在这里完成 MINIT/RINIT。
2. 设置 PHPX 的异常桥接,使 PHP 异常可以安全返回到生成的 WIT `result`
3. 取得当前 TypePHP 应用的 `zend_module_entry`,调用 `zend_register_module_ex()``zend_startup_module_ex()`,完成应用模块注册与 MINIT。
4. 注册标准流并设置请求路径等 SAPI 请求信息。
5. 因为 Embed request 和请求内存池此时已经启动,生成代码会显式调用当前应用模块的 `request_startup_func`,补做该模块的 RINIT;RINIT 再初始化 TypePHP 请求级全局变量和类静态数据,完成后才返回 `runtime` resource。
这里“手动”调用的是 Host 可见的 `create-runtime()`,而不是让用户手动拼装 ZendVM 生命周期。MINIT/RINIT 的具体调用及其先后顺序全部封装在 PHPX 和生成的 Component adapter 中。
### 导出调用共享同一个 request
同一 `runtime` resource 上的所有 `#[WasmExport]` 调用共享一次 RINIT 建立的 Zend request:
- 不会在每次函数调用前后重复执行 RINIT/RSHUTDOWN。
- PHP request 内存池、请求级全局变量和静态状态会持续到 resource 被释放。
- 当前仅支持 NTS;同一个 runtime 上的调用必须串行,生成的 adapter 会拒绝并发或重入调用。
- 普通 PHP 异常会被转换为 WIT `result` 错误,runtime 仍然可以继续使用。
- Zend bailout 表示请求状态可能已经损坏,adapter 会将 runtime 标记为 failed,后续调用会被拒绝,直到 resource 被释放。
### 释放 resource 才会执行 RSHUTDOWN
释放 WIT `runtime` resource 会调用 `typephp_runtime_shutdown()`
1. 调用当前 TypePHP 应用模块的 RSHUTDOWN,清理 TypePHP 请求级对象和全局数据。
2. 注销并关闭当前应用模块,执行相应模块清理。
3. 调用 `php_embed_shutdown()`,完成其余扩展的 request shutdown、module shutdown 和 SAPI shutdown。
4. 最后释放 request 内存池,避免 PHP/CPP 包装对象在内存池消失后继续析构。
不要只依赖 JavaScript GC 触发 resource finalizer。浏览器和 Node Host 应在 `finally` 中显式调用 `runtime[Symbol.dispose]()`;Wasmtime 或其他 Host binding 也应显式 drop resource。直接终止 Worker 或进程会回收整个 Wasm 实例,但不保证 PHP 的 RSHUTDOWN/MSHUTDOWN 回调得到执行,因此不能把必须持久化的数据只放在关闭回调中。
一个 Component 实例当前只允许同时存在一个活动的 runtime resource。释放完成后可以重新创建;初始化失败或发生 Zend bailout 时,应先释放旧 resource,而不是继续调用导出函数。
## 高精度类型
WASI 产物包含 TypePHP 的三种语言级高精度类型:
- `BigInt`:GMP 6.3.0
- `BigFloat`:MPFR 4.2.2
- `Decimal`:mpdecimal 4.0.1
完整示例位于 [high-precision.php](../wasm/examples/high-precision.php)。构建并运行:
```bash
php bin/tpc.php --wasm wasm/examples/high-precision.php
wasmtime -S http high-precision.wasm
```
预期输出:
```text
1111111101111111110111111111010
1000000000000000000000000000001
12348.14159265358979324
```
wasm32 使用 32 位指针,但 PHP 的 `zend_long` 保持 64 位,以维持 TypePHP 与 64 位 PHP 的整数语义。GMP 和 mpdecimal 使用 32 位 limb;这不改变任意精度语义,但大数吞吐量低于具有汇编优化的原生 64 位构建。
## 当前平台边界
- 仅支持 NTS、单线程。
- Fiber 和 Generator 被禁用;编译器在发现 `yield` 时直接报致命错误。
- PHPX Facade API 在 `__wasi__` 下整体禁用。PHPX 核心类型和 `phpx_std` 仍可使用。
- 不支持动态扩展、网络 socket、进程、shell 和信号。静态可识别的调用会在编译期报致命错误。
- 保留 PHP stream 框架、本地文件能力以及由 WASI host 提供的时间和随机数能力。
- command component 可由 Wasmtime 直接运行;library component 需要 Host 按 WIT 接口调用 `create-runtime()` 和导出函数。Chrome 使用 Jco 生成的 ESM 和 `examples/wasm-hello/typephp-worker.mjs` 中的 Worker host。
PHPX Facade 只是为 PHP 可选扩展生成的便捷包装,并非 TypePHP ABI 的组成部分。WASI 下整体关闭它,可以避免把 curl、socket、Swoole 等不可用 API 暴露为“可编译但链接失败”的接口;PHP/WASI 静态内建扩展本身不受 Facade 开关影响。
## WASI SDK 目录
集成 SDK 使用唯一、完整的前缀,位于 PHPX 根目录的 `wasm/wasm32-wasip2/`
```text
phpx/wasm/wasm32-wasip2/
├── include/php/ # PHP 安装头文件
├── include/phpx/ # PHPX 和 TypePHP runtime 头文件
├── include/gmp.h ...
├── lib/libphp.a
├── lib/libphpx.a
├── lib/libgmp.a
├── lib/libgmpxx.a
├── lib/libmpfr.a
├── lib/libmpdec.a
├── lib/libmpdec++.a
└── .typephp-wasi-sdk-abi
```
普通用户通过 TypePHP/PHPX 集成安装包获得该目录。TypePHP 开发者需要自行 clone 与当前版本绑定的 `php-8.5.9-wasm` 和 PHPX 源码,并通过 `wasm/build-sdk.sh` 组装完整 SDK。PHP/WASI 只负责 PHP;PHPX 负责 GMP、MPFR、其专属的 mpdecimal 以及 PHPX runtime。所有产物安装到同一个 PHPX checkout。若 PHPX 不在 `vendor/swoole/phpx`,继续使用已有的 `PHPX_HOME` 指向该 checkout。
不提供单独覆盖 `libphp.a`、`libphpx.a` 或数值库的路径;所有库、头文件和 `.typephp-wasi-sdk-abi` 必须来自同一次兼容构建,避免混用不同的 `zend_long`、C++ exceptions、SJLJ 或 Component Model ABI。

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

@ -7,4 +7,7 @@ function main(): void
var_dump(php_uname());
global $argv;
var_dump($argv);
$date = date('Y-m-d H:i:s', time());
var_dump($date);
}

@ -0,0 +1,6 @@
<?php
function main()
{
$homepage = file_get_contents('https://www.example.com/');
echo $homepage;
}

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

@ -0,0 +1,73 @@
# 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 快照持久化
- 通过同步 `file_get_contents()` 发起 HTTP/HTTPS GET;浏览器等待期间由 JSPI 挂起 Wasm 调用栈
- PHP 8.5 runtime 信息
- 由 `get_loaded_extensions()` 动态读取的 PHP/WASI 内置扩展列表;点击扩展后,JavaScript 调用 `#[WasmExport]` 导出的函数读取版本、函数、类、常量和 INI 配置
- TypePHP 语言级 BigInt、Decimal、BigFloat 高精度计算
原始 socket、进程、shell、信号、Fiber 和 Generator 明确不支持。
浏览器构建要求支持 `WebAssembly.Suspending``WebAssembly.promising`
的 JSPI 实现。HTTP 请求仍受浏览器 CORS、CSP 和 Mixed Content 策略约束。
`file_get_contents()` 当前支持 GET、`http.timeout` 和
`http.ignore_errors`;不提供 Curl API,也不会退化为忙等待。
## 构建
先确保 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 源码
- `mode: library`:生成可由 JavaScript 多次调用的 Component,而不是运行一次即退出的命令
- `build-dir: build`:生成的 C++ 与目标文件
- `output: component/wasm-hello.wasm`:WASI 0.2 Component
- `wasm: browser`:显式生成浏览器模块;简单项目使用 `wasm: component` 只生成 Component
- 未配置 `target-platform` 时,WASM 项目默认使用 `wasm32-wasip2`
- `wasm-browser-dir: generated`:Jco 浏览器模块
- `wasm-package``wasm-world`:定义导出接口的稳定 WIT 名称
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 写入虚拟文件系统的运行次数会跨页面刷新保存。点击任意 PHP 扩展名称,页面会向 Worker 发送请求,Worker 调用 Wasm `runtime.getExtensionInfo()` 导出函数,最后由 JavaScript 解析返回的 JSON 并渲染扩展详情。
生产构建:
```bash
npm run build
```
`src/` 是 TypePHP 应用,`typephp-worker.mjs` 是浏览器 WASI host,`main.js` 和 `style.css` 负责交互界面。`build/`、`component/`、`dist/`、`generated/` 均为可重新生成的输出。
WASI 运行库、PHPX 和生成的 C++ 均使用 `-O2` 编译。最终链接会移除调试与符号段,以减少浏览器下载、解析和编译 Wasm 的开销;构建过程不会自动调用系统中的 `wasm-opt`,避免旧版 Binaryen 与 WASI SDK 生成的 Wasm 异常指令不兼容。

@ -0,0 +1,134 @@
<!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>
<article class="feature-card accent-cyan">
<div class="icon"></div><p>HTTP fetch</p><h3 id="http-value"></h3><small id="http-detail">file_get_contents() + WASI HTTP</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>
<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>
<article id="extension-detail" class="extension-detail" aria-live="polite">
<div class="extension-empty">
<strong>选择一个扩展</strong>
<span>点击上方扩展名称,由 JavaScript 调用 Wasm 导出函数读取详情。</span>
</div>
</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 · HTTP 通过 JSPI 挂起等待</span>
</footer>
</main>
<script type="module" src="/main.js"></script>
</body>
</html>

@ -0,0 +1,231 @@
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',
'http-value', 'http-detail', 'bigint-value', 'decimal-value', 'bigfloat-value',
'extension-count', 'extension-list',
].map((id) => [id, document.getElementById(id)]));
let worker = null;
let selectedExtension = '';
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('extension-count', `${report.runtime.extensions.length} 个内置扩展`);
elements['extension-list'].replaceChildren(...report.runtime.extensions.map((extension) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'extension-button';
button.dataset.extension = extension;
button.textContent = extension;
button.title = `查看 ${extension} 扩展信息`;
return button;
}));
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('http-value', report.http.ok ? `${report.http.bytes} bytes` : '请求失败');
value('http-detail', report.http.preview);
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 appendCodeList(container, values, emptyLabel = '无') {
if (!Array.isArray(values) || values.length === 0) {
const empty = document.createElement('span');
empty.textContent = emptyLabel;
container.append(empty);
return;
}
for (const item of values) {
const code = document.createElement('code');
code.textContent = String(item);
code.title = String(item);
container.append(code);
}
}
function extensionGroup(title, content) {
const group = document.createElement('section');
group.className = 'extension-group';
const heading = document.createElement('h4');
heading.textContent = title;
const items = document.createElement('div');
items.className = 'extension-items';
content(items);
group.append(heading, items);
return group;
}
function keyValueList(container, values) {
const entries = values && typeof values === 'object' ? Object.entries(values) : [];
if (entries.length === 0) {
appendCodeList(container, []);
return;
}
const list = document.createElement('dl');
for (const [key, item] of entries) {
const row = document.createElement('div');
const term = document.createElement('dt');
const value = document.createElement('dd');
term.textContent = key;
value.textContent = typeof item === 'string' ? item : JSON.stringify(item);
row.append(term, value);
list.append(row);
}
container.append(list);
}
function renderExtensionInfo(info) {
const heading = document.createElement('div');
heading.className = 'extension-detail-header';
const identity = document.createElement('div');
const name = document.createElement('h3');
name.textContent = info.name;
const version = document.createElement('p');
version.textContent = `version ${info.version}`;
identity.append(name, version);
const flags = document.createElement('div');
flags.className = 'extension-flags';
for (const label of [info.persistent ? 'persistent' : 'non-persistent', info.temporary ? 'temporary' : 'built-in']) {
const flag = document.createElement('span');
flag.textContent = label;
flags.append(flag);
}
heading.append(identity, flags);
const groups = document.createElement('div');
groups.className = 'extension-groups';
groups.append(
extensionGroup(`Functions · ${info.functions.length}`, (node) => appendCodeList(node, info.functions)),
extensionGroup(`Classes · ${info.classes.length}`, (node) => appendCodeList(node, info.classes)),
extensionGroup(`Constants · ${info.constants.length}`, (node) => appendCodeList(node, info.constants)),
extensionGroup('INI entries', (node) => keyValueList(node, info.iniEntries)),
extensionGroup('Dependencies', (node) => keyValueList(node, info.dependencies)),
);
document.getElementById('extension-detail').replaceChildren(heading, groups);
}
function loadExtensionInfo(extension) {
if (!worker) return;
selectedExtension = extension;
for (const button of elements['extension-list'].querySelectorAll('.extension-button')) {
button.classList.toggle('active', button.dataset.extension === extension);
}
document.getElementById('extension-detail').innerHTML = '<span class="extension-loading">正在调用 Wasm 导出函数…</span>';
worker.postMessage({ type: 'extension-info', extension });
}
function run() {
worker?.terminate();
worker = new Worker(new URL('./typephp-worker.mjs', import.meta.url), { type: 'module' });
let stdout = '';
let stderr = '';
selectedExtension = '';
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`;
elements.run.disabled = false;
setStatus('error', 'Wasm error');
elements.output.textContent = [stdout, stderr].filter(Boolean).join('\n--- stderr ---\n');
} else if (data.type === 'report') {
elements.run.disabled = false;
elements.output.textContent = [data.json, stdout, stderr].filter(Boolean).join('\n--- component output ---\n');
try {
renderReport(JSON.parse(data.json));
setStatus('success', 'Ready for JS calls');
} catch (error) {
setStatus('error', 'Invalid export result');
elements.output.textContent += `\n\nUI parse error: ${error.message}`;
}
} else if (data.type === 'extension-info') {
if (data.extension === selectedExtension) {
try {
renderExtensionInfo(JSON.parse(data.json));
} catch (error) {
document.getElementById('extension-detail').textContent = `无法解析扩展信息:${error.message}`;
}
}
} else if (data.type === 'extension-error' && data.extension === selectedExtension) {
document.getElementById('extension-detail').textContent = data.error;
}
};
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)));
elements['extension-list'].addEventListener('click', (event) => {
const button = event.target.closest('.extension-button');
if (button) loadExtensionInfo(button.dataset.extension);
});
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,10 @@
name: wasm-hello
mode: library
build-dir: build
output: component/wasm-hello.wasm
sources:
- src
wasm: browser
wasm-browser-dir: generated
wasm-package: typephp:wasm-hello@1.0.0
wasm-world: wasm-hello

@ -0,0 +1,4 @@
{
"message": "Hello from browser fetch via PHP file_get_contents()",
"runtime": "WASI HTTP 0.2 + JSPI"
}

@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
use native_types;
final class WasiDemo
{
public static function report(array $arguments, string $greeting, string $stdin): array
{
if ($greeting === '') {
$greeting = 'Hello from the WASI environment';
}
$argv = array_merge(['typephp.wasm'], $arguments);
return [
'runtime' => [
'php' => phpversion(),
'platform' => php_uname(),
'integerBits' => PHP_INT_SIZE * 8,
'extensions' => get_loaded_extensions(),
],
'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' => count($argv),
'argv' => $argv,
'greeting' => $greeting,
'stdin' => trim($stdin),
],
'filesystem' => self::filesystemReport(),
'http' => self::httpReport(),
'precision' => self::precisionReport(),
'capabilities' => [
'supported' => ['arguments', 'environment', 'stdin/stdout/stderr', 'clock', 'random', 'filesystem', 'HTTP GET'],
'disabled' => ['raw sockets', 'process', 'signals', 'shell', 'Fiber', 'Generator'],
],
];
}
public static function extensionInfo(string $name): array
{
if (!extension_loaded($name)) {
throw new InvalidArgumentException("PHP extension '{$name}' is not loaded");
}
$extension = new ReflectionExtension($name);
$functions = get_extension_funcs($name);
if ($functions === false) {
$functions = [];
}
return [
'name' => $extension->getName(),
'version' => $extension->getVersion() ?: 'built-in',
'persistent' => $extension->isPersistent(),
'temporary' => $extension->isTemporary(),
'dependencies' => $extension->getDependencies(),
'iniEntries' => $extension->getINIEntries(),
'constants' => array_keys($extension->getConstants()),
'functions' => array_values($functions),
'classes' => $extension->getClassNames(),
];
}
private static function httpReport(): array
{
$url = getenv('TYPEPHP_FETCH_URL');
if ($url === false || $url === '') {
return ['ok' => false, 'url' => '', 'bytes' => 0, 'preview' => 'No URL configured'];
}
$body = file_get_contents($url);
if ($body === false) {
return ['ok' => false, 'url' => $url, 'bytes' => 0, 'preview' => 'Request failed'];
}
return [
'ok' => true,
'url' => $url,
'bytes' => strlen($body),
'preview' => trim(substr($body, 0, 80)),
];
}
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,23 @@
<?php
declare(strict_types=1);
#[WasmExport(name: 'get-demo-report')]
function getDemoReport(string $argumentsJson, string $greeting, string $stdin): string
{
$arguments = json_decode($argumentsJson, true, flags: JSON_THROW_ON_ERROR);
$report = WasiDemo::report($arguments, $greeting, $stdin);
return json_encode(
$report,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
);
}
#[WasmExport(name: 'get-extension-info')]
function getExtensionInfo(string $extension): string
{
return json_encode(
WasiDemo::extensionInfo($extension),
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
);
}

@ -0,0 +1,88 @@
: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; }
.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; }
.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; }
.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, .extension-button { padding: 7px 11px; border: 1px solid #62daf329; border-radius: 99px; background: #38c5e70c; color: #a9c9dc; font: 650 .7rem ui-monospace, monospace; }
.extension-button { cursor: pointer; transition: border-color .18s, background .18s, color .18s, transform .18s; }
.extension-button:hover, .extension-button:focus-visible { border-color: #79e8ff88; background: #38c5e71c; color: #e7faff; outline: 0; transform: translateY(-1px); }
.extension-button.active { border-color: #a789ff99; background: #8e6fff20; color: #f0eaff; box-shadow: 0 0 0 3px #8e6fff0c; }
.extension-detail { grid-column: 1 / -1; min-height: 112px; padding: 22px; border: 1px solid #ffffff0c; border-radius: 18px; background: #030b1470; }
.extension-empty { display: flex; flex-direction: column; gap: 7px; color: #6f829a; font-size: .76rem; }
.extension-empty strong { color: #a9bad0; font-size: .88rem; }
.extension-detail-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 20px; }
.extension-detail-header h3 { margin: 0 0 6px; color: #eef6ff; font: 720 1.2rem ui-monospace, monospace; }
.extension-detail-header p { margin: 0; color: #71849d; font: .72rem ui-monospace, monospace; }
.extension-flags { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 7px; }
.extension-flags span { padding: 5px 8px; border-radius: 7px; background: #ffffff09; color: #91a6c0; font: 650 .64rem ui-monospace, monospace; }
.extension-groups { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.extension-group { min-width: 0; padding: 15px; border: 1px solid #ffffff0a; border-radius: 13px; background: #0915228a; }
.extension-group h4 { margin: 0 0 11px; color: #7890ab; font-size: .68rem; letter-spacing: .09em; text-transform: uppercase; }
.extension-items { display: flex; flex-wrap: wrap; gap: 6px; max-height: 180px; overflow: auto; }
.extension-items code { max-width: 100%; padding: 4px 7px; overflow: hidden; border-radius: 6px; background: #ffffff07; color: #b9cadf; font: .66rem ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
.extension-items dl { width: 100%; margin: 0; }
.extension-items dl div { grid-template-columns: minmax(90px, .7fr) 1fr; padding: 7px 0; }
.extension-items dl div:first-child { border-top: 0; }
.extension-loading { color: #79e8ff; font: .76rem 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, .extension-groups { 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, .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; } }

@ -0,0 +1,154 @@
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();
let runtime = null;
let fileData = null;
let persistent = false;
let storageName = 'typephp-wasi-filesystem.json';
let extensionQueue = Promise.resolve();
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();
}
}
async function start(data) {
try {
if (typeof WebAssembly.Suspending !== 'function'
|| typeof WebAssembly.promising !== 'function') {
throw new Error('This browser does not support WebAssembly JSPI, which is required for blocking WASI I/O');
}
persistent = data.persistent === true;
storageName = String(data.storageName || 'typephp-wasi-filesystem.json');
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 } : {};
env.TYPEPHP_FETCH_URL ??= new URL('/fetch-demo.json', self.location.href).href;
const wasi = new WASIShim({
sandbox: {
args,
env,
enableNetwork: true,
},
});
const { instantiate } = await import('./generated/program.js');
const component = await instantiate(null, wasi.getImportObject());
runtime = await component.api.createRuntime();
const json = await runtime.getDemoReport(
JSON.stringify(Array.isArray(data.args) ? data.args.map(String) : []),
String(env.DEMO_GREETING || ''),
String(data.stdin || ''),
);
if (persistent) {
await saveFileData(storageName, fileData);
}
self.postMessage({ type: 'report', json });
} catch (error) {
self.postMessage({ type: 'error', error: error?.stack || String(error) });
}
}
async function getExtensionInfo(extension) {
if (!runtime) {
throw new Error('TypePHP runtime is not ready');
}
const json = await runtime.getExtensionInfo(extension);
self.postMessage({ type: 'extension-info', extension, json });
}
self.onmessage = ({ data }) => {
if (data?.type === 'run') {
start(data);
} else if (data?.type === 'extension-info') {
const extension = String(data.extension || '');
extensionQueue = extensionQueue
.then(() => getExtensionInfo(extension))
.catch((error) => {
self.postMessage({ type: 'extension-error', extension, error: error?.stack || String(error) });
});
}
};

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

@ -595,13 +595,34 @@ function packageUnixLike(): void
$binary = 'tpc';
$versionFile = 'version.txt';
$phpxSourceDir = getenv('PHPX_HOME');
if (!is_string($phpxSourceDir) || $phpxSourceDir === '' || !is_dir($phpxSourceDir)) {
$phpxSourceDir = 'vendor/swoole/phpx';
}
$phpxSourceDir = realpath($phpxSourceDir) ?: rtrim($phpxSourceDir, '/\\');
$wasiSdkSourceRoot = $phpxSourceDir . '/wasm/wasm32-wasip2';
$wasiSdkPackageRoot = 'vendor/swoole/phpx/wasm/wasm32-wasip2';
$requiredFiles = [
$binary,
'composer.json',
'README.md',
'LICENSE.md',
'examples/hello.php',
'wasm/build-typephp-program.sh',
"{$wasiSdkSourceRoot}/.typephp-wasi-sdk-abi",
"{$wasiSdkSourceRoot}/include/php/main/php.h",
"{$wasiSdkSourceRoot}/include/php/main/php_config.h",
"{$wasiSdkSourceRoot}/include/php/Zend/zend_config.h",
"{$wasiSdkSourceRoot}/include/php/ext/date/lib/timelib_config.h",
"{$wasiSdkSourceRoot}/include/phpx/phpx.h",
"{$wasiSdkSourceRoot}/include/phpx/typephp_helper.h",
"{$wasiSdkSourceRoot}/include/gmp.h",
"{$wasiSdkSourceRoot}/include/mpfr.h",
"{$wasiSdkSourceRoot}/include/decimal.hh",
];
foreach (['libphp.a', 'libphpx.a', 'libgmp.a', 'libgmpxx.a', 'libmpfr.a', 'libmpdec.a', 'libmpdec++.a'] as $library) {
$requiredFiles[] = "{$wasiSdkSourceRoot}/lib/{$library}";
}
foreach ($requiredFiles as $file) {
if (!is_file($file)) {
throw new RuntimeException("Required package file not found: {$file}");
@ -682,6 +703,8 @@ function packageUnixLike(): void
mustCopy('LICENSE.md', "{$topLevelDir}/LICENSE.md");
mustCreateDirectory("{$topLevelDir}/examples");
mustCopy('examples/hello.php', "{$topLevelDir}/examples/hello.php");
copyDirectory('wasm', "{$topLevelDir}/wasm");
copyDirectory("{$phpxSourceDir}/wasm", "{$topLevelDir}/vendor/swoole/phpx/wasm");
if (is_file($outputFile) && !unlink($outputFile)) {
throw new RuntimeException("Unable to remove existing archive: {$outputFile}");
@ -717,7 +740,14 @@ function packageUnixLike(): void
"{$topLevelDir}/README.md",
"{$topLevelDir}/LICENSE.md",
"{$topLevelDir}/examples/hello.php",
"{$topLevelDir}/wasm/build-typephp-program.sh",
"{$topLevelDir}/{$wasiSdkPackageRoot}/.typephp-wasi-sdk-abi",
"{$topLevelDir}/{$wasiSdkPackageRoot}/include/php/main/php.h",
"{$topLevelDir}/{$wasiSdkPackageRoot}/include/phpx/phpx.h",
];
foreach (['libphp.a', 'libphpx.a', 'libgmp.a', 'libgmpxx.a', 'libmpfr.a', 'libmpdec.a', 'libmpdec++.a'] as $library) {
$requiredEntries[] = "{$topLevelDir}/{$wasiSdkPackageRoot}/lib/{$library}";
}
$verificationZip = new ZipArchive();
if ($verificationZip->open($outputFile) !== true) {
throw new RuntimeException("Unable to verify archive: {$outputFile}");

@ -0,0 +1,7 @@
<?php
function main(): void
{
$output = `echo unsupported`;
echo $output;
}

@ -0,0 +1,7 @@
<?php
function main(): void
{
$generator = fn (): iterable => yield 1;
var_dump($generator);
}

@ -0,0 +1,9 @@
<?php
function main(): void
{
$generator = function (): iterable {
yield 1;
};
var_dump($generator);
}

@ -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,9 @@
<?php
class WasmExportService
{
#[WasmExport]
public function execute(): void
{
}
}

@ -0,0 +1,6 @@
<?php
#[WasmExport(42)]
function invalidWasmExportName(): void
{
}

@ -0,0 +1,7 @@
<?php
#[WasmExport(name: 'greet-user')]
function greetUser(string $name): string
{
return "Hello, $name";
}

@ -0,0 +1,47 @@
<?php
namespace TypePhpTest\Build;
use PHPUnit\Framework\TestCase;
use TypePhp\Build\PhpxLocator;
final class PhpxLocatorTest extends TestCase
{
private string|false $originalPhpxHome;
private string $phpxHome;
protected function setUp(): void
{
$this->originalPhpxHome = getenv('PHPX_HOME');
$this->phpxHome = sys_get_temp_dir() . '/typephp-phpx-locator-' . bin2hex(random_bytes(6));
mkdir($this->phpxHome, 0777, true);
}
protected function tearDown(): void
{
if ($this->originalPhpxHome === false) {
putenv('PHPX_HOME');
} else {
putenv('PHPX_HOME=' . $this->originalPhpxHome);
}
rmdir($this->phpxHome);
}
public function testPhpxHomeHasPriorityAndReturnsAnAbsolutePath(): void
{
putenv('PHPX_HOME=' . $this->phpxHome);
self::assertSame(realpath($this->phpxHome), PhpxLocator::resolve('/not-used'));
}
public function testInvalidPhpxHomeFallsBackToComposerInstallation(): void
{
putenv('PHPX_HOME=' . $this->phpxHome . '/missing');
$projectRoot = dirname(__DIR__, 3);
self::assertSame(
realpath($projectRoot . '/vendor/swoole/phpx'),
PhpxLocator::resolve($projectRoot),
);
}
}

@ -0,0 +1,177 @@
<?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: browser
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::assertSame('command', $config->mode);
self::assertSame('typephp:demo@1.0.0', $config->package);
self::assertSame('demo', $config->world);
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('component', $config->profile);
self::assertSame('command', $config->mode);
}
public function testLibraryModeAndWitIdentityAreAccepted(): void
{
file_put_contents($this->directory . '/project.yml', <<<'YAML'
name: calculator
mode: library
wasm: component
wasm-package: acme:calculator@2.1.0
wasm-world: calculator-api
sources:
- src
YAML);
$config = WasiProjectConfig::load('project.yml', null, $this->directory, '/default-build');
self::assertSame('library', $config->mode);
self::assertSame('acme:calculator@2.1.0', $config->package);
self::assertSame('calculator-api', $config->world);
}
public function testComponentDoesNotRequireTargetPlatform(): 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',
);
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'
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');
}
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');
}
}

@ -0,0 +1,108 @@
<?php
namespace TypePhp\Tests\Build;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use TypePhp\Build\WasiToolchain;
final class WasiToolchainTest extends TestCase
{
private string $directory;
private string|false $originalPath;
protected function setUp(): void
{
$this->directory = sys_get_temp_dir() . '/typephp-wasi-tools-' . bin2hex(random_bytes(6));
mkdir($this->directory, 0777, true);
$this->originalPath = getenv('PATH');
}
protected function tearDown(): void
{
putenv('PATH=' . ($this->originalPath !== false ? $this->originalPath : ''));
foreach (glob($this->directory . '/*') ?: [] as $file) {
unlink($file);
}
rmdir($this->directory);
}
public function testDetectsSupportedToolsOnlyFromPath(): void
{
$this->installFakeTools(22, 47, 1, 'wasm32-unknown-wasip2');
putenv('PATH=' . $this->directory);
$tools = (new WasiToolchain())->detect();
$this->assertSame($this->directory . '/wasm32-wasip2-clang++', $tools['clang++']);
$this->assertSame($this->directory . '/wasmtime', $tools['wasmtime']);
$this->assertSame($this->directory . '/jco', $tools['jco']);
$this->assertSame('wasm32-unknown-wasip2', $tools['target']);
$this->assertSame('22.0.0', $tools['clang-version']);
$this->assertSame('47.0.0', $tools['wasmtime-version']);
$this->assertSame('1.0.0', $tools['jco-version']);
}
public function testRejectsMissingTool(): void
{
putenv('PATH=' . $this->directory);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('`wasm32-wasip2-clang` was not found in PATH');
(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');
putenv('PATH=' . $this->directory);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('`wasm32-wasip2-clang` 21 is too old');
(new WasiToolchain())->detect();
}
public function testRejectsNonWasiClangTarget(): void
{
$this->installFakeTools(22, 47, 1, 'x86_64-unknown-linux-gnu');
putenv('PATH=' . $this->directory);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('not configured for wasm32-unknown-wasip2');
(new WasiToolchain())->detect();
}
private function installFakeTools(int $llvmMajor, int $wasmtimeMajor, int $jcoMajor, string $target): void
{
foreach (['wasm32-wasip2-clang', 'llvm-ar', 'llvm-ranlib', 'llvm-nm'] as $tool) {
$this->writeExecutable($tool, "#!/bin/sh\necho 'LLVM version {$llvmMajor}.0.0'\n");
}
$this->writeExecutable('wasm-component-ld', "#!/bin/sh\necho 'wasm-component-ld version 0.5.22'\n");
$this->writeExecutable(
'wasm32-wasip2-clang++',
"#!/bin/sh\nif [ \"\$1\" = '--print-target-triple' ]; then echo '{$target}'; else echo 'clang version {$llvmMajor}.0.0'; fi\n",
);
$this->writeExecutable('wasmtime', "#!/bin/sh\necho 'wasmtime {$wasmtimeMajor}.0.0'\n");
$this->writeExecutable('jco', "#!/bin/sh\necho 'jco {$jcoMajor}.0.0'\n");
}
private function writeExecutable(string $name, string $contents): void
{
$path = $this->directory . '/' . $name;
file_put_contents($path, $contents);
chmod($path, 0755);
}
}

@ -0,0 +1,89 @@
<?php
namespace TypePhpTest\Build;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use TypePhp\Build\WasmInterfaceGenerator;
use TypePhp\Entity\ArgInfo;
use TypePhp\Entity\FunctionDef;
use TypePhp\Type;
final class WasmInterfaceGeneratorTest extends TestCase
{
public function testGeneratesTypedWitAndManifest(): void
{
$function = new FunctionDef('greetUser', Type::STR, 'App');
$function->wasmExport = true;
$function->displayName = 'App\\greetUser';
$function->returnTypeStr = 'string';
$argument = new ArgInfo();
$argument->name = 'name';
$argument->phpName = 'name';
$argument->type = Type::STR;
$function->argInfoList = [$argument];
$generator = new WasmInterfaceGenerator();
$manifest = $generator->buildManifest(
[$function],
'acme:demo@1.0.0',
'demo',
static fn (): string => 'php_app__greetuser',
);
self::assertSame('greet-user', $manifest['functions'][0]['name']);
self::assertSame('php_app__greetuser', $manifest['functions'][0]['cpp-symbol']);
self::assertSame('string', $manifest['functions'][0]['parameters'][0]['wit-type']);
self::assertStringContainsString(
'greet-user: func(name: string) -> result<string, typephp-error>;',
$generator->renderWit($manifest),
);
$adapter = $generator->renderCppAdapter($manifest);
self::assertStringContainsString(
'extern "C" bool exports_acme_demo_api_method_runtime_greet_user(',
$adapter,
);
self::assertStringContainsString('auto result = php_app__greetuser(', $adapter);
self::assertStringContainsString('catch (zend_object *exception)', $adapter);
self::assertSame(
"acme:demo/api@1.0.0#create-runtime\n"
. "acme:demo/api@1.0.0#[method]runtime.greet-user\n",
$generator->renderJcoAsyncExports($manifest),
);
}
public function testRejectsExportNameCollisions(): void
{
$first = new FunctionDef('first', Type::VOID, '');
$first->wasmExport = true;
$first->wasmExportName = 'same-name';
$second = new FunctionDef('second', Type::VOID, '');
$second->wasmExport = true;
$second->wasmExportName = 'same-name';
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('WasmExport name collision');
(new WasmInterfaceGenerator())->buildManifest(
[$first, $second],
'acme:demo@1.0.0',
'demo',
static fn (FunctionDef $function): string => $function->name,
);
}
public function testRejectsUntypedAbi(): void
{
$function = new FunctionDef('dynamicValue', Type::VAR, '');
$function->wasmExport = true;
$function->returnTypeUndeclared = true;
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('must declare a return type');
(new WasmInterfaceGenerator())->buildManifest(
[$function],
'acme:demo@1.0.0',
'demo',
static fn (): string => 'php_dynamicvalue',
);
}
}

@ -8,7 +8,7 @@ final class CompileTimeAttributeRegistryTest extends TestCase
public function testEveryBuiltInCompileTimeAttributeHasCompleteMetadata(): void
{
$expected = [
'MethodsFor', 'NoExport', 'Getter', 'Setter', 'With', 'Printer', 'Arrayable',
'MethodsFor', 'NoExport', 'WasmExport', 'Getter', 'Setter', 'With', 'Printer', 'Arrayable',
'NotNull', 'NotEmpty', 'Validate', 'Override', 'MustUse', 'Hot', 'Cold', 'Constructor',
];
$this->assertSame($expected, CompileTimeAttributeRegistry::names());
@ -21,6 +21,7 @@ final class CompileTimeAttributeRegistryTest extends TestCase
$this->assertFalse($definition['repeatable']);
}
$this->assertNotContains('NoExport', CompileTimeAttributeRegistry::names(true));
$this->assertNotContains('WasmExport', CompileTimeAttributeRegistry::names(true));
$this->assertContains('Getter', CompileTimeAttributeRegistry::names(true));
$this->assertContains('Override', CompileTimeAttributeRegistry::names(true));
$this->assertSame(

@ -99,6 +99,19 @@ class CompilerBaseApiTest extends TestCase
return $projectFile;
}
public function testWasiTargetDetection(): void
{
foreach (['wasm32-wasip2', 'wasm32-unknown-wasip2'] as $target) {
$this->setPropertyValue('targetPlatform', $target);
$this->assertTrue($this->compiler->isWasiTarget(), $target);
}
foreach (['', 'wasm32-wasi', 'wasm32-wasip1', 'wasm32-wasip1-threads', 'wasm32-unknown-unknown', 'aarch64-linux-gnu'] as $target) {
$this->setPropertyValue('targetPlatform', $target);
$this->assertFalse($this->compiler->isWasiTarget(), $target);
}
}
private function createFakeClangFormat(string $binDir, string $logFile): void
{
mkdir($binDir, 0777, true);

@ -8,6 +8,19 @@ use TypePhp\Exception\TestError;
class FiberGeneratorTest extends TestCase
{
public function testWasiTargetRejectsGeneratorDuringPreparation(): void
{
$compiler = CompilerTest::create(ROOT_PATH);
$reflection = new \ReflectionClass($compiler);
$reflection->getProperty('targetPlatform')->setValue($compiler, 'wasm32-wasip2');
$file = __DIR__ . '/../../code/generator-conversion-error.php';
$compiler->addFiles([$file]);
$this->expectException(TestError::class);
$this->expectExceptionMessage('Fiber and Generator are not supported by the WASI target');
$compiler->prepareFile($file);
}
public function testCompilerStateIsRestoredAfterGeneratorConversionError(): void
{
$compiler = CompilerTest::create(ROOT_PATH);

@ -6,9 +6,22 @@ use PHPUnit\Framework\TestCase;
use TypePhp\Platform\Windows;
use TypePhp\Platform\Linux;
use TypePhp\Platform\Macos;
use TypePhp\Platform\Wasi;
class PlatformTest extends TestCase
{
public function testWasiTargetProperties(): void
{
$platform = new Wasi('wasm32-unknown-wasip2');
$this->assertSame('WASI SDK (wasm32-unknown-wasip2)', $platform->getName());
$this->assertSame('.o', $platform->getObjectExtension());
$this->assertSame('.wasm', $platform->getExecutableExtension());
$this->assertSame('.a', $platform->getSharedLibraryExtension());
$this->assertSame('LL', $platform->getIntegerLiteralSuffix());
$this->assertSame([], $platform->getBuildLibraryWarnings('', '', 'bin'));
}
/**
* 测试 Windows 平台基本功能
*/

@ -0,0 +1,54 @@
<?php
namespace TypePhp\Tests;
use PHPUnit\Framework\TestCase;
use TypePhp\CompilerTest;
use TypePhp\Exception\TestError;
class WasiUnsupportedSyntaxTest extends TestCase
{
/** @dataProvider unsupportedConversionSyntaxProvider */
public function testUnsupportedSyntaxFailsDuringWasiConversion(string $file, string $message): void
{
$compiler = CompilerTest::create(ROOT_PATH);
(new \ReflectionClass($compiler))->getProperty('targetPlatform')->setValue($compiler, 'wasm32-wasip2');
$source = __DIR__ . '/../code/' . $file;
$compiler->addFiles([$source]);
$compiler->prepareFile($source);
$this->expectException(TestError::class);
$this->expectExceptionMessage($message);
$compiler->convertFile($source);
}
public static function unsupportedConversionSyntaxProvider(): array
{
return [
'backtick shell execution' => [
'wasi-backtick.php',
'Backtick shell execution is not supported by the WASI target',
],
'generator closure' => [
'wasi-generator-closure.php',
'Fiber and Generator are not supported by the WASI target',
],
'generator arrow function' => [
'wasi-generator-arrow.php',
'Fiber and Generator are not supported by the WASI target',
],
'process function' => [
'wasi-process.php',
'Function `proc_open` is not supported by the WASI target',
],
'socket function' => [
'wasi-socket.php',
'Function `stream_socket_server` is not supported by the WASI target',
],
'signal function' => [
'wasi-signal.php',
'Function `pcntl_signal` is not supported by the WASI target',
],
];
}
}

@ -0,0 +1,21 @@
<?php
final class WasmExportAttributeTest extends BaseTest
{
public function testAcceptsNamedFunctionWithConstantExportName(): void
{
$this->compile('wasm-export-valid.php');
}
public function testRejectsMethods(): void
{
$this->expectException(\TypePhp\Exception\SyntaxError::class);
$this->expectExceptionMessage('WasmExport can only be applied to named functions');
$this->compile('wasm-export-invalid-method.php');
}
public function testRejectsNonStringName(): void
{
$this->exec('WasmExport name must be a constant string', 'wasm-export-invalid-name.php');
}
}

@ -0,0 +1,48 @@
<?php
namespace TypePhp\Build;
use Composer\InstalledVersions;
use RuntimeException;
final class PhpxLocator
{
public static function resolve(string $rootPath): string
{
$phpxHome = getenv('PHPX_HOME');
if (is_string($phpxHome) && $phpxHome !== '') {
$resolved = self::existingDirectory($phpxHome);
if ($resolved !== null) {
return $resolved;
}
}
if (class_exists(InstalledVersions::class) && InstalledVersions::isInstalled('swoole/phpx')) {
$installPath = InstalledVersions::getInstallPath('swoole/phpx');
if (is_string($installPath)) {
$resolved = self::existingDirectory($installPath);
if ($resolved !== null) {
return $resolved;
}
}
}
$resolved = self::existingDirectory(rtrim($rootPath, '/\\') . '/vendor/swoole/phpx');
if ($resolved !== null) {
return $resolved;
}
throw new RuntimeException(
"phpx directory not found. Set PHPX_HOME or install swoole/phpx with Composer.",
);
}
private static function existingDirectory(string $path): ?string
{
$path = rtrim($path, '/\\');
if (!is_dir($path)) {
return null;
}
return realpath($path) ?: $path;
}
}

@ -14,6 +14,7 @@ use TypePhp\Exception\Unsupported;
use TypePhp\Installer\LibPhpInstaller;
use TypePhp\Installer\LibPhpxInstaller;
use TypePhp\Platform\Linux;
use TypePhp\Platform\Wasi;
use TypePhp\Platform\Windows;
trait SourcePipelineTrait
@ -98,7 +99,7 @@ trait SourcePipelineTrait
// 仅在 PHP 脚本入口(bin/tpc.php)前置检测 phpx 库:缺少库立即 fatal,
// 避免继续向下执行到文件处理/编译阶段才报错。已编译的 tpc 可执行文件
// 在进入 main() 前就由动态链接器加载 libphpx,无需(也无法)在此检测。
if (defined('TYPEPHP_PHP_SCRIPT_ENTRY')) {
if (defined('TYPEPHP_PHP_SCRIPT_ENTRY') && !($this->getPlatform() instanceof Wasi)) {
$this->validatePhpxLibrary();
}
@ -227,7 +228,9 @@ trait SourcePipelineTrait
$this->stop('No valid source file found');
}
if ($this->isBuildModeLib()) {
// A WASI library publishes WIT/Component exports rather than a native
// TypePHP shared-library ABI, so a PHP import stub would be misleading.
if ($this->isBuildModeLib() && !$this->isWasiTarget()) {
$this->genLibraryImportStub($files);
}

@ -0,0 +1,158 @@
<?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 string $mode,
public string $package,
public string $world,
) {
}
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 ?? 'component'),
'command',
'typephp:app@1.0.0',
'app',
);
}
$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['wasm-mode'] ?? $config['mode'] ?? $config['build-mode'] ?? $config['type'] ?? 'command'));
$mode = match ($mode) {
'bin', 'binary', 'cli' => 'command',
'lib', 'library', 'reactor' => 'library',
default => $mode,
};
if (!in_array($mode, ['command', 'library'], true)) {
throw new RuntimeException('A WASI project mode must be `command` or `library`');
}
$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');
}
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 {
$output = $projectDir . DIRECTORY_SEPARATOR . $name . '.wasm';
}
$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;
$browserDir = $profile === 'browser' && !empty($browserPath)
? self::absolutePath((string) $browserPath, $projectDir)
: null;
$package = strtolower(trim((string) ($config['wasm-package'] ?? 'typephp:' . $name . '@1.0.0')));
if (preg_match('/^[a-z][a-z0-9-]*:[a-z][a-z0-9-]*@[0-9]+\.[0-9]+\.[0-9]+$/', $package) !== 1) {
throw new RuntimeException('`wasm-package` must use the WIT form namespace:name@major.minor.patch');
}
$world = strtolower(trim((string) ($config['wasm-world'] ?? $name)));
$world = str_replace('_', '-', $world);
if (preg_match('/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/', $world) !== 1) {
throw new RuntimeException('`wasm-world` must be a lowercase WIT identifier');
}
return new self($realInput, $buildDir, $output, $browserDir, $profile, $mode, $package, $world);
}
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;
}
return true;
}
private static function normalizeProfile(string $profile): string
{
$profile = strtolower(trim($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;
}
}

@ -0,0 +1,119 @@
<?php
namespace TypePhp\Build;
use RuntimeException;
final class WasiToolchain
{
public const MIN_LLVM_MAJOR = 22;
public const MIN_WASMTIME_MAJOR = 47;
public const MIN_JCO_MAJOR = 1;
/** @return array<string, string> */
public function detect(bool $requireBrowserTools = true): array
{
$tools = [];
$requiredTools = [
'wasm32-wasip2-clang',
'wasm32-wasip2-clang++',
'llvm-ar',
'llvm-ranlib',
'llvm-nm',
'wasm-component-ld',
'wasmtime',
];
if ($requireBrowserTools) {
$requiredTools[] = 'jco';
}
foreach ($requiredTools as $name) {
$tools[$name] = $this->findExecutable($name);
}
$versions = [];
foreach (['wasm32-wasip2-clang', 'wasm32-wasip2-clang++', 'llvm-ar', 'llvm-ranlib', 'llvm-nm'] as $name) {
$versions[$name] = $this->requireVersion($name, $tools[$name], self::MIN_LLVM_MAJOR);
}
$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);
if ($exitCode !== 0 || $target !== 'wasm32-unknown-wasip2') {
$detail = trim($error) !== '' ? ': ' . trim($error) : '';
throw new RuntimeException(
"wasm32-wasip2-clang++ from PATH is not configured for wasm32-unknown-wasip2 (reported target: "
. ($target !== '' ? $target : 'unknown') . "){$detail}",
);
}
$tools['clang'] = $tools['wasm32-wasip2-clang'];
$tools['clang++'] = $tools['wasm32-wasip2-clang++'];
$tools['wasm-ld'] = $tools['wasm-component-ld'];
$tools['target'] = $target;
$tools['clang-version'] = $versions['wasm32-wasip2-clang++'];
$tools['wasmtime-version'] = $versions['wasmtime'];
if ($requireBrowserTools) {
$tools['jco-version'] = $versions['jco'];
}
return $tools;
}
private function findExecutable(string $name): string
{
$path = getenv('PATH');
foreach (explode(PATH_SEPARATOR, is_string($path) ? $path : '') as $directory) {
if ($directory === '') {
continue;
}
$candidate = rtrim($directory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $name;
if (is_file($candidate) && is_executable($candidate)) {
// Preserve the PATH entry instead of resolving symlinks. LLVM
// multicall binaries select their driver mode and adjacent
// .cfg file from argv[0] (notably clang++ and wasm-ld).
return $candidate;
}
}
throw new RuntimeException("Required WASI tool `{$name}` was not found in PATH");
}
private function requireVersion(string $name, string $executable, int $minimumMajor): string
{
[$exitCode, $output, $error] = $this->run([$executable, '--version']);
$versionText = trim($output . "\n" . $error);
if ($exitCode !== 0 || preg_match('/\bv?((\d+)(?:\.\d+)+)\b/i', $versionText, $match) !== 1) {
throw new RuntimeException("Unable to determine the version of WASI tool `{$name}` from PATH");
}
$major = (int) $match[2];
if ($major < $minimumMajor) {
throw new RuntimeException(
"WASI tool `{$name}` {$major} is too old; version {$minimumMajor} or newer is required",
);
}
return $match[1];
}
/** @return array{int, string, string} */
private function run(array $command): array
{
$process = proc_open(
$command,
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes,
);
if (!is_resource($process)) {
return [127, '', 'failed to start process'];
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return [proc_close($process), $stdout, $stderr];
}
}

@ -0,0 +1,470 @@
<?php
namespace TypePhp\Build;
use RuntimeException;
use TypePhp\Entity\ArgInfo;
use TypePhp\Entity\FunctionDef;
use TypePhp\Type;
/** Generates the stable, tool-neutral contract consumed by PHPX's host bindgen. */
final class WasmInterfaceGenerator
{
/**
* @param iterable<FunctionDef> $functions
* @return array{package:string, world:string, interface:string, functions:list<array<string, mixed>>}
*/
public function buildManifest(
iterable $functions,
string $package,
string $world,
callable $nativeName,
): array {
$exports = [];
$names = [];
foreach ($functions as $function) {
if (!$function->wasmExport) {
continue;
}
$displayName = $function->displayName ?: $function->getNamespacedName();
if ($function->method || $function->stub) {
throw new RuntimeException("WasmExport function {$displayName}() must have a TypePHP function body");
}
if ($function->generator || $function->returnsByRef || $function->hasVariadicArg()) {
throw new RuntimeException("WasmExport function {$displayName}() cannot be a generator, return by reference, or be variadic");
}
if ($function->returnTypeUndeclared) {
throw new RuntimeException("WasmExport function {$displayName}() must declare a return type");
}
$exportName = $function->wasmExportName !== ''
? $function->wasmExportName
: $this->toWitName($function->name);
$this->assertWitIdentifier($exportName, "WasmExport name for {$displayName}()");
$key = strtolower($exportName);
if (isset($names[$key])) {
throw new RuntimeException(
"WasmExport name collision: {$names[$key]}() and {$displayName}() both export `{$exportName}`"
);
}
$names[$key] = $displayName;
$parameters = [];
foreach ($function->argInfoList as $argument) {
if ($argument->byRef || $argument->variadic || $argument->default !== '') {
throw new RuntimeException(
"WasmExport parameter \${$argument->phpName} of {$displayName}() cannot be by-reference, variadic, or optional"
);
}
$parameters[] = [
'name' => $this->toWitName($argument->phpName),
'php-name' => $argument->phpName,
'cpp-name' => $argument->name,
'cpp-type' => $argument->type,
'wit-type' => $this->argumentType($argument, $displayName),
];
}
$exports[] = [
'name' => $exportName,
'php-name' => $function->getNamespacedName(),
'cpp-symbol' => $nativeName($function),
'parameters' => $parameters,
'result' => $this->resultType($function, $displayName),
'result-cpp-type' => $function->returnType,
];
}
if ($exports === []) {
throw new RuntimeException('WASI library mode requires at least one #[WasmExport] function');
}
return [
'schema' => 1,
'package' => $package,
'world' => $world,
'interface' => 'api',
'runtime' => [
'threading' => 'nts',
'lifecycle' => 'wit-resource',
'init-symbol' => 'typephp_runtime_init',
'shutdown-symbol' => 'typephp_runtime_shutdown',
'error-model' => 'result',
],
'functions' => $exports,
];
}
/** @param array{package:string, world:string, interface:string, functions:list<array<string, mixed>>} $manifest */
public function renderWit(array $manifest): string
{
$lines = [
'package ' . $manifest['package'] . ';',
'',
'interface ' . $manifest['interface'] . ' {',
' record typephp-error {',
' class: string,',
' message: string,',
' code: s64,',
' }',
'',
' resource runtime {',
];
foreach ($manifest['functions'] as $function) {
$parameters = [];
foreach ($function['parameters'] as $parameter) {
$parameters[] = $parameter['name'] . ': ' . $parameter['wit-type'];
}
$success = $function['result'] === null ? '_' : $function['result'];
$lines[] = ' ' . $function['name'] . ': func(' . implode(', ', $parameters)
. ') -> result<' . $success . ', typephp-error>;';
}
$lines[] = ' }';
$lines[] = '';
$lines[] = ' create-runtime: func() -> result<runtime, typephp-error>;';
$lines[] = '}';
$lines[] = '';
$lines[] = 'world ' . $manifest['world'] . ' {';
$lines[] = ' export ' . $manifest['interface'] . ';';
$lines[] = '}';
return implode(PHP_EOL, $lines) . PHP_EOL;
}
/**
* Every TypePHP browser export may transitively call an asynchronous WASI
* import. Jco must wrap these entry points with WebAssembly.promising or a
* synchronous-looking PHP call such as RINIT, file I/O, or HTTP cannot
* suspend through JSPI.
*
* @param array<string, mixed> $manifest
*/
public function renderJcoAsyncExports(array $manifest): string
{
[$package, $version] = explode('@', $manifest['package'], 2);
$interface = $package . '/' . $manifest['interface'] . '@' . $version;
$exports = [$interface . '#create-runtime'];
foreach ($manifest['functions'] as $function) {
$exports[] = $interface . '#[method]runtime.' . $function['name'];
}
return implode(PHP_EOL, $exports) . PHP_EOL;
}
/**
* Render the small TypePHP-specific half of the C binding. The generic
* Canonical ABI half and component type object are emitted by the pinned
* wit-bindgen binary shipped with PHPX.
*
* @param array<string, mixed> $manifest
*/
public function renderCppAdapter(array $manifest): string
{
[$packageNamespace, $packageTail] = explode(':', explode('@', $manifest['package'], 2)[0], 2);
$packageName = $packageTail;
$world = $this->cName($manifest['world']);
$prefix = 'exports_' . $this->cName($packageNamespace) . '_' . $this->cName($packageName)
. '_' . $this->cName($manifest['interface']);
$errorType = $prefix . '_typephp_error_t';
$lines = [
'#include <cstdlib>',
'#include <cstring>',
'#include <exception>',
'#include <new>',
'#include <phpx.h>',
'#include <typephp_helper.h>',
'#include "' . $this->cName($manifest['world']) . '.h"',
'',
'extern "C" int typephp_runtime_init(int argc, char **argv);',
'extern "C" void typephp_runtime_shutdown();',
'',
'struct ' . $prefix . '_runtime_t {',
' bool call_active = false;',
'};',
'',
'namespace {',
'bool runtime_started = false;',
'bool runtime_failed = false;',
'',
'void set_error(' . $errorType . ' *error, const char *class_name, size_t class_len,',
' const char *message, size_t message_len, int64_t code = 0) {',
' ' . $world . '_string_dup_n(&error->class_, class_name, class_len);',
' ' . $world . '_string_dup_n(&error->message, message, message_len);',
' error->code = code;',
'}',
'',
'void set_error(' . $errorType . ' *error, const char *message) {',
' set_error(error, "TypePHP\\\\WasmError", sizeof("TypePHP\\\\WasmError") - 1,',
' message, std::strlen(message));',
'}',
'',
'struct CallGuard {',
' bool &active;',
' explicit CallGuard(bool &value) : active(value) { active = true; }',
' ~CallGuard() { active = false; }',
'};',
'',
'void set_exception(' . $errorType . ' *error, zend_object *exception) {',
' zend_class_entry *base = instanceof_function(exception->ce, zend_ce_exception)',
' ? zend_ce_exception : zend_ce_error;',
' zval message_rv;',
' zval code_rv;',
' zval *message = zend_read_property_ex(base, exception, ZSTR_KNOWN(ZEND_STR_MESSAGE), true, &message_rv);',
' zval *code = zend_read_property_ex(base, exception, ZSTR_KNOWN(ZEND_STR_CODE), true, &code_rv);',
' zend_string *class_name = exception->ce->name;',
' const char *message_data = Z_TYPE_P(message) == IS_STRING ? Z_STRVAL_P(message) : "PHP exception";',
' size_t message_len = Z_TYPE_P(message) == IS_STRING ? Z_STRLEN_P(message) : sizeof("PHP exception") - 1;',
' int64_t exception_code = Z_TYPE_P(code) == IS_LONG ? Z_LVAL_P(code) : 0;',
' set_error(error, ZSTR_VAL(class_name), ZSTR_LEN(class_name), message_data, message_len, exception_code);',
' zend_clear_exception();',
'}',
'} // namespace',
'',
'extern "C" bool ' . $prefix . '_create_runtime(',
' ' . $prefix . '_own_runtime_t *ret, ' . $errorType . ' *error) {',
' if (runtime_started) {',
' set_error(error, "Only one TypePHP runtime may be active in an NTS component instance");',
' return false;',
' }',
' if (runtime_failed) {',
' set_error(error, "The TypePHP runtime is unavailable after an earlier fatal error");',
' return false;',
' }',
' char program[] = "typephp-component";',
' char *argv[] = {program, nullptr};',
' if (typephp_runtime_init(1, argv) != 0) {',
' runtime_failed = true;',
' set_error(error, "Unable to initialize the TypePHP runtime");',
' return false;',
' }',
' auto *runtime = new (std::nothrow) ' . $prefix . '_runtime_t();',
' if (runtime == nullptr) {',
' typephp_runtime_shutdown();',
' set_error(error, "Unable to allocate the TypePHP runtime resource");',
' return false;',
' }',
' runtime_started = true;',
' *ret = ' . $prefix . '_runtime_new(runtime);',
' return true;',
'}',
'',
'extern "C" void ' . $prefix . '_runtime_destructor(' . $prefix . '_runtime_t *runtime) {',
' delete runtime;',
' if (runtime_started) {',
' typephp_runtime_shutdown();',
' }',
' runtime_started = false;',
' runtime_failed = false;',
'}',
'',
];
foreach ($manifest['functions'] as $function) {
$returnType = $function['result'];
$declaration = [$prefix . '_borrow_runtime_t self'];
$callArguments = [];
foreach ($function['parameters'] as $parameter) {
[$base, $nullable] = $this->splitWitType($parameter['wit-type']);
$cType = $this->cAbiType($base, $world);
$cName = $this->cName($parameter['name']);
if ($base === 'string' || $nullable) {
$declaration[] = $cType . ' *' . ($nullable ? 'maybe_' : '') . $cName;
} else {
$declaration[] = $cType . ' ' . $cName;
}
$callArguments[] = $this->cppArgument($parameter, $cName, $base, $nullable);
}
if ($returnType !== null) {
[$returnBase, $returnNullable] = $this->splitWitType($returnType);
$declaration[] = ($returnNullable
? $world . '_option_' . $this->cName($returnBase) . '_t'
: $this->cAbiType($returnBase, $world)) . ' *ret';
} else {
$returnBase = '';
$returnNullable = false;
}
$declaration[] = $errorType . ' *error';
$lines[] = 'extern ' . $function['result-cpp-type'] . ' ' . $function['cpp-symbol']
. '(' . implode(', ', array_map(
static fn (array $parameter): string => $parameter['cpp-type'] . ' ' . $parameter['cpp-name'],
$function['parameters'],
)) . ');';
$lines[] = '';
$lines[] = 'extern "C" bool ' . $prefix . '_method_runtime_' . $this->cName($function['name'])
. '(' . implode(', ', $declaration) . ') {';
$lines[] = ' if (self == nullptr || !runtime_started || runtime_failed) {';
$lines[] = ' set_error(error, "The TypePHP runtime resource is closed or unavailable");';
$lines[] = ' return false;';
$lines[] = ' }';
$lines[] = ' if (self->call_active) {';
$lines[] = ' set_error(error, "Concurrent or reentrant calls on one NTS TypePHP component are not supported");';
$lines[] = ' return false;';
$lines[] = ' }';
$lines[] = ' CallGuard call_guard(self->call_active);';
$lines[] = ' bool success = false;';
$lines[] = ' zend_try {';
$lines[] = ' try {';
$call = $function['cpp-symbol'] . '(' . implode(', ', $callArguments) . ')';
if ($returnType === null) {
$lines[] = ' ' . $call . ';';
} else {
$lines[] = ' auto result = ' . $call . ';';
foreach ($this->cppResult($returnBase, $returnNullable, $world) as $resultLine) {
$lines[] = ' ' . $resultLine;
}
}
$lines[] = ' success = true;';
$lines[] = ' } catch (zend_object *exception) {';
$lines[] = ' set_exception(error, exception);';
$lines[] = ' } catch (const std::exception &exception) {';
$lines[] = ' set_error(error, exception.what());';
$lines[] = ' } catch (...) {';
$lines[] = ' set_error(error, "Unknown C++ exception");';
$lines[] = ' }';
$lines[] = ' } zend_catch {';
$lines[] = ' runtime_failed = true;';
$lines[] = ' set_error(error, "Zend bailout while executing the exported function");';
$lines[] = ' } zend_end_try();';
$lines[] = ' return success;';
$lines[] = '}';
$lines[] = '';
}
return implode(PHP_EOL, $lines) . PHP_EOL;
}
/** @return array{string, bool} */
private function splitWitType(string $type): array
{
if (str_starts_with($type, 'option<') && str_ends_with($type, '>')) {
return [substr($type, 7, -1), true];
}
return [$type, false];
}
private function cAbiType(string $type, string $world): string
{
return match ($type) {
'bool' => 'bool',
's64' => 'int64_t',
'f64' => 'double',
'string' => $world . '_string_t',
default => throw new RuntimeException("Unsupported WIT C ABI type `{$type}`"),
};
}
/** @param array<string, mixed> $parameter */
private function cppArgument(array $parameter, string $name, string $base, bool $nullable): string
{
if (!$nullable) {
return match ($base) {
'string' => 'php::Str(reinterpret_cast<const char *>(' . $name . '->ptr), ' . $name . '->len)',
default => $name,
};
}
$pointer = 'maybe_' . $name;
$value = match ($base) {
'string' => 'php::Str(reinterpret_cast<const char *>(' . $pointer . '->ptr), ' . $pointer . '->len)',
default => '*' . $pointer,
};
return '(' . $pointer . ' == nullptr ? php::Var(php::null) : php::Var(' . $value . '))';
}
/** @return list<string> */
private function cppResult(string $base, bool $nullable, string $world): array
{
if ($nullable) {
$lines = [
'ret->is_some = !result.isNull();',
'if (ret->is_some) {',
];
if ($base === 'string') {
$lines[] = ' php::Str string_result = php::toString(result);';
$lines[] = ' ' . $world . '_string_dup_n(&ret->val, string_result.data(), string_result.length());';
$lines[] = '}';
return $lines;
}
$assignment = match ($base) {
'bool' => 'ret->val = php::toBool(result);',
's64' => 'ret->val = php::toInt(result);',
'f64' => 'ret->val = php::toFloat(result);',
default => throw new RuntimeException("Unsupported nullable WIT result `{$base}`"),
};
$lines[] = ' ' . $assignment;
$lines[] = '}';
return $lines;
}
return [match ($base) {
'bool' => '*ret = php::toBool(result);',
's64' => '*ret = php::toInt(result);',
'f64' => '*ret = php::toFloat(result);',
'string' => $world . '_string_dup_n(ret, result.data(), result.length());',
default => throw new RuntimeException("Unsupported WIT result `{$base}`"),
}];
}
private function cName(string $name): string
{
return strtolower(str_replace('-', '_', $name));
}
private function argumentType(ArgInfo $argument, string $function): string
{
$type = $this->scalarType(
$argument->type,
"parameter \${$argument->phpName} of {$function}()",
$argument->typeStr,
);
return $argument->nullable ? "option<{$type}>" : $type;
}
private function resultType(FunctionDef $function, string $displayName): ?string
{
if ($function->returnType === Type::VOID) {
return null;
}
$nullable = str_starts_with($function->returnTypeStr, '?')
|| str_contains(strtolower($function->returnTypeStr), 'null');
$type = $this->scalarType($function->returnType, "return type of {$displayName}()", $function->returnTypeStr);
return $nullable ? "option<{$type}>" : $type;
}
private function scalarType(string $type, string $location, string $declaredType = ''): string
{
$mapped = match ($type) {
Type::BOOL => 'bool',
Type::INT => 's64',
Type::FLOAT => 'f64',
Type::STR => 'string',
default => null,
};
if ($mapped !== null) {
return $mapped;
}
$normalized = strtolower(str_replace(['?', '|null', 'null|'], '', $declaredType));
$mapped = match ($normalized) {
'bool' => 'bool',
'int' => 's64',
'float' => 'f64',
'string' => 'string',
default => null,
};
if ($mapped !== null) {
return $mapped;
}
throw new RuntimeException(
"Unsupported WasmExport {$location}; the first release supports bool, int, float, string, nullable scalars, and void"
);
}
private function toWitName(string $name): string
{
$name = preg_replace('/(?<=[a-z0-9])(?=[A-Z])/', '-', $name) ?? $name;
return strtolower(str_replace('_', '-', $name));
}
private function assertWitIdentifier(string $name, string $location): void
{
if (preg_match('/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/', $name) !== 1) {
throw new RuntimeException("{$location} must be a lowercase WIT identifier (for example `greet-user`)");
}
}
}

@ -8,6 +8,8 @@
namespace TypePhp;
use TypePhp\Build\PhpxLocator;
use League\CLImate\CLImate;
use TypePhp\Backend\CompilerBackend;
use TypePhp\Backend\CompilerFactory;
@ -174,6 +176,41 @@ class CompilerBase implements PropertyAccessContext
'stream_socket_accept',
'popen',
];
/**
* APIs which cannot have the same semantics in Wasmtime and a browser.
* Keep this list at the language boundary so a WASI build never degrades
* into a link error or a browser-only implementation.
*/
private const array WASI_UNSUPPORTED_FUNCTIONS = [
'exec',
'passthru',
'popen',
'proc_close',
'proc_get_status',
'proc_nice',
'proc_open',
'proc_terminate',
'shell_exec',
'system',
'fsockopen',
'pfsockopen',
'stream_socket_accept',
'stream_socket_client',
'stream_socket_enable_crypto',
'stream_socket_get_name',
'stream_socket_pair',
'stream_socket_recvfrom',
'stream_socket_sendto',
'stream_socket_server',
'stream_socket_shutdown',
];
private const array WASI_UNSUPPORTED_FUNCTION_PREFIXES = [
'pcntl_',
'posix_',
'socket_',
];
public const int DECL_TYPE_OF_RETURN = 1;
public const int DECL_TYPE_OF_PROPERTY = 2;
public const int DECL_TYPE_OF_CONST = 3;
@ -492,59 +529,11 @@ class CompilerBase implements PropertyAccessContext
protected function getPhpxDir(): string
{
// 解析 phpx 目录,优先级(macOS / Windows / Linux 通用):
// 1. PHPX_HOME 环境变量指定的目录
// 2. vendor 目录(Composer 安装路径,或 TypePHP 源码检出下的 vendor/swoole/phpx)
$phpxDir = getenv('PHPX_HOME');
if ($phpxDir !== false && $phpxDir !== '') {
$phpxDir = rtrim($phpxDir, '\/');
if (is_dir($phpxDir)) {
return $this->normalizePhpxDir($phpxDir);
}
}
// Composer-installed TypePHP and PHPX are sibling packages. Ask
// Composer for the actual package path instead of assuming a nested
// vendor directory inside the TypePHP package.
if (class_exists(\Composer\InstalledVersions::class)
&& \Composer\InstalledVersions::isInstalled('swoole/phpx')) {
$composerInstallPath = \Composer\InstalledVersions::getInstallPath('swoole/phpx');
if (is_string($composerInstallPath)) {
$composerInstallPath = rtrim($composerInstallPath, '\/');
if (is_dir($composerInstallPath)) {
return $this->normalizePhpxDir($composerInstallPath);
}
}
}
// TypePHP source checkout: phpx is installed below this repository.
$composerPhpxDir = $this->rootPath . self::PHPX_VENDOR_DIR;
if (is_dir($composerPhpxDir)) {
return $this->normalizePhpxDir($composerPhpxDir);
}
if (defined('ROOT_PATH')) {
$rootPhpxDir = ROOT_PATH . self::PHPX_VENDOR_DIR;
if (is_dir($rootPhpxDir)) {
return $this->normalizePhpxDir($rootPhpxDir);
}
try {
return PhpxLocator::resolve($this->rootPath);
} catch (\RuntimeException $exception) {
$this->error($exception->getMessage());
}
// 两个路径都不存在,报错
$this->error(
'phpx directory not found. Please either:\n' .
'1. Set PHPX_HOME environment variable to your phpx installation path\n' .
'2. Install phpx via Composer: composer require swoole/phpx'
);
}
/**
* 规范化 phpx 目录路径(解析相对路径与 ..,便于错误提示和后续路径拼接)
*/
private function normalizePhpxDir(string $dir): string
{
$real = realpath($dir);
return $real !== false ? $real : $dir;
}
protected function getPlatform(): PlatformBase
@ -581,6 +570,29 @@ class CompilerBase implements PropertyAccessContext
return $this->getPlatform() instanceof Macos;
}
public function isWasiTarget(): bool
{
$target = strtolower($this->targetPlatform);
return $target === 'wasm32-unknown-wasip2' || $target === 'wasm32-wasip2';
}
protected function assertWasiFunctionSupported(NodeAbstract $expr, string $name): void
{
if (!$this->isWasiTarget()) {
return;
}
$name = strtolower(ltrim($name, '\\'));
if (in_array($name, self::WASI_UNSUPPORTED_FUNCTIONS, true)) {
$this->fatalError($expr, "Function `{$name}` is not supported by the WASI target");
}
foreach (self::WASI_UNSUPPORTED_FUNCTION_PREFIXES as $prefix) {
if (str_starts_with($name, $prefix)) {
$this->fatalError($expr, "Function `{$name}` is not supported by the WASI target");
}
}
}
public function isBuildModeBin(): bool
{
return $this->buildMode === self::BUILD_MODE_BIN;
@ -3804,6 +3816,9 @@ class CompilerBase implements PropertyAccessContext
protected function parseShellExec(Expr\ShellExec $expr): string
{
if ($this->isWasiTarget()) {
$this->fatalError($expr, 'Backtick shell execution is not supported by the WASI target');
}
$list = [];
foreach ($expr->parts as $part) {
$list[] = $this->identifierToStr($part);

@ -46,6 +46,10 @@ class FunctionDef
public bool $hot = false;
/** Prefer optimizing this function for rarely executed paths. */
public bool $cold = false;
/** Whether this function is part of the public WIT component interface. */
public bool $wasmExport = false;
/** Explicit WIT export name, or an empty string when it is derived from the PHP name. */
public string $wasmExportName = '';
/** Number of fixed positional values returned through the internal tuple fast path. */
public int $multiReturnCount = 0;
/** Source file containing this function definition. */

@ -247,6 +247,9 @@ trait ClosureGenerator
protected function validateGeneratorClosure(Expr\ArrowFunction|Expr\Closure $expr, array $params): void
{
if ($this->isWasiTarget()) {
$this->fatalError($expr, 'Fiber and Generator are not supported by the WASI target');
}
if ($expr->byRef) {
$this->fatalError($expr, 'Generator closures returning by reference are not supported yet');
}

@ -61,6 +61,9 @@ trait FiberGenerator
protected function prepareGeneratorFunction(Function_|ClassMethod $v, FunctionDef $functionDef): void
{
if ($this->isWasiTarget()) {
$this->fatalError($v, 'Fiber and Generator are not supported by the WASI target');
}
if ($v->byRef) {
$this->fatalError($v, 'Generators returning by reference are not supported yet');
}

@ -78,6 +78,9 @@ trait FunctionCallTrait
} elseif ($expr->name->getType() === 'Name' or $expr->name->getType() === 'Name_FullyQualified') {
$name = $this->parseIdentifier($expr->name);
$globalName = ltrim($name, '\\');
if ($this->isInternalFunction($globalName)) {
$this->assertWasiFunctionSupported($expr, $globalName);
}
if (in_array($name, Constants::UNSUPPORTED_FUNCTIONS)) {
$this->fatalError($expr, 'Unsupported function: `' . $name . '`');
}

@ -0,0 +1,50 @@
<?php
namespace TypePhp\Platform;
final class Wasi extends UnixPlatform
{
public function __construct(private readonly string $target = 'wasm32-unknown-wasip2')
{
}
public function getName(): string
{
return "WASI SDK ({$this->target})";
}
public function isCurrent(): bool
{
return false;
}
public function getSharedLibraryExtension(): string
{
return '.a';
}
public function getExecutableExtension(): string
{
return '.wasm';
}
public function getDefaultCompiler(): string
{
$compiler = getenv('TYPEPHP_WASI_CXX');
return is_string($compiler) && $compiler !== '' ? $compiler : 'clang++';
}
public function getBuildLibraryWarnings(
string $phpDir,
string $phpxDir,
string $buildMode,
bool $checkPhpxRuntime = true,
): array {
return [];
}
public function getIntegerLiteralSuffix(): string
{
return 'LL';
}
}

@ -231,6 +231,65 @@ class Preprocessor extends CompilerBase
return false;
}
private function parseWasmExportAttribute(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): ?string
{
foreach ($node->attrGroups as $group) {
foreach ($group->attrs as $attribute) {
if (!$this->isRootCompileTimeAttribute($attribute, 'WasmExport')) {
continue;
}
if ($node instanceof Node\Stmt\ClassMethod) {
$this->fatalCompileTimeAttribute(
$node,
'WasmExport',
'WasmExport can only be applied to named functions',
$attribute,
);
}
if (count($attribute->args) > 1) {
$this->fatalCompileTimeAttribute(
$node,
'WasmExport',
'WasmExport accepts at most one string name',
$attribute,
);
}
if ($attribute->args === []) {
return '';
}
$argument = $attribute->args[0];
if ($argument->name !== null && strcasecmp($argument->name->toString(), 'name') !== 0) {
$this->fatalCompileTimeAttribute(
$node,
'WasmExport',
'WasmExport only accepts the named argument `name`',
$attribute,
);
}
if (!$argument->value instanceof Node\Scalar\String_) {
$this->fatalCompileTimeAttribute(
$node,
'WasmExport',
'WasmExport name must be a constant string',
$attribute,
);
}
$name = $argument->value->value;
if ($name === '') {
$this->fatalCompileTimeAttribute(
$node,
'WasmExport',
'WasmExport name must not be empty',
$attribute,
);
}
return $name;
}
}
return null;
}
private function isRootCompileTimeAttribute(Node\Attribute $attribute, string $name): bool
{
return strcasecmp($this->getResolvedPhpName($attribute->name), $name) === 0;
@ -535,6 +594,9 @@ class Preprocessor extends CompilerBase
$functionDef->overrideRequired = (bool) $v->getAttribute(FunctionAttributeLowering::OVERRIDE_ATTRIBUTE, false);
$functionDef->hot = (bool) $v->getAttribute(FunctionAttributeLowering::HOT_ATTRIBUTE, false);
$functionDef->cold = (bool) $v->getAttribute(FunctionAttributeLowering::COLD_ATTRIBUTE, false);
$wasmExportName = $this->parseWasmExportAttribute($v);
$functionDef->wasmExport = $wasmExportName !== null;
$functionDef->wasmExportName = $wasmExportName ?? '';
if ($functionDef->mustUse && $returnType === Type::VOID) {
$this->fatalCompileTimeAttribute(
$v,

@ -23,6 +23,7 @@ final class CompileTimeAttributeRegistry
public const ARGUMENTS_METHODS_FOR = 'methods_for';
public const ARGUMENTS_FIELDS = 'fields';
public const ARGUMENTS_VALIDATE = 'validate';
public const ARGUMENTS_WASM_EXPORT = 'wasm_export';
public const PHASE_PREPROCESS = 'preprocess';
public const PHASE_ENTER = 'enter';
@ -73,6 +74,7 @@ final class CompileTimeAttributeRegistry
$add('MethodsFor', [self::TARGET_NAMED_CLASS], 'MethodsFor can only be applied to classes', self::ARGUMENTS_METHODS_FOR, self::PHASE_PREPROCESS);
$add('NoExport', [self::TARGET_CLASS_LIKE, self::TARGET_FUNCTION, self::TARGET_METHOD], 'NoExport can only be applied to classes, functions, or methods', self::ARGUMENTS_NONE, self::PHASE_PREPROCESS, false);
$add('WasmExport', [self::TARGET_FUNCTION], 'WasmExport can only be applied to named functions', self::ARGUMENTS_WASM_EXPORT, self::PHASE_PREPROCESS, false);
foreach (['Getter', 'Setter', 'With'] as $name) {
$add($name, [self::TARGET_PROPERTY], $name . ' can only be applied to instance properties', self::ARGUMENTS_NONE, self::PHASE_CLASS_LEAVE);
}

@ -18,6 +18,7 @@ use TypePhp\Build\NativeCommandOptionsTrait;
use TypePhp\Build\NativeBuilder;
use TypePhp\Build\PrecompiledHeaderManager;
use TypePhp\Build\SourcePipelineTrait;
use TypePhp\Build\WasmInterfaceGenerator;
use TypePhp\Config\ProjectYamlLoader;
use TypePhp\Diagnostics\CompileTimeAttributeDiagnostic;
use TypePhp\Build\ResourceCompilationTrait;
@ -37,6 +38,7 @@ use TypePhp\Generator\LibraryImportStubGenerator;
use TypePhp\Generator\Symbol;
use TypePhp\Metadata\Constants;
use TypePhp\Platform\PlatformFactory;
use TypePhp\Platform\Wasi;
use TypePhp\Platform\Windows;
use TypePhp\Resolver\Reflection;
use TypePhp\Resolver\ClassConstantValueTrait;
@ -164,8 +166,23 @@ class Translator extends Preprocessor
protected function detectPlatform(): void
{
try {
$targetPlatform = $this->climate->arguments->defined('target-platform')
? (string) $this->climate->arguments->get('target-platform')
: '';
if ($targetPlatform === 'wasm32-wasip1' || $targetPlatform === 'wasm32-wasi') {
throw new \RuntimeException('WASI Preview 1 is not supported; use wasm32-wasip2');
}
if ($targetPlatform === 'wasm32-wasip2' || $targetPlatform === 'wasm32-unknown-wasip2') {
$detectedTarget = getenv('TYPEPHP_WASI_TARGET');
$this->platform = new Wasi(
is_string($detectedTarget) && $detectedTarget !== '' ? $detectedTarget : $targetPlatform,
);
} else {
$this->platform = PlatformFactory::create();
$this->cppCompiler = CompilerFactory::detectCompilerName($this->platform);
}
$this->cppCompiler = $this->platform instanceof Wasi
? $this->platform->getDefaultCompiler()
: CompilerFactory::detectCompilerName($this->platform);
if ($this->platform instanceof Windows) {
$libInfo = $this->platform->detectPhpLibs($this->getPhpDir());
@ -181,8 +198,15 @@ class Translator extends Preprocessor
}
$this->compilerBackend = CompilerFactory::createByName($this->cppCompiler, $this->platform);
$backendName = $this->compilerBackend->getName();
if ($this->platform instanceof Wasi) {
$clangVersion = getenv('TYPEPHP_WASI_CLANG_VERSION');
$backendName = 'LLVM Clang'
. (is_string($clangVersion) && $clangVersion !== '' ? " {$clangVersion}" : '');
}
$label = $this->platform instanceof Wasi ? 'Initialized target/toolchain' : 'Initialized platform/backend';
$this->climate->info(
"Initialized platform/backend: {$this->platform->getName()} + {$this->compilerBackend->getName()} ({$this->compilerBackend->getCompilerCommand()})"
"{$label}: {$this->platform->getName()} + {$backendName} ({$this->compilerBackend->getCompilerCommand()})"
);
} catch (\Throwable $e) {
$this->error($e->getMessage());
@ -244,6 +268,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 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)');
@ -754,7 +779,7 @@ class Translator extends Preprocessor
$code .= 'extern "C" void save_ps_args(int, char **) {}' . PHP_EOL;
}
if ($this->isBuildModeBin()) {
if ($this->isBuildModeBin() && !$this->isWasiTarget()) {
$cliHeaders = [
'#include "php_cli_process_title.h"',
'#include "php_cli_process_title_arginfo.h"',
@ -856,7 +881,7 @@ CODE;
$code .= "// clang-format off\n";
$code .= "static const zend_function_entry ext_functions[] = {\n";
if ($this->isBuildModeBin()) {
if ($this->isBuildModeBin() && !$this->isWasiTarget()) {
$code .= $this->getIndent() . "PHP_FE(cli_set_process_title, arginfo_cli_set_process_title)\n";
$code .= $this->getIndent() . "PHP_FE(cli_get_process_title, arginfo_cli_get_process_title)\n";
}
@ -885,7 +910,9 @@ CODE;
$code .= 'PHP_MINIT_FUNCTION(' . $this->getModuleName() . ') {' . PHP_EOL;
$code .= 'zend_try {' . PHP_EOL;
$code .= '// class/interface class entries' . PHP_EOL;
if (!$this->isWasiTarget()) {
$code .= 'typephp_register_fiber_generator_class();' . PHP_EOL;
}
$code .= 'if (typephp_install_reflection_attribute_handlers() != SUCCESS) {' . PHP_EOL;
$code .= $this->getIndent() . 'return FAILURE;' . PHP_EOL;
$code .= '}' . PHP_EOL;
@ -1289,7 +1316,9 @@ CODE;
{
$job = $this->maxJob;
if (!$this->isWasiTarget()) {
$sourceFiles[] = $this->getPhpxDir() . '/src/misc/typephp_fiber_generator.cc';
}
$sourceFiles[] = $this->getPhpxDir() . '/src/misc/typephp_helper.cc';
// embed 需要 main 函数,以及 cli 的内置函数定义
@ -1297,7 +1326,7 @@ CODE;
$sourceFiles[] = $this->getPhpxDir() . '/src/misc/typephp_main.cc';
}
if ($this->isBuildModeBin()) {
if ($this->isBuildModeBin() && !$this->isWasiTarget()) {
$sourceFiles[] = $this->getPhpxDir() . '/src/misc/php_cli_process_title.c';
$sourceFiles[] = $this->getPhpxDir() . '/src/misc/ps_title.c';
}
@ -1728,6 +1757,50 @@ CODE;
return $this->buildMode;
}
/** Write the public WIT contract and PHPX host-bindgen manifest. */
public function writeWasmInterface(
string $manifestFile,
string $witFile,
string $adapterFile,
string $asyncExportsFile,
string $package,
string $world,
): void
{
if (!$this->isWasiTarget() || !$this->isBuildModeLib()) {
$this->error('WIT interfaces can only be generated for a WASI library build');
}
try {
$generator = new WasmInterfaceGenerator();
$manifest = $generator->buildManifest(
$this->symbols->functions(),
$package,
$world,
fn (FunctionDef $function): string => self::PREFIX
. $this->getNativeName($function->name, $function->namespace),
);
foreach (array_unique([
dirname($manifestFile),
dirname($witFile),
dirname($adapterFile),
dirname($asyncExportsFile),
]) as $directory) {
if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) {
throw new \RuntimeException("Unable to create WASM interface directory: {$directory}");
}
}
$json = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
if (file_put_contents($manifestFile, $json . PHP_EOL) === false
|| file_put_contents($witFile, $generator->renderWit($manifest)) === false
|| file_put_contents($adapterFile, $generator->renderCppAdapter($manifest)) === false
|| file_put_contents($asyncExportsFile, $generator->renderJcoAsyncExports($manifest)) === false) {
throw new \RuntimeException('Unable to write the generated WASM interface');
}
} catch (\Throwable $exception) {
$this->error($exception->getMessage());
}
}
public function getArgInfoStubFilename(string $stubFile): string
{
$rs = str_replace(['.stub.php', '.php'], '', $stubFile);

@ -1,5 +1,8 @@
<?php
use TypePhp\Translator;
use TypePhp\Build\WasiToolchain;
use TypePhp\Build\WasiProjectConfig;
use TypePhp\Build\PhpxLocator;
function main(int $argc, array $argv): void
{
@ -11,6 +14,11 @@ function main(int $argc, array $argv): void
define("ROOT_PATH", getcwd());
}
if (getenv('TYPEPHP_WASM_INTERNAL_COMPILE') !== '1' && shouldCompileWasm($argv)) {
compileWasmProgram($argv);
return;
}
// .prof 文件分析模式:./tpc app.prof
if ($argc >= 2 && str_ends_with($argv[1], '.prof')) {
profileAnalyze($argc, $argv);
@ -27,10 +35,45 @@ function main(int $argc, array $argv): void
// 生成 C++ 文件
$sourceFiles = $translator->convert($files);
$wasmManifest = getenv('TYPEPHP_WASM_INTERFACE_MANIFEST');
if (is_string($wasmManifest) && $wasmManifest !== '') {
$wasmWit = getenv('TYPEPHP_WASM_INTERFACE_WIT');
$wasmAdapter = getenv('TYPEPHP_WASM_INTERFACE_ADAPTER');
$wasmAsyncExports = getenv('TYPEPHP_WASM_INTERFACE_ASYNC_EXPORTS');
$wasmPackage = getenv('TYPEPHP_WASM_PACKAGE');
$wasmWorld = getenv('TYPEPHP_WASM_WORLD');
if (!is_string($wasmWit) || $wasmWit === ''
|| !is_string($wasmAdapter) || $wasmAdapter === ''
|| !is_string($wasmAsyncExports) || $wasmAsyncExports === ''
|| !is_string($wasmPackage) || $wasmPackage === ''
|| !is_string($wasmWorld) || $wasmWorld === '') {
throw new RuntimeException('Incomplete internal WASM interface configuration');
}
$translator->writeWasmInterface(
$wasmManifest,
$wasmWit,
$wasmAdapter,
$wasmAsyncExports,
$wasmPackage,
$wasmWorld,
);
$sourceFiles[] = $wasmAdapter;
}
// --dry 模式:仅生成 C++ 代码,不执行编译
if ($translator->isDryRun()) {
$buildDir = $translator->getBuildDir();
$count = count($sourceFiles);
$sourceListFile = getenv('TYPEPHP_GENERATED_SOURCE_LIST');
if (is_string($sourceListFile) && $sourceListFile !== '') {
$sourceListDir = dirname($sourceListFile);
if (!is_dir($sourceListDir) && !mkdir($sourceListDir, 0777, true) && !is_dir($sourceListDir)) {
throw new RuntimeException("Unable to create generated source manifest directory: {$sourceListDir}");
}
if (file_put_contents($sourceListFile, implode(PHP_EOL, $sourceFiles) . PHP_EOL) === false) {
throw new RuntimeException("Unable to write generated source manifest: {$sourceListFile}");
}
}
$translator->output("Dry run completed: {$count} C++ source file(s) generated in {$buildDir}", 'lightBlue');
return;
}
@ -45,6 +88,205 @@ function main(int $argc, array $argv): void
}
}
/**
* 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.
*/
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");
exit(1);
}
$buildDir = $arguments[++$i];
continue;
}
if (str_starts_with($argument, '--build-dir=')) {
$buildDir = substr($argument, strlen('--build-dir='));
if ($buildDir === '') {
fwrite(STDERR, "Option --build-dir requires a directory\n");
exit(1);
}
continue;
}
if (str_starts_with($argument, '-')) {
fwrite(STDERR, "Unsupported option in --wasm mode: {$argument}\n");
exit(1);
}
if ($input !== null) {
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|project.yml> [--wasm[=browser|component]] [--build-dir <directory>]\n");
exit(1);
}
$workingDirectory = getcwd();
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__) . '/wasm/build-typephp-program.sh';
if (!is_executable($builder)) {
fwrite(STDERR, "TypePHP WASI builder is not executable: {$builder}\n");
exit(1);
}
try {
$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");
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);
}
$environment = getenv();
if (!is_array($environment)) {
$environment = [];
}
$environment['TYPEPHP_WASI_CC'] = $tools['clang'];
$environment['TYPEPHP_WASI_CXX'] = $tools['clang++'];
$environment['TYPEPHP_WASI_AR'] = $tools['llvm-ar'];
$environment['TYPEPHP_WASI_RANLIB'] = $tools['llvm-ranlib'];
$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_WASM_PROGRAM_BUILD_DIR'] = $project->buildDir;
$environment['TYPEPHP_WASM_MODE'] = $project->mode;
$environment['TYPEPHP_WASM_PACKAGE'] = $project->package;
$environment['TYPEPHP_WASM_WORLD'] = $project->world;
$compilerExecutable = realpath($argv[0]);
if ($compilerExecutable === false || !is_executable($compilerExecutable)) {
fwrite(STDERR, "Unable to resolve the current TypePHP compiler executable: {$argv[0]}\n");
exit(1);
}
if ($project->browserDir !== null) {
$environment['TYPEPHP_WASM_BROWSER_DIR'] = $project->browserDir;
}
try {
$phpxDir = PhpxLocator::resolve(ROOT_PATH);
} catch (RuntimeException $exception) {
fwrite(STDERR, "Unable to locate PHPX: {$exception->getMessage()}\n");
exit(1);
}
if ($project->mode === 'library') {
$hostOs = match (PHP_OS_FAMILY) {
'Linux' => 'linux',
'Darwin' => 'macos',
'Windows' => 'windows',
default => strtolower(PHP_OS_FAMILY),
};
$hostArch = strtolower(php_uname('m'));
$hostArch = match ($hostArch) {
'amd64', 'x64' => 'x86_64',
'arm64' => $hostOs === 'linux' ? 'aarch64' : 'arm64',
default => $hostArch,
};
$bindgen = $phpxDir . DIRECTORY_SEPARATOR . 'wasm' . DIRECTORY_SEPARATOR . 'bin'
. DIRECTORY_SEPARATOR . $hostOs . '-' . $hostArch . DIRECTORY_SEPARATOR . 'wit-bindgen'
. ($hostOs === 'windows' ? '.exe' : '');
if (!is_file($bindgen)) {
fwrite(STDERR, "PHPX bundled WIT binding generator is missing: {$bindgen}\n");
fwrite(STDERR, "Install the matching PHPX package; installing wit-bindgen separately is not required.\n");
exit(1);
}
$environment['TYPEPHP_WIT_BINDGEN'] = $bindgen;
}
$command = [$builder, $project->input, $project->output ?? '-', $phpxDir, $compilerExecutable];
$process = proc_open(
$command,
[STDIN, STDOUT, STDERR],
$pipes,
getcwd(),
$environment,
);
if (!is_resource($process)) {
fwrite(STDERR, "Failed to start the TypePHP WASI builder\n");
exit(1);
}
$exitCode = proc_close($process);
if ($exitCode !== 0) {
exit($exitCode);
}
}
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];

@ -19,6 +19,20 @@ final readonly class NoExport
{
}
/**
* Expose a statically typed function through a WASI 0.2 component interface.
*
* This is a compile-time attribute. It is never instantiated by the PHP
* runtime and therefore adds no reflection or dispatch overhead.
*/
#[Attribute(Attribute::TARGET_FUNCTION)]
final readonly class WasmExport
{
public function __construct(public ?string $name = null)
{
}
}
#[Attribute(Attribute::TARGET_PROPERTY)]
final readonly class Getter
{

@ -0,0 +1,139 @@
# TypePHP WASI SDK layout
TypePHP application builds never compile PHP, PHPX, GMP, MPFR, or mpdecimal.
The integrated PHPX installer places their prebuilt `wasm32-wasip2` SDK at:
```text
<phpx>/wasm/wasm32-wasip2/
├── include/
│ ├── php/ PHP installed and generated headers
│ ├── phpx/ PHPX public and TypePHP runtime headers
│ ├── zlib.h zlib API used by PHP's static zlib extension
│ ├── zconf.h
│ ├── sodium.h
│ ├── openssl/ OpenSSL crypto-only public headers
│ ├── libxml2/ libxml2 public headers
│ ├── sqlite3.h
│ ├── zip.h
│ ├── bzlib.h
│ ├── gmp.h
│ ├── gmpxx.h
│ ├── mpfr.h
│ ├── mpdecimal.h
│ └── decimal.hh
├── lib/
│ ├── libphp.a
│ ├── libphpx.a
│ ├── libgmp.a
│ ├── libgmpxx.a
│ ├── libmpfr.a
│ ├── libmpdec.a
│ └── libmpdec++.a
└── .typephp-wasi-sdk-abi
```
The zlib, bzip2, libsodium, libcrypto, libxml2, SQLite, and libzip objects are
embedded in `libphp.a`; the SDK intentionally does not ship or link their
dependency archives separately.
The ABI file must contain exactly:
```text
typephp-wasip2-sdk-abi-v4
```
TypePHP locates PHPX through the existing `PHPX_HOME` setting, Composer's
`swoole/phpx` installation metadata, or `vendor/swoole/phpx`. TypePHP developers
who independently clone and build the matching `php-8.5.9-wasm` and PHPX
repositories install the complete SDK below that PHPX checkout. There is no
additional WASI SDK environment variable and no set of per-library search
paths: all headers, archives, and the ABI marker must be installed together so
an application cannot accidentally mix incompatible builds.
TypePHP owns complete SDK orchestration. From the compiler repository, build
and install the matching PHP and PHPX portions with:
```shell
./wasm/build-sdk.sh \
--prefix "${PHPX_HOME}/wasm/wasm32-wasip2" \
--jobs 16
```
The PHP build produces only `libphp.a` and PHP headers. The PHPX build owns
GMP, MPFR, the vendored mpdecimal, `libphpx.a`, and their headers. The
orchestrator validates the combined installation before writing
`.typephp-wasi-sdk-abi`.
Autoconf, Bison, re2c, Rust, upstream `wit-bindgen`, and the PHP/PHPX source
trees are SDK producer dependencies only. They are never searched for by an
application build. PHPX release packages include the pinned host-side
`phpx-wit-bindgen` needed for application-specific exports below
`<phpx>/wasm/bin/<host-os>-<host-arch>/`.
SDK producer and TypePHP integration checks are kept with the TypePHP WASM
backend rather than php-src:
```text
wasm/link-numeric-smoke-test.sh GMP, MPFR, and mpdecimal link check
wasm/test-typephp-program.sh TypePHP high-precision integration check
wasm/numeric-smoke-test.cc Native numeric test program
wasm/examples/high-precision.php TypePHP integration example
```
## Language-level component exports
A WASI command remains the default and defines `main()`. A callable component
uses library mode and exports explicitly annotated, statically typed functions:
```php
#[WasmExport]
function add(int $left, int $right): int
{
return $left + $right;
}
#[WasmExport(name: 'greet-user')]
function greetUser(string $name): string
{
return "Hello, $name";
}
```
```yaml
name: calculator
mode: library
wasm: browser
wasm-package: app:calculator@1.0.0
wasm-world: calculator
sources:
- src
```
The first ABI version supports `bool`, `int`, `float`, `string`, nullable
versions of those types, and `void`. PHP `int` maps to WIT `s64` and therefore
to JavaScript `bigint`. Untyped values, arrays, objects, references, variadic
or optional parameters, generators, and exported methods are compile-time
errors. Every exported call uses a WIT `result` so PHP exceptions and Zend
bailouts are converted before crossing the Canonical ABI boundary.
`tpc` writes the generated `.wit` next to its intermediate interface manifest,
invokes PHPX's bundled generator, and links a reactor component. Browser mode
then uses Jco exactly as command components do. The generated
`create-runtime()` function returns a WIT `runtime` resource. Creating it
starts one NTS PHP request; dropping it runs `RSHUTDOWN` before the request
memory pool is released. Methods on the same resource must remain serialized,
and only one runtime resource may be active in a component instance.
Jco exposes the resource as a JavaScript class. Browser code creates one
runtime and reuses it for hot calls:
```js
const runtime = await api.createRuntime();
console.log(await runtime.add(20n, 22n));
console.log(await runtime.greetUser('TypePHP'));
// Release deterministically instead of waiting for JavaScript GC.
runtime[Symbol.dispose]();
```
Top-level WIT `result` errors are surfaced by Jco as JavaScript exceptions.

@ -0,0 +1,136 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
compiler_dir=$(cd "${script_dir}/.." && pwd)
usage()
{
cat <<'EOF'
Usage: ./wasm/build-sdk.sh --prefix <wasm32-wasip2-sdk-dir> [options]
Options:
--prefix <dir> Required complete SDK installation prefix
--php-source <dir> PHP source tree (default: projects/php-8.5.9)
--phpx-source <dir> PHPX source tree (default: PHPX_HOME or vendor package)
--build-dir <dir> Build root (default: /tmp/typephp-wasip2-sdk-build)
--jobs <number> Parallel build jobs (default: 8)
-h, --help Show this help
EOF
}
prefix=
php_source=${compiler_dir}/projects/php-8.5.9
phpx_source=${PHPX_HOME:-${compiler_dir}/vendor/swoole/phpx}
build_root=${TYPEPHP_WASM_SDK_BUILD_DIR:-/tmp/typephp-wasip2-sdk-build}
jobs=${TYPEPHP_WASM_JOBS:-8}
while [[ $# -gt 0 ]]; do
case "$1" in
--prefix)
[[ $# -ge 2 ]] || { echo "--prefix requires a directory" >&2; exit 2; }
prefix=$2
shift 2
;;
--prefix=*) prefix=${1#*=}; shift ;;
--php-source)
[[ $# -ge 2 ]] || { echo "--php-source requires a directory" >&2; exit 2; }
php_source=$2
shift 2
;;
--php-source=*) php_source=${1#*=}; shift ;;
--phpx-source)
[[ $# -ge 2 ]] || { echo "--phpx-source requires a directory" >&2; exit 2; }
phpx_source=$2
shift 2
;;
--phpx-source=*) phpx_source=${1#*=}; shift ;;
--build-dir)
[[ $# -ge 2 ]] || { echo "--build-dir requires a directory" >&2; exit 2; }
build_root=$2
shift 2
;;
--build-dir=*) build_root=${1#*=}; shift ;;
--jobs|-j)
[[ $# -ge 2 ]] || { echo "$1 requires a number" >&2; exit 2; }
jobs=$2
shift 2
;;
--jobs=*|-j*) jobs=${1#*=}; jobs=${jobs#-j}; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [[ -z "${prefix}" ]]; then
echo "--prefix is required" >&2
usage >&2
exit 2
fi
if [[ ! "${jobs}" =~ ^[1-9][0-9]*$ ]]; then
echo "Invalid --jobs value: ${jobs}" >&2
exit 2
fi
if [[ ! -x "${php_source}/wasm/build.sh" ]]; then
echo "PHP/WASI build entry was not found: ${php_source}/wasm/build.sh" >&2
exit 1
fi
if [[ ! -x "${phpx_source}/wasm/build.sh" ]]; then
echo "PHPX/WASI build entry was not found: ${phpx_source}/wasm/build.sh" >&2
exit 1
fi
mkdir -p "${prefix}" "${build_root}"
prefix=$(cd "${prefix}" && pwd)
php_source=$(cd "${php_source}" && pwd)
phpx_source=$(cd "${phpx_source}" && pwd)
build_root=$(cd "${build_root}" && pwd)
"${php_source}/wasm/build.sh" \
--prefix "${prefix}" \
--build-dir "${build_root}/php" \
--jobs "${jobs}"
"${phpx_source}/wasm/build.sh" \
--prefix "${prefix}" \
--build-dir "${build_root}/phpx" \
--jobs "${jobs}"
required_files=(
.typephp-wasi-php-abi
.typephp-wasi-numeric-abi
.typephp-wasi-runtime-abi
include/php/main/php.h
include/php/main/php_config.h
include/phpx/phpx.h
include/phpx/typephp_helper.h
include/zlib.h
include/zconf.h
include/sodium.h
include/openssl/evp.h
include/libxml2/libxml/parser.h
include/sqlite3.h
include/zip.h
include/bzlib.h
include/gmp.h
include/mpfr.h
include/mpdecimal.h
include/decimal.hh
lib/libphp.a
lib/libphpx.a
lib/libgmp.a
lib/libgmpxx.a
lib/libmpfr.a
lib/libmpdec.a
lib/libmpdec++.a
)
for file in "${required_files[@]}"; do
if [[ ! -f "${prefix}/${file}" ]]; then
echo "TypePHP WASI SDK is incomplete: ${prefix}/${file}" >&2
exit 1
fi
done
printf '%s\n' 'typephp-wasip2-sdk-abi-v4' > "${prefix}/.typephp-wasi-sdk-abi"
echo "Installed complete TypePHP WASI 0.2 SDK: ${prefix}"

@ -0,0 +1,278 @@
#!/usr/bin/env bash
set -euo pipefail
fatal_error() {
local red=''
local reset=''
if [[ -t 2 && -z "${NO_COLOR:-}" && "${TERM:-}" != dumb ]]; then
red=$'\033[1;31m'
reset=$'\033[0m'
fi
printf '%sFatal error: %s%s\n' "${red}" "$1" "${reset}" >&2
shift
for line in "$@"; do
printf '%s %s%s\n' "${red}" "${line}" "${reset}" >&2
done
exit 1
}
if [[ $# -ne 4 ]]; then
echo "Usage: $0 <program.php> <output.wasm|-> <phpx-dir> <tpc-executable>" >&2
exit 1
fi
caller_dir=${PWD}
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
compiler_dir=$(cd "${script_dir}/.." && pwd)
phpx_dir=$3
typephp_compiler=$4
wasi_sdk_dir=${phpx_dir}/wasm/wasm32-wasip2
wasi_include_dir=${wasi_sdk_dir}/include
wasi_php_include_dir=${wasi_include_dir}/php
wasi_phpx_include_dir=${wasi_include_dir}/phpx
wasi_library_dir=${wasi_sdk_dir}/lib
wasi_cxx=${TYPEPHP_WASI_CXX:?TYPEPHP_WASI_CXX is required}
input=$1
if [[ "${input}" != /* ]]; then
input=${caller_dir}/${input}
fi
input=$(realpath "${input}")
stem=$(basename "${input}" .php)
stem=${stem//[^a-zA-Z0-9_-]/_}
build_root=${TYPEPHP_WASM_PROGRAM_BUILD_DIR:-${caller_dir}/build}
mkdir -p "${build_root}"
build_root=$(cd "${build_root}" && pwd)
generated_dir=${build_root}
generated_source_list=${build_root}/.typephp-wasm-sources
wasm_mode=${TYPEPHP_WASM_MODE:-command}
interface_dir=${build_root}/wasm-interface
interface_manifest=${interface_dir}/typephp-wasm-interface.json
interface_wit=${interface_dir}/world.wit
interface_adapter=${interface_dir}/typephp_wasm_adapter.cc
interface_async_exports=${interface_dir}/jco-async-exports
cleanup_generated_source_list() {
rm -f -- "${generated_source_list}"
}
trap cleanup_generated_source_list EXIT
if [[ $2 != - ]]; then
output=$2
if [[ "${output}" != /* ]]; then
output=${caller_dir}/${output}
fi
else
output=${caller_dir}/${stem}.wasm
fi
mkdir -p "${generated_dir}" "$(dirname "${output}")"
# Convert first so target-specific source errors are reported before validating
# and linking the separately installed WASI SDK.
internal_compile_env=(
TYPEPHP_WASM_INTERNAL_COMPILE=1
TYPEPHP_GENERATED_SOURCE_LIST="${generated_source_list}"
)
internal_compile_args=()
if [[ "${wasm_mode}" == library ]]; then
mkdir -p "${interface_dir}"
internal_compile_env+=(
TYPEPHP_WASM_INTERFACE_MANIFEST="${interface_manifest}"
TYPEPHP_WASM_INTERFACE_WIT="${interface_wit}"
TYPEPHP_WASM_INTERFACE_ADAPTER="${interface_adapter}"
TYPEPHP_WASM_INTERFACE_ASYNC_EXPORTS="${interface_async_exports}"
TYPEPHP_WASM_PACKAGE="${TYPEPHP_WASM_PACKAGE:?TYPEPHP_WASM_PACKAGE is required in library mode}"
TYPEPHP_WASM_WORLD="${TYPEPHP_WASM_WORLD:?TYPEPHP_WASM_WORLD is required in library mode}"
)
internal_compile_args+=(-m lib)
fi
env "${internal_compile_env[@]}" "${typephp_compiler}" "${input}" \
--dry \
--target-platform wasm32-wasip2 \
--build-dir "${generated_dir}" \
"${internal_compile_args[@]}" \
--no-progress \
--no-color
if [[ ! -s "${generated_source_list}" ]]; then
echo "TypePHP did not write the generated C++ source manifest: ${generated_source_list}" >&2
exit 1
fi
mapfile -t generated_sources < "${generated_source_list}"
if [[ ${#generated_sources[@]} -eq 0 ]]; then
echo "TypePHP did not generate any C++ source files" >&2
exit 1
fi
wasi_sdk_stamp=${wasi_sdk_dir}/.typephp-wasi-sdk-abi
if [[ ! -f "${wasi_sdk_stamp}" ]] \
|| ! grep -qx 'typephp-wasip2-sdk-abi-v4' "${wasi_sdk_stamp}"; then
fatal_error \
"TypePHP WASI SDK is missing or ABI-incompatible: ${wasi_sdk_dir}" \
"Install the matching PHPX package or set PHPX_HOME to its installation directory."
fi
required_libraries=(libphp.a libphpx.a libgmp.a libgmpxx.a libmpfr.a libmpdec.a libmpdec++.a)
for library in "${required_libraries[@]}"; do
if [[ ! -f "${wasi_library_dir}/${library}" ]]; then
fatal_error "TypePHP WASI SDK library is missing: ${wasi_library_dir}/${library}"
fi
done
required_headers=(
php/main/php.h
php/main/php_config.h
php/Zend/zend_config.h
php/ext/date/lib/timelib_config.h
phpx/phpx.h
phpx/typephp_helper.h
zlib.h
zconf.h
gmp.h
mpfr.h
decimal.hh
)
for header in "${required_headers[@]}"; do
if [[ ! -f "${wasi_include_dir}/${header}" ]]; then
fatal_error "TypePHP WASI SDK header is missing: ${wasi_include_dir}/${header}"
fi
done
compile_flags=(
-std=c++17
-O2
-fwasm-exceptions
-mllvm -wasm-enable-sjlj
-mllvm -wasm-use-legacy-eh=false
-Wno-deprecated-literal-operator
)
include_flags=(
-I"${wasi_php_include_dir}"
-I"${wasi_php_include_dir}/main"
-I"${wasi_php_include_dir}/Zend"
-I"${wasi_php_include_dir}/TSRM"
-I"${wasi_php_include_dir}/ext/date/lib"
-I"${wasi_phpx_include_dir}"
-I"${wasi_include_dir}"
-I"${generated_dir}/include"
)
# WIT is application-specific, but its generator is a host build tool. PHPX
# packages a pinned wit-bindgen binary per host so users never install it or a
# Rust toolchain. It is not linked into PHPX or the resulting component.
binding_objects=()
if [[ "${wasm_mode}" == library ]]; then
bindgen=${TYPEPHP_WIT_BINDGEN:?TYPEPHP_WIT_BINDGEN is required in library mode}
if [[ ! -x "${bindgen}" ]]; then
fatal_error \
"PHPX bundled WIT binding generator is missing: ${bindgen}" \
"Install the matching PHPX package; installing wit-bindgen separately is not required."
fi
bindgen_version=$("${bindgen}" --version 2>/dev/null || true)
if [[ "${bindgen_version}" != 'wit-bindgen-cli 0.60.0' ]]; then
fatal_error \
"PHPX bundled WIT binding generator has an incompatible version: ${bindgen_version:-unknown}" \
"Expected wit-bindgen-cli 0.60.0 from the matching PHPX package."
fi
binding_world=${TYPEPHP_WASM_WORLD//-/_}
"${bindgen}" c \
--world "${TYPEPHP_WASM_WORLD}" \
--rename-world "${binding_world}" \
--out-dir "${interface_dir}" \
"${interface_wit}"
generated_sources+=("${interface_dir}/${binding_world}.c")
binding_objects+=("${interface_dir}/${binding_world}_component_type.o")
fi
generated_objects=()
for source in "${generated_sources[@]}"; do
if [[ ! -f "${source}" ]]; then
echo "Generated C++ source file not found: ${source}" >&2
exit 1
fi
object=${source%.cc}.o
if [[ "${source}" == *.c ]]; then
"${TYPEPHP_WASI_CC:?TYPEPHP_WASI_CC is required}" -O2 -c "${source}" -o "${object}"
else
"${wasi_cxx}" "${compile_flags[@]}" "${include_flags[@]}" -I"${interface_dir}" -c "${source}" -o "${object}"
fi
generated_objects+=("${object}")
done
link_mode_flags=()
if [[ "${wasm_mode}" == library ]]; then
link_mode_flags+=(-mexec-model=reactor)
fi
# Every generated object and runtime archive is already built with -O2. Keep
# the final driver invocation optimized as well, but do not let Clang discover
# an arbitrary system wasm-opt: older Binaryen releases cannot parse the Wasm
# exception-reference instructions emitted by the current WASI SDK. Stripping
# linker metadata has a much larger browser startup benefit than another slow
# whole-module optimization pass and does not change runtime semantics.
"${wasi_cxx}" \
-O2 \
--no-wasm-opt \
-std=c++17 \
-fwasm-exceptions \
"${generated_objects[@]}" \
"${binding_objects[@]}" \
-Wl,--whole-archive \
"${wasi_library_dir}/libphpx.a" \
-Wl,--no-whole-archive \
"${wasi_library_dir}/libphp.a" \
"${wasi_library_dir}/libmpdec++.a" \
"${wasi_library_dir}/libmpdec.a" \
"${wasi_library_dir}/libmpfr.a" \
"${wasi_library_dir}/libgmpxx.a" \
"${wasi_library_dir}/libgmp.a" \
-lwasi-emulated-signal -lsetjmp -lunwind -ldl -lm \
"${link_mode_flags[@]}" \
-Wl,--strip-all \
-Wl,--fatal-warnings \
-o "${output}"
echo "Built TypePHP/WASI program: ${output}"
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}
browser_dir=${TYPEPHP_WASM_BROWSER_DIR:-${output%.wasm}.browser}
mkdir -p "${browser_dir}"
jco_flags=()
if "${jco_bin}" transpile --help 2>&1 | grep -q -- '--bindgen-enable-wasm-exnref'; then
jco_flags+=(--bindgen-enable-wasm-exnref)
fi
if ! "${jco_bin}" transpile --help 2>&1 | grep -q -- '--async-wasi-imports'; then
echo "Jco does not support JSPI-backed asynchronous WASI imports; upgrade Jco" >&2
exit 1
fi
jco_flags+=(--async-mode jspi --async-wasi-imports --async-wasi-exports)
if [[ "${wasm_mode}" == library ]]; then
if ! "${jco_bin}" transpile --help 2>&1 | grep -q -- '--async-exports'; then
fatal_error "Jco does not support JSPI-backed TypePHP exports; upgrade Jco"
fi
if [[ ! -s "${interface_async_exports}" ]]; then
fatal_error "TypePHP did not generate the Jco async export list: ${interface_async_exports}"
fi
mapfile -t jco_async_exports < "${interface_async_exports}"
jco_flags+=(--async-exports "${jco_async_exports[@]}")
fi
"${jco_bin}" transpile "${output}" \
-o "${browser_dir}" \
--name program \
--no-nodejs-compat \
--no-namespaced-exports \
--instantiation async \
--base64-cutoff=0 \
"${jco_flags[@]}"
echo "Built TypePHP/WASI browser module: ${browser_dir}"
fi
if [[ "${TYPEPHP_WASM_RUN:-0}" == 1 ]]; then
wasmtime_bin=${TYPEPHP_WASMTIME:-wasmtime}
XDG_CACHE_HOME=${XDG_CACHE_HOME:-/tmp/typephp-wasmtime-cache} \
"${wasmtime_bin}" "${output}"
fi

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
use native_types;
function main(): void
{
$integer = std::bigInt("123456789012345678901234567890");
echo ($integer * 9)->toString(), "\n";
$float = std::bigFloat("1000000000000000000000000000000");
echo ($float + std::bigFloat("1"))->toString(), "\n";
$decimal = std::decimal("12345.00000000000000001");
echo ($decimal + std::decimal("3.14159265358979323"))->toString(), "\n";
}

@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
compiler_dir=$(cd "${script_dir}/.." && pwd)
phpx_home=${PHPX_HOME:-${compiler_dir}/vendor/swoole/phpx}
prefix=${TYPEPHP_WASI_SDK_DIR:-${phpx_home}/wasm/wasm32-wasip2}
output=${TYPEPHP_WASM_NUMERIC_OUTPUT:-/tmp/typephp-wasm-numeric.wasm}
wasi_cxx=${TYPEPHP_WASI_CXX:-$(command -v wasm32-wasip2-clang++ || true)}
if [[ -z "${wasi_cxx}" ]]; then
echo "Required WASI tool 'wasm32-wasip2-clang++' was not found in PATH" >&2
exit 1
fi
for library in libgmp.a libgmpxx.a libmpfr.a libmpdec.a libmpdec++.a; do
if [[ ! -f "${prefix}/lib/${library}" ]]; then
echo "WASI numeric library not found: ${prefix}/lib/${library}" >&2
exit 1
fi
done
"${wasi_cxx}" \
-O0 \
-std=c++17 \
-fwasm-exceptions \
-mllvm -wasm-enable-sjlj \
-mllvm -wasm-use-legacy-eh=false \
-I"${prefix}/include" \
"${script_dir}/numeric-smoke-test.cc" \
-L"${prefix}/lib" \
-lmpdec++ -lmpdec -lmpfr -lgmpxx -lgmp \
-lwasi-emulated-signal \
-lsetjmp -lunwind -lm \
-o "${output}"
echo "Linked numeric WASI smoke test: ${output}"

@ -0,0 +1,52 @@
#include <gmpxx.h>
#include <mpfr.h>
#include <decimal.hh>
#include <cstdio>
#include <string>
int main()
{
mpz_class integer("18446744073709551616");
integer = integer * integer + 7;
if (integer.get_str() != "340282366920938463463374607431768211463") {
return 1;
}
mpfr_t value;
mpfr_init2(value, 256);
if (mpfr_set_str(value, "2", 10, MPFR_RNDN) != 0) {
mpfr_clear(value);
return 2;
}
mpfr_sqrt(value, value, MPFR_RNDN);
char float_buffer[96];
mpfr_snprintf(float_buffer, sizeof(float_buffer), "%.40RNf", value);
mpfr_clear(value);
if (std::string(float_buffer) != "1.4142135623730950488016887242096980785697") {
return 3;
}
decimal::Decimal small_decimal("1.25");
if (small_decimal.to_sci() != "1.25") {
std::fprintf(stderr, "unexpected parsed Decimal: %s\n", small_decimal.to_sci().c_str());
return 4;
}
small_decimal *= decimal::Decimal("8");
if (small_decimal.to_sci() != "10.00") {
std::fprintf(stderr, "unexpected small Decimal result: %s\n", small_decimal.to_sci().c_str());
return 5;
}
decimal::Context decimal_context(32);
decimal::Decimal decimal_value("12345678901234567890.125");
decimal_value = decimal_value.mul(decimal::Decimal("8"), decimal_context);
const std::string decimal_string = decimal_value.to_sci();
if (decimal_string != "98765431209876543121.000") {
std::fprintf(stderr, "unexpected Decimal result: %s\n", decimal_string.c_str());
return 6;
}
std::puts("TYPEPHP_WASM_NUMERIC_OK");
return 0;
}

@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
compiler_dir=$(cd "${script_dir}/.." && pwd)
output=${TYPEPHP_WASM_TEST_OUTPUT:-/tmp/typephp-wasm-high-precision.wasm}
wasmtime_bin=${TYPEPHP_WASMTIME:-$(command -v wasmtime || true)}
if [[ -z "${wasmtime_bin}" ]]; then
echo "Required WASI tool 'wasmtime' was not found in PATH" >&2
exit 1
fi
output_dir=$(dirname "${output}")
output_name=$(basename "${output}")
(
cd "${output_dir}"
php "${compiler_dir}/bin/tpc.php" --wasm=component "${script_dir}/examples/high-precision.php"
if [[ high-precision.wasm != "${output_name}" ]]; then
mv high-precision.wasm "${output_name}"
fi
)
actual=$(XDG_CACHE_HOME=${XDG_CACHE_HOME:-/tmp/typephp-wasmtime-cache} \
"${wasmtime_bin}" -S http "${output}")
expected=$'1111111101111111110111111111010\n1000000000000000000000000000001\n12348.14159265358979324'
if [[ "${actual}" != "${expected}" ]]; then
echo "Unexpected TypePHP/WASI output:" >&2
printf '%s\n' "${actual}" >&2
exit 1
fi
printf '%s\n' "${actual}"
echo "TypePHP/WASI integration test passed"
Loading…
Cancel
Save