feat(wasm): add WASI component library mode with WIT interface generation

- Add wasm_mode environment variable with command/library options
- Implement WIT interface generation with manifest, adapter, and binding files
- Create WasmExport attribute for marking functions as component exports
- Generate C bindings and integrate wit-bindgen for component compilation
- Add support for library mode with reactor execution model
- Update build script to handle component vs command compilation
- Add JavaScript integration for calling exported Wasm functions
- Implement example extension info retrieval via WasmExport functions
- Add CSS styling for extension detail UI components
- Update documentation for WASI component library workflow
pull/46/head
韩天峰 3 weeks ago
parent ac16f5fbac
commit 3d36928e8b
  1. 2
      docs/README.md
  2. 2
      docs/TYPEPHP_WASM_IMPLEMENTATION_PLAN.md
  3. 90
      docs/WASI_BUILD.md
  4. 6
      examples/wasm-hello/README.md
  5. 6
      examples/wasm-hello/index.html
  6. 125
      examples/wasm-hello/main.js
  7. 4
      examples/wasm-hello/project.yml
  8. 36
      examples/wasm-hello/src/WasiDemo.php
  9. 21
      examples/wasm-hello/src/main.php
  10. 24
      examples/wasm-hello/style.css
  11. 59
      examples/wasm-hello/typephp-worker.mjs
  12. 9
      phpunit/code/wasm-export-invalid-method.php
  13. 6
      phpunit/code/wasm-export-invalid-name.php
  14. 7
      phpunit/code/wasm-export-valid.php
  15. 23
      phpunit/src/Build/WasiProjectConfigTest.php
  16. 89
      phpunit/src/Build/WasmInterfaceGeneratorTest.php
  17. 3
      phpunit/src/CompileTimeAttributeRegistryTest.php
  18. 21
      phpunit/src/WasmExportAttributeTest.php
  19. 4
      src/Build/SourcePipelineTrait.php
  20. 45
      src/Build/WasiProjectConfig.php
  21. 470
      src/Build/WasmInterfaceGenerator.php
  22. 4
      src/Entity/FunctionDef.php
  23. 62
      src/Preprocessor.php
  24. 2
      src/Transform/CompileTimeAttributeRegistry.php
  25. 45
      src/Translator.php
  26. 51
      src/compiler.php
  27. 14
      src/polyfills.php
  28. 66
      wasm/README.md
  29. 76
      wasm/build-typephp-program.sh

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

@ -8,7 +8,7 @@
本文记录 TypePHP 支持 WebAssembly 的技术决策、功能边界、运行时架构、主要风险、验证方法和分阶段实施计划。
2026-08-07 的实现验证已经证明:精简 PHP 8.5、PHPX 核心、TypePHP 生成代码、GMP、MPFR 和 mpdecimal 可以通过 WASI SDK 静态链接为单个模块,并在 Wasmtime 中运行。可复现构建方法见 [构建 TypePHP WASI 程序](TYPEPHP_WASI_BUILD.md)。本文余下内容同时保留浏览器阶段的设计目标。
2026-08-07 的实现验证已经证明:精简 PHP 8.5、PHPX 核心、TypePHP 生成代码、GMP、MPFR 和 mpdecimal 可以通过 WASI SDK 静态链接为单个模块,并在 Wasmtime 中运行。可复现构建方法见 [构建 TypePHP WASI 程序](WASI_BUILD.md)。本文余下内容同时保留浏览器阶段的设计目标。
## 2. 核心结论

