commit
1baefe166f
63 changed files with 8336 additions and 104 deletions
@ -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 目标下明确不支持。 |
||||
@ -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', |
||||
}, |
||||
}); |
||||
@ -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', |
||||
); |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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`)"); |
||||
} |
||||
} |
||||
} |
||||
@ -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'; |
||||
} |
||||
} |
||||
@ -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…
Reference in new issue