diff --git a/docs/README.md b/docs/README.md index 63b77532..77f91603 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) diff --git a/docs/TYPEPHP_WASM_IMPLEMENTATION_PLAN.md b/docs/TYPEPHP_WASM_IMPLEMENTATION_PLAN.md index e556f7db..634aa11e 100644 --- a/docs/TYPEPHP_WASM_IMPLEMENTATION_PLAN.md +++ b/docs/TYPEPHP_WASM_IMPLEMENTATION_PLAN.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. 核心结论 diff --git a/docs/TYPEPHP_WASI_BUILD.md b/docs/WASI_BUILD.md similarity index 57% rename from docs/TYPEPHP_WASI_BUILD.md rename to docs/WASI_BUILD.md index daf5d77a..3324daa0 100644 --- a/docs/TYPEPHP_WASI_BUILD.md +++ b/docs/WASI_BUILD.md @@ -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 result; +``` + +浏览器中对应的调用为: + +```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 暴露为“可编译但链接失败”的接口。 diff --git a/examples/wasm-hello/README.md b/examples/wasm-hello/README.md index a0756833..e4cf3d4d 100644 --- a/examples/wasm-hello/README.md +++ b/examples/wasm-hello/README.md @@ -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 并渲染扩展详情。 生产构建: diff --git a/examples/wasm-hello/index.html b/examples/wasm-hello/index.html index fa5dd807..29460c74 100644 --- a/examples/wasm-hello/index.html +++ b/examples/wasm-hello/index.html @@ -111,6 +111,12 @@
由 get_loaded_extensions() 动态读取
+
+
+ 选择一个扩展 + 点击上方扩展名称,由 JavaScript 调用 Wasm 导出函数读取详情。 +
+
diff --git a/examples/wasm-hello/main.js b/examples/wasm-hello/main.js index 9bffb482..4d1252d2 100644 --- a/examples/wasm-hello/main.js +++ b/examples/wasm-hello/main.js @@ -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 = '正在调用 Wasm 导出函数…'; + 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(); diff --git a/examples/wasm-hello/project.yml b/examples/wasm-hello/project.yml index ce2dc428..863c1d40 100644 --- a/examples/wasm-hello/project.yml +++ b/examples/wasm-hello/project.yml @@ -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 diff --git a/examples/wasm-hello/src/WasiDemo.php b/examples/wasm-hello/src/WasiDemo.php index 71d3a290..02e49324 100644 --- a/examples/wasm-hello/src/WasiDemo.php +++ b/examples/wasm-hello/src/WasiDemo.php @@ -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'); diff --git a/examples/wasm-hello/src/main.php b/examples/wasm-hello/src/main.php index 9a27e451..041729b0 100644 --- a/examples/wasm-hello/src/main.php +++ b/examples/wasm-hello/src/main.php @@ -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, + ); } diff --git a/examples/wasm-hello/style.css b/examples/wasm-hello/style.css index 8b168a43..85b0307c 100644 --- a/examples/wasm-hello/style.css +++ b/examples/wasm-hello/style.css @@ -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; } } diff --git a/examples/wasm-hello/typephp-worker.mjs b/examples/wasm-hello/typephp-worker.mjs index e006a18c..9f03e122 100644 --- a/examples/wasm-hello/typephp-worker.mjs +++ b/examples/wasm-hello/typephp-worker.mjs @@ -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; + 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); } - } finally { - if (persistent && fileData) { - try { - await saveFileData(storageName, fileData); - } catch (error) { - self.postMessage({ type: 'error', error: error?.stack || String(error) }); - exitCode = 1; - } - } - self.postMessage({ type: 'exit', code: exitCode }); - self.close(); + 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) }); + }); } }; diff --git a/phpunit/code/wasm-export-invalid-method.php b/phpunit/code/wasm-export-invalid-method.php new file mode 100644 index 00000000..cf9886b1 --- /dev/null +++ b/phpunit/code/wasm-export-invalid-method.php @@ -0,0 +1,9 @@ +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 diff --git a/phpunit/src/Build/WasmInterfaceGeneratorTest.php b/phpunit/src/Build/WasmInterfaceGeneratorTest.php new file mode 100644 index 00000000..ae9b6085 --- /dev/null +++ b/phpunit/src/Build/WasmInterfaceGeneratorTest.php @@ -0,0 +1,89 @@ +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;', + $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', + ); + } +} diff --git a/phpunit/src/CompileTimeAttributeRegistryTest.php b/phpunit/src/CompileTimeAttributeRegistryTest.php index 09395985..a5a31a2e 100644 --- a/phpunit/src/CompileTimeAttributeRegistryTest.php +++ b/phpunit/src/CompileTimeAttributeRegistryTest.php @@ -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( diff --git a/phpunit/src/WasmExportAttributeTest.php b/phpunit/src/WasmExportAttributeTest.php new file mode 100644 index 00000000..f4f337d8 --- /dev/null +++ b/phpunit/src/WasmExportAttributeTest.php @@ -0,0 +1,21 @@ +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'); + } +} diff --git a/src/Build/SourcePipelineTrait.php b/src/Build/SourcePipelineTrait.php index 1d74964f..2afae88b 100644 --- a/src/Build/SourcePipelineTrait.php +++ b/src/Build/SourcePipelineTrait.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); } diff --git a/src/Build/WasiProjectConfig.php b/src/Build/WasiProjectConfig.php index 55970b1f..1935ba56 100644 --- a/src/Build/WasiProjectConfig.php +++ b/src/Build/WasiProjectConfig.php @@ -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 diff --git a/src/Build/WasmInterfaceGenerator.php b/src/Build/WasmInterfaceGenerator.php new file mode 100644 index 00000000..bb4388e7 --- /dev/null +++ b/src/Build/WasmInterfaceGenerator.php @@ -0,0 +1,470 @@ + $functions + * @return array{package:string, world:string, interface:string, functions:list>} + */ + 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>} $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;'; + $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 $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 $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 ', + '#include ', + '#include ', + '#include ', + '#include ', + '#include ', + '#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 $parameter */ + private function cppArgument(array $parameter, string $name, string $base, bool $nullable): string + { + if (!$nullable) { + return match ($base) { + 'string' => 'php::Str(reinterpret_cast(' . $name . '->ptr), ' . $name . '->len)', + default => $name, + }; + } + $pointer = 'maybe_' . $name; + $value = match ($base) { + 'string' => 'php::Str(reinterpret_cast(' . $pointer . '->ptr), ' . $pointer . '->len)', + default => '*' . $pointer, + }; + return '(' . $pointer . ' == nullptr ? php::Var(php::null) : php::Var(' . $value . '))'; + } + + /** @return list */ + 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`)"); + } + } +} diff --git a/src/Entity/FunctionDef.php b/src/Entity/FunctionDef.php index 4457cdaa..afb39254 100644 --- a/src/Entity/FunctionDef.php +++ b/src/Entity/FunctionDef.php @@ -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. */ diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 97c20dfb..83820500 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -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, diff --git a/src/Transform/CompileTimeAttributeRegistry.php b/src/Transform/CompileTimeAttributeRegistry.php index 1c2db082..f85bdc86 100644 --- a/src/Transform/CompileTimeAttributeRegistry.php +++ b/src/Transform/CompileTimeAttributeRegistry.php @@ -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); } diff --git a/src/Translator.php b/src/Translator.php index 28203e38..d3426cc3 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -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); diff --git a/src/compiler.php b/src/compiler.php index 947a57d9..e51a43a2 100644 --- a/src/compiler.php +++ b/src/compiler.php @@ -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( diff --git a/src/polyfills.php b/src/polyfills.php index 2919f6b5..fcca006f 100644 --- a/src/polyfills.php +++ b/src/polyfills.php @@ -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 { diff --git a/wasm/README.md b/wasm/README.md index 0a5c12d3..41f8f07b 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -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 +`/wasm/bin/-/`. + +## 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. diff --git a/wasm/build-typephp-program.sh b/wasm/build-typephp-program.sh index cb6d5adf..c917c8e2 100755 --- a/wasm/build-typephp-program.sh +++ b/wasm/build-typephp-program.sh @@ -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 \