@ -1,6 +1,6 @@
# 构建 TypePHP WASI 程序
TypePHP 使用稳定的 WASI 0.2(Preview 2)和 Component Model。TypePHP 生成的 C++、PHPX 核心、精简的 PHP 8.5 NTS、GMP、MPFR 和 mpdecimal 会静态链接为单个 `.wasm` command component。WASI 0.1(Preview 1)不受支持。
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)不受支持。
## 环境要求
@ -22,7 +22,7 @@ WASI 构建会检查 `wasm32-wasip2-clang`、`wasm32-wasip2-clang++`、`llvm-ar`
## 一条命令构建
源文件必须提供 `main(): void`
command 模式的源文件必须提供 `main(): void`
```php
<?php
@ -63,7 +63,7 @@ sources:
路径、sources 等详细配置继续放在 `project.yml`,不通过 `--wasm=` 传递。
PHP、PHPX、TypePHP runtime、GMP、MPFR 和 mpdecimal 由 SDK 发布阶段预编译为 WASI 静态库。应用构建只编译 TypePHP 为当前程序生成的 C++,然后链接这些 `.a`。`tpc --wasm` 不会下载源码、运行 `wit-bindgen`,也不会调用 PHP、PHPX 或高精度库的构建脚本
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` 扩展。
@ -79,13 +79,93 @@ Chrome Demo:
```bash
cd examples/wasm-hello
npm run wasm
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 的三种语言级高精度类型:
@ -118,7 +198,7 @@ wasm32 使用 32 位指针,但 PHP 的 `zend_long` 保持 64 位,以维持 T
- PHPX Facade API 在 `__wasi__` 下整体禁用。PHPX 核心类型和 `phpx_std` 仍可使用。
- 不支持动态扩展、网络 socket、进程、shell 和信号。静态可识别的调用会在编译期报致命错误。
- 保留 PHP stream 框架、本地文件能力以及由 WASI host 提供的时间和随机数能力。
- `.wasm` 是同一份 WASI 0.2 command component:Wasmtime 直接运行;Chrome 使用 Jco 生成的 ESM 和 `examples/wasm-hello/typephp-worker.mjs` 中的 Worker 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、PDO 等 API 暴露为“可编译但链接失败”的接口。

@ -10,7 +10,7 @@ Demo 展示以下已支持能力:
- 内存文件系统,以及可选的 OPFS 快照持久化
- 通过同步 `file_get_contents()` 发起 HTTP/HTTPS GET;浏览器等待期间由 JSPI 挂起 Wasm 调用栈
- PHP 8.5 runtime 信息
- 由 `get_loaded_extensions()` 动态读取的 PHP/WASI 内置扩展列表
- 由 `get_loaded_extensions()` 动态读取的 PHP/WASI 内置扩展列表;点击扩展后,JavaScript 调用 `#[WasmExport]` 导出的函数读取版本、函数、类、常量和 INI 配置
- TypePHP 语言级 BigInt、Decimal、BigFloat 高精度计算
原始 socket、进程、shell、信号、Fiber 和 Generator 明确不支持。
@ -38,11 +38,13 @@ 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`
@ -58,7 +60,7 @@ php ../../bin/tpc.php project.yml --wasm=component
npm run dev
```
打开终端显示的本地地址。可以修改参数、环境变量和 stdin 后重复运行;勾选 OPFS 后,PHP 写入虚拟文件系统的运行次数会跨页面刷新保存。
打开终端显示的本地地址。可以修改参数、环境变量和 stdin 后重复运行;勾选 OPFS 后,PHP 写入虚拟文件系统的运行次数会跨页面刷新保存。点击任意 PHP 扩展名称,页面会向 Worker 发送请求,Worker 调用 Wasm `runtime.getExtensionInfo()` 导出函数,最后由 JavaScript 解析返回的 JSON 并渲染扩展详情。
生产构建:

@ -111,6 +111,12 @@
<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">

@ -8,6 +8,7 @@ const elements = Object.fromEntries([
].map((id) => [id, document.getElementById(id)]));
let worker = null;
let selectedExtension = '';
function parseArguments(source) {
const args = [];
@ -39,9 +40,13 @@ function renderReport(report) {
value('platform-value', report.runtime.platform);
value('extension-count', `${report.runtime.extensions.length} 个内置扩展`);
elements['extension-list'].replaceChildren(...report.runtime.extensions.map((extension) => {
const badge = document.createElement('span');
badge.textContent = extension;
return badge;
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);
@ -58,11 +63,98 @@ function renderReport(report) {
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…';
@ -75,18 +167,29 @@ function run() {
stderr += data.data;
} else if (data.type === 'error') {
stderr += `${data.error}\n`;
} else if (data.type === 'exit') {
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(stdout));
setStatus(data.code === 0 ? 'success' : 'error', data.code === 0 ? 'Completed' : `Exit ${data.code}`);
renderReport(JSON.parse(data.json));
setStatus('success', 'Ready for JS calls');
} catch (error) {
setStatus('error', `Invalid output · ${data.code}`);
setStatus('error', 'Invalid export result');
elements.output.textContent += `\n\nUI parse error: ${error.message}`;
}
worker.terminate();
worker = null;
} 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;
}
};
@ -121,4 +224,8 @@ async function resetStorage() {
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();

@ -1,8 +1,10 @@
name: wasm-hello
mode: bin
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

@ -6,16 +6,17 @@ use native_types;
final class WasiDemo
{
public static function report(int $argc, array $argv, string $stdin): array
public static function report(array $arguments, string $greeting, string $stdin): array
{
$greeting = getenv('DEMO_GREETING');
if ($greeting === false) {
if ($greeting === '') {
$greeting = 'Hello from the WASI environment';
}
$argv = array_merge(['typephp.wasm'], $arguments);
return [
'runtime' => [
'php' => PHP_VERSION,
'php' => phpversion(),
'platform' => php_uname(),
'integerBits' => PHP_INT_SIZE * 8,
'extensions' => get_loaded_extensions(),
@ -30,7 +31,7 @@ final class WasiDemo
'token' => bin2hex(random_bytes(8)),
],
'input' => [
'argc' => $argc,
'argc' => count($argv),
'argv' => $argv,
'greeting' => $greeting,
'stdin' => trim($stdin),
@ -45,6 +46,31 @@ final class WasiDemo
];
}
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');

@ -2,9 +2,22 @@
declare(strict_types=1);
function main(int $argc, array $argv): void
#[WasmExport(name: 'get-demo-report')]
function getDemoReport(string $argumentsJson, string $greeting, string $stdin): string
{
$stdin = stream_get_contents(STDIN);
$report = WasiDemo::report($argc, $argv, $stdin === false ? '' : $stdin);
echo json_encode($report, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
$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,
);
}

@ -61,8 +61,28 @@ dl { margin: 22px 0 0; } dl div { display: grid; grid-template-columns: 92px 1fr
.extensions-panel { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 22px; margin-top: 16px; padding: 27px; }
.extensions-panel > strong { color: #79e8ff; font: 700 .75rem ui-monospace, monospace; }
.extension-list { display: flex; grid-column: 1 / -1; flex-wrap: wrap; gap: 8px; padding-top: 20px; border-top: 1px solid #ffffff0b; }
.extension-list span { padding: 7px 11px; border: 1px solid #62daf329; border-radius: 99px; background: #38c5e70c; color: #a9c9dc; font: 650 .7rem ui-monospace, monospace; }
.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 { grid-template-columns: 1fr; } }
@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; } }

@ -8,6 +8,11 @@ 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 {
@ -86,20 +91,14 @@ async function saveFileData(storageName, fileData) {
}
}
self.onmessage = async ({ data }) => {
if (data?.type !== 'run') {
return;
}
let exitCode = 0;
let fileData;
const persistent = data.persistent === true;
const storageName = String(data.storageName || 'typephp-wasi-filesystem.json');
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 || '')));
@ -118,24 +117,38 @@ self.onmessage = async ({ data }) => {
});
const { instantiate } = await import('./generated/program.js');
const component = await instantiate(null, wasi.getImportObject());
await component.run.run();
} catch (error) {
if (error?.exitError) {
exitCode = Number(error.code || 0);
} else {
self.postMessage({ type: 'error', error: error?.stack || String(error) });
exitCode = 1;
}
} finally {
if (persistent && fileData) {
try {
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) });
exitCode = 1;
}
}
self.postMessage({ type: 'exit', code: exitCode });
self.close();
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,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";
}

@ -51,6 +51,9 @@ YAML);
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'));
}
@ -67,6 +70,26 @@ YAML);
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

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

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

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

@ -228,7 +228,9 @@ trait SourcePipelineTrait
$this->stop('No valid source file found');
}
if ($this->isBuildModeLib()) {
// A WASI library publishes WIT/Component exports rather than a native
// TypePHP shared-library ABI, so a PHP import stub would be misleading.
if ($this->isBuildModeLib() && !$this->isWasiTarget()) {
$this->genLibraryImportStub($files);
}

@ -13,6 +13,9 @@ final readonly class WasiProjectConfig
public ?string $output,
public ?string $browserDir,
public string $profile,
public string $mode,
public string $package,
public string $world,
) {
}
@ -47,7 +50,16 @@ final readonly class WasiProjectConfig
$buildDir = self::absolutePath($buildDir, $workingDirectory);
if (!is_array($config)) {
return new self($realInput, $buildDir, null, null, self::normalizeProfile($cliProfile ?? 'component'));
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');
@ -55,11 +67,20 @@ final readonly class WasiProjectConfig
throw new RuntimeException('A WASI project must target wasm32-wasip2');
}
$mode = strtolower((string) ($config['mode'] ?? $config['build-mode'] ?? $config['type'] ?? 'bin'));
if (!in_array($mode, ['bin', 'binary', 'cli'], true)) {
throw new RuntimeException('A WASI project must use bin mode');
$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);
@ -69,10 +90,6 @@ final readonly class WasiProjectConfig
throw new RuntimeException('A WASI project output must use the .wasm extension');
}
} else {
$name = trim((string) ($config['name'] ?? 'app'));
if ($name === '' || str_contains($name, '/') || str_contains($name, '\\')) {
throw new RuntimeException('A WASI project name must be a non-empty file name');
}
$output = $projectDir . DIRECTORY_SEPARATOR . $name . '.wasm';
}
@ -90,7 +107,17 @@ final readonly class WasiProjectConfig
? self::absolutePath((string) $browserPath, $projectDir)
: null;
return new self($realInput, $buildDir, $output, $browserDir, $profile);
$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

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

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

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

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

@ -18,6 +18,7 @@ use TypePhp\Build\NativeCommandOptionsTrait;
use TypePhp\Build\NativeBuilder;
use TypePhp\Build\PrecompiledHeaderManager;
use TypePhp\Build\SourcePipelineTrait;
use TypePhp\Build\WasmInterfaceGenerator;
use TypePhp\Config\ProjectYamlLoader;
use TypePhp\Diagnostics\CompileTimeAttributeDiagnostic;
use TypePhp\Build\ResourceCompilationTrait;
@ -1756,6 +1757,50 @@ CODE;
return $this->buildMode;
}
/** Write the public WIT contract and PHPX host-bindgen manifest. */
public function writeWasmInterface(
string $manifestFile,
string $witFile,
string $adapterFile,
string $asyncExportsFile,
string $package,
string $world,
): void
{
if (!$this->isWasiTarget() || !$this->isBuildModeLib()) {
$this->error('WIT interfaces can only be generated for a WASI library build');
}
try {
$generator = new WasmInterfaceGenerator();
$manifest = $generator->buildManifest(
$this->symbols->functions(),
$package,
$world,
fn (FunctionDef $function): string => self::PREFIX
. $this->getNativeName($function->name, $function->namespace),
);
foreach (array_unique([
dirname($manifestFile),
dirname($witFile),
dirname($adapterFile),
dirname($asyncExportsFile),
]) as $directory) {
if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) {
throw new \RuntimeException("Unable to create WASM interface directory: {$directory}");
}
}
$json = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
if (file_put_contents($manifestFile, $json . PHP_EOL) === false
|| file_put_contents($witFile, $generator->renderWit($manifest)) === false
|| file_put_contents($adapterFile, $generator->renderCppAdapter($manifest)) === false
|| file_put_contents($asyncExportsFile, $generator->renderJcoAsyncExports($manifest)) === false) {
throw new \RuntimeException('Unable to write the generated WASM interface');
}
} catch (\Throwable $exception) {
$this->error($exception->getMessage());
}
}
public function getArgInfoStubFilename(string $stubFile): string
{
$rs = str_replace(['.stub.php', '.php'], '', $stubFile);

@ -35,6 +35,31 @@ function main(int $argc, array $argv): void
// 生成 C++ 文件
$sourceFiles = $translator->convert($files);
$wasmManifest = getenv('TYPEPHP_WASM_INTERFACE_MANIFEST');
if (is_string($wasmManifest) && $wasmManifest !== '') {
$wasmWit = getenv('TYPEPHP_WASM_INTERFACE_WIT');
$wasmAdapter = getenv('TYPEPHP_WASM_INTERFACE_ADAPTER');
$wasmAsyncExports = getenv('TYPEPHP_WASM_INTERFACE_ASYNC_EXPORTS');
$wasmPackage = getenv('TYPEPHP_WASM_PACKAGE');
$wasmWorld = getenv('TYPEPHP_WASM_WORLD');
if (!is_string($wasmWit) || $wasmWit === ''
|| !is_string($wasmAdapter) || $wasmAdapter === ''
|| !is_string($wasmAsyncExports) || $wasmAsyncExports === ''
|| !is_string($wasmPackage) || $wasmPackage === ''
|| !is_string($wasmWorld) || $wasmWorld === '') {
throw new RuntimeException('Incomplete internal WASM interface configuration');
}
$translator->writeWasmInterface(
$wasmManifest,
$wasmWit,
$wasmAdapter,
$wasmAsyncExports,
$wasmPackage,
$wasmWorld,
);
$sourceFiles[] = $wasmAdapter;
}
// --dry 模式:仅生成 C++ 代码,不执行编译
if ($translator->isDryRun()) {
$buildDir = $translator->getBuildDir();
@ -177,6 +202,9 @@ function compileWasmProgram(array $argv): void
$environment['TYPEPHP_WASI_CLANG_VERSION'] = $tools['clang-version'];
$environment['TYPEPHP_WASMTIME_VERSION'] = $tools['wasmtime-version'];
$environment['TYPEPHP_WASM_PROGRAM_BUILD_DIR'] = $project->buildDir;
$environment['TYPEPHP_WASM_MODE'] = $project->mode;
$environment['TYPEPHP_WASM_PACKAGE'] = $project->package;
$environment['TYPEPHP_WASM_WORLD'] = $project->world;
$compilerExecutable = realpath($argv[0]);
if ($compilerExecutable === false || !is_executable($compilerExecutable)) {
fwrite(STDERR, "Unable to resolve the current TypePHP compiler executable: {$argv[0]}\n");
@ -192,6 +220,29 @@ function compileWasmProgram(array $argv): void
fwrite(STDERR, "Unable to locate PHPX: {$exception->getMessage()}\n");
exit(1);
}
if ($project->mode === 'library') {
$hostOs = match (PHP_OS_FAMILY) {
'Linux' => 'linux',
'Darwin' => 'macos',
'Windows' => 'windows',
default => strtolower(PHP_OS_FAMILY),
};
$hostArch = strtolower(php_uname('m'));
$hostArch = match ($hostArch) {
'amd64', 'x64' => 'x86_64',
'arm64' => $hostOs === 'linux' ? 'aarch64' : 'arm64',
default => $hostArch,
};
$bindgen = $phpxDir . DIRECTORY_SEPARATOR . 'wasm' . DIRECTORY_SEPARATOR . 'bin'
. DIRECTORY_SEPARATOR . $hostOs . '-' . $hostArch . DIRECTORY_SEPARATOR . 'wit-bindgen'
. ($hostOs === 'windows' ? '.exe' : '');
if (!is_file($bindgen)) {
fwrite(STDERR, "PHPX bundled WIT binding generator is missing: {$bindgen}\n");
fwrite(STDERR, "Install the matching PHPX package; installing wit-bindgen separately is not required.\n");
exit(1);
}
$environment['TYPEPHP_WIT_BINDGEN'] = $bindgen;
}
$command = [$builder, $project->input, $project->output ?? '-', $phpxDir, $compilerExecutable];
$process = proc_open(

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

@ -38,6 +38,66 @@ 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.
`wit-bindgen`, Autoconf, Bison, re2c, and the PHP/PHPX source trees are SDK
producer dependencies only. They are never searched for or invoked by
`tpc --wasm`.
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>/`.
## 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.

@ -47,6 +47,12 @@ 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}"
}
@ -65,10 +71,28 @@ mkdir -p "${generated_dir}" "$(dirname "${output}")"
# Convert first so target-specific source errors are reported before validating
# and linking the separately installed WASI SDK.
TYPEPHP_WASM_INTERNAL_COMPILE=1 TYPEPHP_GENERATED_SOURCE_LIST="${generated_source_list}" "${typephp_compiler}" "${input}" \
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
@ -132,6 +156,33 @@ include_flags=(
-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
@ -139,10 +190,19 @@ for source in "${generated_sources[@]}"; do
exit 1
fi
object=${source%.cc}.o
"${wasi_cxx}" "${compile_flags[@]}" "${include_flags[@]}" -c "${source}" -o "${object}"
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
@ -155,6 +215,7 @@ done
-std=c++17 \
-fwasm-exceptions \
"${generated_objects[@]}" \
"${binding_objects[@]}" \
-Wl,--whole-archive \
"${wasi_library_dir}/libphpx.a" \
-Wl,--no-whole-archive \
@ -165,6 +226,7 @@ done
"${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}"
@ -186,6 +248,16 @@ if [[ "${TYPEPHP_WASM_BROWSER:-0}" == 1 ]]; then
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 \

Loading…
Cancel
Save