- Add WasiProjectConfig class for managing WASI project configurations - Implement --wasm=[browser|component] option parsing in compiler CLI - Add automatic WASI detection based on project.yml files - Integrate Jco for browser component generation - Update documentation to reflect WASI 0.2 (Preview 2) support - Add example wasm-hello project with browser demo - Create index.html and main.js for browser-based WASI execution - Implement OPFS persistence for virtual filesystem in browser - Add type-safe environment variable handling for WASI builds - Update compiler help text to show new WASM options - Replace WASI Preview 1 references with WASI 0.2 terminologypull/46/head
parent
9992793a02
commit
07c7d4b84a
25 changed files with 5588 additions and 46 deletions
@ -0,0 +1,24 @@ |
||||
## 编译 |
||||
|
||||
```shell |
||||
./tpc --wasm test.php |
||||
``` |
||||
|
||||
编译成功后生成 WASI 0.2 Component `test.wasm` 和 `test.browser/` Jco 模块。WASI 0.1 不受支持。 |
||||
生成的 C++ 源码默认写入 `build/`,也可以通过 `--build-dir <directory>` 指定。 |
||||
|
||||
## 执行 |
||||
|
||||
```shell |
||||
wasmtime test.wasm |
||||
``` |
||||
|
||||
## Chrome |
||||
|
||||
```shell |
||||
cd test.browser |
||||
npm install |
||||
npm run dev |
||||
``` |
||||
|
||||
完整浏览器 Demo 位于仓库 `examples/wasm-hello/`,并使用 `project.yml` 构建。TypePHP 在专用 Worker 中执行;默认文件系统驻留内存,可显式启用 OPFS 快照持久化。网络 socket、进程、shell 和信号在 WASI 目标下明确不支持。 |
||||
@ -0,0 +1,5 @@ |
||||
/build/ |
||||
/component/ |
||||
/dist/ |
||||
/generated/ |
||||
/node_modules/ |
||||
@ -0,0 +1,61 @@ |
||||
# TypePHP WASI Browser Lab |
||||
|
||||
这是一个由 `project.yml` 构建的完整 TypePHP/WASI 0.2 浏览器应用。PHP 代码编译成一个自包含的 Component,Jco 将同一个 Component 转译为浏览器 ESM,页面通过 module Worker 加载它。 |
||||
|
||||
Demo 展示以下已支持能力: |
||||
|
||||
- 命令行参数、环境变量与标准输入/输出 |
||||
- WASI wall clock 和 PHP `time()` / `date()` |
||||
- 安全随机数 `random_int()` / `random_bytes()` |
||||
- 内存文件系统,以及可选的 OPFS 快照持久化 |
||||
- PHP 8.5 runtime 信息 |
||||
- TypePHP 语言级 BigInt、Decimal、BigFloat 高精度计算 |
||||
|
||||
网络、进程、shell、信号、Fiber 和 Generator 明确不支持。 |
||||
|
||||
## 构建 |
||||
|
||||
先确保 WASI SDK 和 Wasmtime 已加入 `PATH`,然后在本目录执行: |
||||
|
||||
```bash |
||||
npm ci |
||||
npm run wasm |
||||
``` |
||||
|
||||
等价的仓库根目录命令是: |
||||
|
||||
```bash |
||||
php bin/tpc.php examples/wasm-hello/project.yml |
||||
``` |
||||
|
||||
`project.yml` 控制全部项目路径: |
||||
|
||||
- `sources: src`:TypePHP 源码 |
||||
- `build-dir: build`:生成的 C++ 与目标文件 |
||||
- `output: component/wasm-hello.wasm`:WASI 0.2 Component |
||||
- `wasm: true`:项目自动进入 WASI browser 构建,无需命令行 `--wasm` |
||||
- `wasm-browser-dir: generated`:Jco 浏览器模块 |
||||
|
||||
Jco 是本项目的开发依赖。先执行 `npm ci`,之后通过 `npm run wasm` 构建时,npm 会自动把本地 `node_modules/.bin/jco` 加入 `PATH`。 |
||||
|
||||
若只需要供 Wasmtime 使用的 Component,可以绕过 Jco: |
||||
|
||||
```bash |
||||
php ../../bin/tpc.php project.yml --wasm=component |
||||
``` |
||||
|
||||
## 浏览器运行 |
||||
|
||||
```bash |
||||
npm run dev |
||||
``` |
||||
|
||||
打开终端显示的本地地址。可以修改参数、环境变量和 stdin 后重复运行;勾选 OPFS 后,PHP 写入虚拟文件系统的运行次数会跨页面刷新保存。 |
||||
|
||||
生产构建: |
||||
|
||||
```bash |
||||
npm run build |
||||
``` |
||||
|
||||
`src/` 是 TypePHP 应用,`typephp-worker.mjs` 是浏览器 WASI host,`main.js` 和 `style.css` 负责交互界面。`build/`、`component/`、`dist/`、`generated/` 均为可重新生成的输出。 |
||||
@ -0,0 +1,114 @@ |
||||
<!doctype html> |
||||
<html lang="zh-CN"> |
||||
<head> |
||||
<meta charset="UTF-8"> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1"> |
||||
<meta name="theme-color" content="#07111f"> |
||||
<title>TypePHP · WASI 0.2 Browser Lab</title> |
||||
<link rel="stylesheet" href="/style.css"> |
||||
</head> |
||||
<body> |
||||
<div class="ambient ambient-one"></div> |
||||
<div class="ambient ambient-two"></div> |
||||
<main class="shell"> |
||||
<header class="hero"> |
||||
<div> |
||||
<p class="eyebrow"><span class="pulse"></span> WASI 0.2 · Preview 2</p> |
||||
<h1>TypePHP<br><span>Browser Lab</span></h1> |
||||
<p class="lede">同一个由 PHP、PHPX 与 TypePHP 静态链接的 Component,在浏览器 Worker 中直接执行。</p> |
||||
</div> |
||||
<div class="runtime-orbit" aria-hidden="true"> |
||||
<div class="orbit-ring"></div> |
||||
<div class="runtime-core">PHP<br><strong>AOT</strong></div> |
||||
<span class="satellite one">WASI</span> |
||||
<span class="satellite two">Wasm</span> |
||||
<span class="satellite three">C++17</span> |
||||
</div> |
||||
</header> |
||||
|
||||
<section class="control-panel panel"> |
||||
<div class="panel-heading"> |
||||
<div> |
||||
<p class="section-label">Launch configuration</p> |
||||
<h2>配置本次运行</h2> |
||||
</div> |
||||
<div id="status" class="status idle"><span></span>Ready</div> |
||||
</div> |
||||
<div class="form-grid"> |
||||
<label> |
||||
<span>命令行参数</span> |
||||
<input id="args" value="Ada 42 --browser" autocomplete="off"> |
||||
<small>空格分隔,支持单引号和双引号</small> |
||||
</label> |
||||
<label> |
||||
<span>环境变量</span> |
||||
<input id="env" value="DEMO_GREETING=Hello from Chrome" autocomplete="off"> |
||||
<small>每行一个 KEY=VALUE</small> |
||||
</label> |
||||
<label class="wide"> |
||||
<span>标准输入</span> |
||||
<textarea id="stdin" rows="2">A message sent through WASI stdin.</textarea> |
||||
</label> |
||||
</div> |
||||
<div class="actions"> |
||||
<label class="switch-row"> |
||||
<input id="persistent" type="checkbox" checked> |
||||
<span class="switch"><i></i></span> |
||||
<span><strong>OPFS 持久化</strong><small>刷新页面后保留虚拟文件系统</small></span> |
||||
</label> |
||||
<div class="button-row"> |
||||
<button id="reset" class="button secondary" type="button">清空存储</button> |
||||
<button id="run" class="button primary" type="button"><span>运行 TypePHP</span><b>→</b></button> |
||||
</div> |
||||
</div> |
||||
</section> |
||||
|
||||
<section class="results"> |
||||
<article class="feature-card accent-cyan"> |
||||
<div class="icon">⌁</div><p>Runtime</p><h3 id="runtime-value">等待运行</h3><small id="platform-value">—</small> |
||||
</article> |
||||
<article class="feature-card accent-violet"> |
||||
<div class="icon">◷</div><p>Clock</p><h3 id="clock-value">—</h3><small>WASI wall clock</small> |
||||
</article> |
||||
<article class="feature-card accent-amber"> |
||||
<div class="icon">✦</div><p>Secure random</p><h3 id="random-value">—</h3><small id="token-value">WASI random</small> |
||||
</article> |
||||
<article class="feature-card accent-green"> |
||||
<div class="icon">▱</div><p>Filesystem</p><h3 id="filesystem-value">—</h3><small id="files-value">Memory FS + OPFS snapshot</small> |
||||
</article> |
||||
</section> |
||||
|
||||
<section class="detail-grid"> |
||||
<article class="panel data-panel"> |
||||
<p class="section-label">Guest inputs</p> |
||||
<h2>参数、环境与标准流</h2> |
||||
<dl> |
||||
<div><dt>argv</dt><dd id="argv-value">—</dd></div> |
||||
<div><dt>env</dt><dd id="env-value">—</dd></div> |
||||
<div><dt>stdin</dt><dd id="stdin-value">—</dd></div> |
||||
</dl> |
||||
</article> |
||||
<article class="panel precision-panel"> |
||||
<p class="section-label">Language-level numeric types</p> |
||||
<h2>高精度计算</h2> |
||||
<dl> |
||||
<div><dt>BigInt</dt><dd id="bigint-value">—</dd></div> |
||||
<div><dt>Decimal</dt><dd id="decimal-value">—</dd></div> |
||||
<div><dt>BigFloat</dt><dd id="bigfloat-value">—</dd></div> |
||||
</dl> |
||||
</article> |
||||
</section> |
||||
|
||||
<details class="console panel"> |
||||
<summary><span>Raw component output</span><kbd>stdout / stderr</kbd></summary> |
||||
<pre id="output">尚未运行。</pre> |
||||
</details> |
||||
|
||||
<footer> |
||||
<span>TypePHP → C++17 → WASI 0.2 Component</span> |
||||
<span>运行于独立 ES module Worker · 网络能力关闭</span> |
||||
</footer> |
||||
</main> |
||||
<script type="module" src="/main.js"></script> |
||||
</body> |
||||
</html> |
||||
@ -0,0 +1,115 @@ |
||||
const storageName = 'typephp-wasi-demo-filesystem.json'; |
||||
const elements = Object.fromEntries([ |
||||
'args', 'env', 'stdin', 'persistent', 'run', 'reset', 'status', 'output', |
||||
'runtime-value', 'platform-value', 'clock-value', 'random-value', 'token-value', |
||||
'filesystem-value', 'files-value', 'argv-value', 'env-value', 'stdin-value', |
||||
'bigint-value', 'decimal-value', 'bigfloat-value', |
||||
].map((id) => [id, document.getElementById(id)])); |
||||
|
||||
let worker = null; |
||||
|
||||
function parseArguments(source) { |
||||
const args = []; |
||||
const pattern = /"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^\s]+)/g; |
||||
for (const match of source.matchAll(pattern)) { |
||||
args.push((match[1] ?? match[2] ?? match[3]).replace(/\\([\\"'])/g, '$1')); |
||||
} |
||||
return args; |
||||
} |
||||
|
||||
function parseEnvironment(source) { |
||||
return Object.fromEntries(source.split(/\r?\n/).flatMap((line) => { |
||||
const separator = line.indexOf('='); |
||||
return separator > 0 ? [[line.slice(0, separator).trim(), line.slice(separator + 1)]] : []; |
||||
})); |
||||
} |
||||
|
||||
function setStatus(kind, label) { |
||||
elements.status.className = `status ${kind}`; |
||||
elements.status.lastChild.textContent = label; |
||||
} |
||||
|
||||
function value(id, content) { |
||||
elements[id].textContent = content === '' ? '(空)' : String(content ?? '—'); |
||||
} |
||||
|
||||
function renderReport(report) { |
||||
value('runtime-value', `PHP ${report.runtime.php}`); |
||||
value('platform-value', report.runtime.platform); |
||||
value('clock-value', report.clock.iso8601); |
||||
value('random-value', report.random.integer); |
||||
value('token-value', report.random.token); |
||||
value('filesystem-value', `第 ${report.filesystem.run} 次运行`); |
||||
value('files-value', report.filesystem.files.join(' · ')); |
||||
value('argv-value', report.input.argv.join(' ')); |
||||
value('env-value', report.input.greeting); |
||||
value('stdin-value', report.input.stdin); |
||||
value('bigint-value', report.precision.bigint); |
||||
value('decimal-value', report.precision.decimal); |
||||
value('bigfloat-value', report.precision.bigfloat); |
||||
} |
||||
|
||||
function run() { |
||||
worker?.terminate(); |
||||
worker = new Worker(new URL('./typephp-worker.mjs', import.meta.url), { type: 'module' }); |
||||
let stdout = ''; |
||||
let stderr = ''; |
||||
|
||||
elements.run.disabled = true; |
||||
elements.output.textContent = '正在实例化 WASI 0.2 Component…'; |
||||
setStatus('running', 'Running'); |
||||
|
||||
worker.onmessage = ({ data }) => { |
||||
if (data.type === 'stdout') { |
||||
stdout += data.data; |
||||
} else if (data.type === 'stderr') { |
||||
stderr += data.data; |
||||
} else if (data.type === 'error') { |
||||
stderr += `${data.error}\n`; |
||||
} else if (data.type === 'exit') { |
||||
elements.run.disabled = false; |
||||
elements.output.textContent = [stdout, stderr].filter(Boolean).join('\n--- stderr ---\n'); |
||||
try { |
||||
renderReport(JSON.parse(stdout)); |
||||
setStatus(data.code === 0 ? 'success' : 'error', data.code === 0 ? 'Completed' : `Exit ${data.code}`); |
||||
} catch (error) { |
||||
setStatus('error', `Invalid output · ${data.code}`); |
||||
elements.output.textContent += `\n\nUI parse error: ${error.message}`; |
||||
} |
||||
worker.terminate(); |
||||
worker = null; |
||||
} |
||||
}; |
||||
|
||||
worker.onerror = (event) => { |
||||
elements.run.disabled = false; |
||||
setStatus('error', 'Worker error'); |
||||
elements.output.textContent = event.message; |
||||
}; |
||||
|
||||
worker.postMessage({ |
||||
type: 'run', |
||||
args: parseArguments(elements.args.value), |
||||
env: parseEnvironment(elements.env.value), |
||||
stdin: elements.stdin.value, |
||||
persistent: elements.persistent.checked, |
||||
storageName, |
||||
}); |
||||
} |
||||
|
||||
async function resetStorage() { |
||||
if (!navigator.storage?.getDirectory) { |
||||
setStatus('error', 'OPFS unavailable'); |
||||
return; |
||||
} |
||||
const root = await navigator.storage.getDirectory(); |
||||
await root.removeEntry(storageName).catch((error) => { |
||||
if (error.name !== 'NotFoundError') throw error; |
||||
}); |
||||
setStatus('idle', 'Storage cleared'); |
||||
value('filesystem-value', '等待重新运行'); |
||||
} |
||||
|
||||
elements.run.addEventListener('click', run); |
||||
elements.reset.addEventListener('click', () => resetStorage().catch((error) => setStatus('error', error.message))); |
||||
run(); |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,18 @@ |
||||
{ |
||||
"name": "typephp-wasm-hello", |
||||
"version": "0.1.0", |
||||
"private": true, |
||||
"type": "module", |
||||
"scripts": { |
||||
"wasm": "php ../../bin/tpc.php project.yml", |
||||
"dev": "vite", |
||||
"build": "vite build" |
||||
}, |
||||
"dependencies": { |
||||
"@bytecodealliance/preview2-shim": "0.18.0" |
||||
}, |
||||
"devDependencies": { |
||||
"@bytecodealliance/jco": "^1.27.0", |
||||
"vite": "^7.1.0" |
||||
} |
||||
} |
||||
@ -0,0 +1,9 @@ |
||||
name: wasm-hello |
||||
mode: bin |
||||
target-platform: wasm32-wasip2 |
||||
build-dir: build |
||||
output: component/wasm-hello.wasm |
||||
sources: |
||||
- src |
||||
wasm: true |
||||
wasm-browser-dir: generated |
||||
@ -0,0 +1,97 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
use native_types; |
||||
|
||||
final class WasiDemo |
||||
{ |
||||
public static function report(int $argc, array $argv, string $stdin): array |
||||
{ |
||||
$greeting = getenv('DEMO_GREETING'); |
||||
if ($greeting === false) { |
||||
$greeting = 'Hello from the WASI environment'; |
||||
} |
||||
|
||||
return [ |
||||
'runtime' => [ |
||||
'php' => PHP_VERSION, |
||||
'platform' => php_uname(), |
||||
'integerBits' => PHP_INT_SIZE * 8, |
||||
], |
||||
'clock' => [ |
||||
'timestamp' => time(), |
||||
'iso8601' => date('Y-m-d H:i:s T'), |
||||
'microtime' => microtime(true), |
||||
], |
||||
'random' => [ |
||||
'integer' => random_int(100000, 999999), |
||||
'token' => bin2hex(random_bytes(8)), |
||||
], |
||||
'input' => [ |
||||
'argc' => $argc, |
||||
'argv' => $argv, |
||||
'greeting' => $greeting, |
||||
'stdin' => trim($stdin), |
||||
], |
||||
'filesystem' => self::filesystemReport(), |
||||
'precision' => self::precisionReport(), |
||||
'capabilities' => [ |
||||
'supported' => ['arguments', 'environment', 'stdin/stdout/stderr', 'clock', 'random', 'filesystem'], |
||||
'disabled' => ['network', 'process', 'signals', 'shell', 'Fiber', 'Generator'], |
||||
], |
||||
]; |
||||
} |
||||
|
||||
private static function filesystemReport(): array |
||||
{ |
||||
$directory = '/workspace'; |
||||
if (!is_dir($directory)) { |
||||
mkdir($directory); |
||||
} |
||||
|
||||
$counterFile = $directory . '/run-count.txt'; |
||||
$counter = 0; |
||||
if (file_exists($counterFile)) { |
||||
$counter = (int) trim((string) file_get_contents($counterFile)); |
||||
} |
||||
$counter++; |
||||
file_put_contents($counterFile, (string) $counter); |
||||
|
||||
$messageFile = $directory . '/hello.txt'; |
||||
$message = 'TypePHP wrote this file during browser run #' . $counter; |
||||
file_put_contents($messageFile, $message); |
||||
|
||||
$files = scandir($directory); |
||||
if ($files === false) { |
||||
$files = []; |
||||
} |
||||
$visibleFiles = array_values(array_diff($files, ['.', '..'])); |
||||
|
||||
return [ |
||||
'run' => $counter, |
||||
'readback' => (string) file_get_contents($messageFile), |
||||
'files' => $visibleFiles, |
||||
]; |
||||
} |
||||
|
||||
private static function precisionReport(): array |
||||
{ |
||||
$big = std::bigInt('123456789012345678901234567890'); |
||||
$bigResult = ($big * std::bigInt(1000000) + std::bigInt(42))->toString(); |
||||
|
||||
$price = std::decimal('199.95'); |
||||
$taxRate = std::decimal('0.0825'); |
||||
$decimalResult = ($price * (std::decimal(1) + $taxRate))->toString(); |
||||
|
||||
$pi = std::bigFloat('3.141592653589793238462643383279502884197'); |
||||
$radius = std::bigFloat(12); |
||||
$bigFloatResult = ($pi * $radius * $radius)->toString(); |
||||
|
||||
return [ |
||||
'bigint' => $bigResult, |
||||
'decimal' => $decimalResult, |
||||
'bigfloat' => $bigFloatResult, |
||||
]; |
||||
} |
||||
} |
||||
@ -0,0 +1,10 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
function main(int $argc, array $argv): void |
||||
{ |
||||
$stdin = stream_get_contents(STDIN); |
||||
$report = WasiDemo::report($argc, $argv, $stdin === false ? '' : $stdin); |
||||
echo json_encode($report, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); |
||||
} |
||||
@ -0,0 +1,65 @@ |
||||
:root { |
||||
color-scheme: dark; |
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; |
||||
background: #07111f; |
||||
color: #eaf2ff; |
||||
font-synthesis: none; |
||||
} |
||||
|
||||
* { box-sizing: border-box; } |
||||
body { margin: 0; min-width: 320px; min-height: 100vh; overflow-x: hidden; background: radial-gradient(circle at 50% -20%, #16345a 0, #07111f 42%, #040a13 100%); } |
||||
button, input, textarea { font: inherit; } |
||||
.ambient { position: fixed; width: 34rem; height: 34rem; border-radius: 50%; filter: blur(110px); opacity: .13; pointer-events: none; } |
||||
.ambient-one { top: -12rem; right: -8rem; background: #35e0ff; } |
||||
.ambient-two { bottom: -16rem; left: -10rem; background: #9d6cff; } |
||||
.shell { position: relative; width: min(1180px, calc(100% - 40px)); margin: 0 auto; padding: 64px 0 36px; } |
||||
.hero { display: grid; grid-template-columns: 1.2fr .8fr; align-items: center; min-height: 370px; gap: 40px; } |
||||
.eyebrow, .section-label { margin: 0 0 12px; color: #79e8ff; font-size: .73rem; font-weight: 800; letter-spacing: .17em; text-transform: uppercase; } |
||||
.pulse { display: inline-block; width: 8px; height: 8px; margin-right: 9px; border-radius: 50%; background: #6df5bd; box-shadow: 0 0 0 6px #6df5bd18; } |
||||
h1 { margin: 0; font-size: clamp(4rem, 8vw, 7rem); font-weight: 760; letter-spacing: -.07em; line-height: .82; } |
||||
h1 span { color: transparent; background: linear-gradient(90deg, #77eaff, #a789ff 62%, #f7a95c); background-clip: text; } |
||||
.lede { max-width: 620px; margin: 28px 0 0; color: #9fb1ca; font-size: 1.08rem; line-height: 1.8; } |
||||
.runtime-orbit { position: relative; width: 280px; height: 280px; margin: auto; display: grid; place-items: center; } |
||||
.orbit-ring { position: absolute; inset: 18px; border: 1px solid #83e9ff40; border-radius: 50%; box-shadow: inset 0 0 60px #558cff0c; animation: spin 22s linear infinite; } |
||||
.orbit-ring::before, .orbit-ring::after { content: ""; position: absolute; inset: 34px; border: 1px dashed #a789ff38; border-radius: 50%; } |
||||
.orbit-ring::after { inset: -15px; border-style: solid; border-color: #ffffff0d; } |
||||
.runtime-core { z-index: 1; display: grid; place-items: center; width: 120px; height: 120px; border: 1px solid #79e8ff55; border-radius: 32px; background: linear-gradient(145deg, #163552, #0b1b2c); color: #87ecff; text-align: center; box-shadow: 0 26px 80px #0008, inset 0 1px #ffffff18; transform: rotate(-5deg); } |
||||
.runtime-core strong { color: #fff; font-size: 2rem; } |
||||
.satellite { position: absolute; padding: 7px 11px; border: 1px solid #ffffff18; border-radius: 99px; background: #0c1929dd; color: #aebed3; font: 700 .66rem ui-monospace, monospace; } |
||||
.satellite.one { top: 20px; left: 31px; }.satellite.two { right: 3px; top: 108px; }.satellite.three { bottom: 22px; left: 16px; } |
||||
@keyframes spin { to { transform: rotate(360deg); } } |
||||
.panel { border: 1px solid #ffffff12; border-radius: 24px; background: linear-gradient(145deg, #0d1c2dcc, #091522d9); box-shadow: 0 25px 70px #00000035, inset 0 1px #ffffff09; backdrop-filter: blur(18px); } |
||||
.control-panel { padding: 30px; } |
||||
.panel-heading, .actions { display: flex; align-items: center; justify-content: space-between; gap: 24px; } |
||||
h2 { margin: 0; font-size: 1.35rem; letter-spacing: -.025em; } |
||||
.status { display: flex; align-items: center; gap: 9px; padding: 8px 13px; border-radius: 99px; background: #ffffff08; color: #9cafc6; font-size: .76rem; font-weight: 750; text-transform: uppercase; letter-spacing: .08em; } |
||||
.status span { width: 7px; height: 7px; border-radius: 50%; background: currentColor; } |
||||
.status.running { color: #ffd67a; }.status.running span { animation: blink .8s ease-in-out infinite alternate; }.status.success { color: #62edb2; }.status.error { color: #ff7d8d; } |
||||
@keyframes blink { to { opacity: .25; } } |
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin: 26px 0; } |
||||
label > span:first-child { display: block; margin: 0 0 8px; color: #c7d5e7; font-size: .82rem; font-weight: 680; } |
||||
label.wide { grid-column: 1 / -1; } |
||||
input, textarea { width: 100%; border: 1px solid #ffffff13; border-radius: 12px; outline: 0; background: #030b14a8; color: #eaf2ff; padding: 13px 15px; transition: border .2s, box-shadow .2s; } |
||||
input:focus, textarea:focus { border-color: #66ddff70; box-shadow: 0 0 0 3px #56dfff0d; } |
||||
textarea { resize: vertical; } |
||||
label small { display: block; margin-top: 7px; color: #62758e; font-size: .7rem; } |
||||
.actions { padding-top: 23px; border-top: 1px solid #ffffff0c; } |
||||
.switch-row { display: flex; align-items: center; gap: 12px; cursor: pointer; } |
||||
.switch-row input { position: absolute; opacity: 0; pointer-events: none; } |
||||
.switch { position: relative; width: 44px; height: 24px; border-radius: 20px; background: #28384b; transition: .2s; } |
||||
.switch i { position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; border-radius: 50%; background: #8292a7; transition: .2s; } |
||||
.switch-row input:checked + .switch { background: #27bb88; }.switch-row input:checked + .switch i { transform: translateX(20px); background: #fff; } |
||||
.switch-row > span:last-child strong, .switch-row > span:last-child small { display: block; }.switch-row strong { font-size: .8rem; }.switch-row small { margin-top: 3px; color: #687b94; font-size: .68rem; } |
||||
.button-row { display: flex; gap: 10px; }.button { border: 0; border-radius: 12px; padding: 12px 17px; color: #dce9f8; cursor: pointer; font-size: .8rem; font-weight: 750; transition: transform .18s, opacity .18s; }.button:hover { transform: translateY(-1px); }.button:disabled { opacity: .45; cursor: wait; transform: none; }.button.secondary { background: #ffffff0a; border: 1px solid #ffffff12; }.button.primary { min-width: 150px; display: flex; justify-content: space-between; background: linear-gradient(100deg, #25b9dc, #725ee8); box-shadow: 0 9px 30px #4584df2e; color: #fff; } |
||||
.results { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin: 16px 0; } |
||||
.feature-card { position: relative; min-height: 178px; padding: 21px; overflow: hidden; border: 1px solid #ffffff10; border-radius: 20px; background: #0a1725d9; } |
||||
.feature-card::after { content: ""; position: absolute; width: 110px; height: 110px; right: -42px; bottom: -52px; border-radius: 50%; background: var(--accent); filter: blur(45px); opacity: .18; } |
||||
.accent-cyan { --accent: #45defd; }.accent-violet { --accent: #9a78ff; }.accent-amber { --accent: #ffb860; }.accent-green { --accent: #4ee1a3; } |
||||
.icon { display: grid; place-items: center; width: 35px; height: 35px; border-radius: 10px; background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); font-size: 1.15rem; } |
||||
.feature-card p { margin: 22px 0 7px; color: #70839c; font-size: .7rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }.feature-card h3 { margin: 0; overflow: hidden; color: #e8f1fe; font: 650 1.02rem ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }.feature-card small { display: block; margin-top: 8px; overflow: hidden; color: #667991; font-size: .68rem; text-overflow: ellipsis; white-space: nowrap; } |
||||
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }.data-panel, .precision-panel { padding: 27px; } |
||||
dl { margin: 22px 0 0; } dl div { display: grid; grid-template-columns: 92px 1fr; gap: 14px; padding: 13px 0; border-top: 1px solid #ffffff0b; } dt { color: #71849d; font: 700 .71rem ui-monospace, monospace; text-transform: uppercase; } dd { margin: 0; overflow-wrap: anywhere; color: #bfd0e5; font: .78rem/1.55 ui-monospace, monospace; } |
||||
.console { margin-top: 16px; padding: 0; overflow: hidden; } .console summary { display: flex; justify-content: space-between; padding: 19px 24px; cursor: pointer; color: #a9bad0; font-size: .8rem; } kbd { border: 1px solid #ffffff12; border-radius: 6px; padding: 3px 7px; background: #ffffff08; color: #63778f; font: .65rem ui-monospace, monospace; }.console pre { max-height: 420px; margin: 0; padding: 23px; overflow: auto; border-top: 1px solid #ffffff0b; background: #02081099; color: #8fe9c0; font: .73rem/1.6 ui-monospace, monospace; } |
||||
footer { display: flex; justify-content: space-between; gap: 20px; padding: 28px 4px 0; color: #50647d; font-size: .68rem; } |
||||
@media (max-width: 820px) { .hero { grid-template-columns: 1fr; padding-bottom: 40px; }.runtime-orbit { display: none; }.results { grid-template-columns: 1fr 1fr; }.detail-grid { grid-template-columns: 1fr; } } |
||||
@media (max-width: 560px) { .shell { width: min(100% - 24px, 1180px); padding-top: 38px; }h1 { font-size: 3.6rem; }.control-panel { padding: 21px; }.form-grid, .results { grid-template-columns: 1fr; }.wide { grid-column: auto !important; }.actions, .panel-heading, footer { align-items: stretch; flex-direction: column; }.button-row { width: 100%; }.button { flex: 1; } } |
||||
@ -0,0 +1,135 @@ |
||||
import { |
||||
_setStderr, |
||||
_setStdin, |
||||
_setStdout, |
||||
} from '@bytecodealliance/preview2-shim/cli'; |
||||
import { _setFileData } from '@bytecodealliance/preview2-shim/filesystem'; |
||||
import { WASIShim } from '@bytecodealliance/preview2-shim/instantiation'; |
||||
|
||||
const encoder = new TextEncoder(); |
||||
const decoder = new TextDecoder(); |
||||
|
||||
function outputHandler(stream) { |
||||
return { |
||||
write(bytes) { |
||||
self.postMessage({ type: stream, data: decoder.decode(bytes, { stream: true }) }); |
||||
return BigInt(bytes.byteLength); |
||||
}, |
||||
blockingFlush() {}, |
||||
}; |
||||
} |
||||
|
||||
function inputHandler(text) { |
||||
const bytes = encoder.encode(text); |
||||
let offset = 0; |
||||
return { |
||||
blockingRead(length) { |
||||
if (offset >= bytes.byteLength) { |
||||
throw { tag: 'closed' }; |
||||
} |
||||
const end = Math.min(offset + Number(length), bytes.byteLength); |
||||
const chunk = bytes.slice(offset, end); |
||||
offset = end; |
||||
return chunk; |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
function encodeFileData(value) { |
||||
return JSON.stringify(value, (_key, item) => item instanceof Uint8Array |
||||
? { typephpBytes: Array.from(item) } |
||||
: item); |
||||
} |
||||
|
||||
function decodeFileData(value) { |
||||
return JSON.parse(value, (_key, item) => item && Array.isArray(item.typephpBytes) |
||||
? new Uint8Array(item.typephpBytes) |
||||
: item); |
||||
} |
||||
|
||||
async function openPersistentFile(name) { |
||||
if (!navigator.storage?.getDirectory) { |
||||
throw new Error('OPFS is not available in this browser'); |
||||
} |
||||
const root = await navigator.storage.getDirectory(); |
||||
const handle = await root.getFileHandle(name, { create: true }); |
||||
if (typeof handle.createSyncAccessHandle !== 'function') { |
||||
throw new Error('OPFS synchronous access requires a dedicated Worker'); |
||||
} |
||||
return handle.createSyncAccessHandle(); |
||||
} |
||||
|
||||
async function loadFileData(storageName) { |
||||
const access = await openPersistentFile(storageName); |
||||
try { |
||||
const size = access.getSize(); |
||||
if (size === 0) { |
||||
return { dir: {} }; |
||||
} |
||||
const bytes = new Uint8Array(size); |
||||
access.read(bytes, { at: 0 }); |
||||
return decodeFileData(decoder.decode(bytes)); |
||||
} finally { |
||||
access.close(); |
||||
} |
||||
} |
||||
|
||||
async function saveFileData(storageName, fileData) { |
||||
const access = await openPersistentFile(storageName); |
||||
try { |
||||
const bytes = encoder.encode(encodeFileData(fileData)); |
||||
access.truncate(0); |
||||
access.write(bytes, { at: 0 }); |
||||
access.flush(); |
||||
} finally { |
||||
access.close(); |
||||
} |
||||
} |
||||
|
||||
self.onmessage = async ({ data }) => { |
||||
if (data?.type !== 'run') { |
||||
return; |
||||
} |
||||
|
||||
let exitCode = 0; |
||||
let fileData; |
||||
const persistent = data.persistent === true; |
||||
const storageName = String(data.storageName || 'typephp-wasi-filesystem.json'); |
||||
try { |
||||
fileData = persistent ? await loadFileData(storageName) : { dir: {} }; |
||||
_setFileData(fileData); |
||||
_setStdin(inputHandler(String(data.stdin || ''))); |
||||
_setStdout(outputHandler('stdout')); |
||||
_setStderr(outputHandler('stderr')); |
||||
|
||||
const args = ['typephp.wasm', ...(Array.isArray(data.args) ? data.args.map(String) : [])]; |
||||
const env = data.env && typeof data.env === 'object' ? data.env : {}; |
||||
const wasi = new WASIShim({ |
||||
sandbox: { |
||||
args, |
||||
env, |
||||
enableNetwork: false, |
||||
}, |
||||
}); |
||||
const { instantiate } = await import('./generated/program.js'); |
||||
const component = await instantiate(null, wasi.getImportObject()); |
||||
await component.run.run(); |
||||
} catch (error) { |
||||
if (error?.exitError) { |
||||
exitCode = Number(error.code || 0); |
||||
} else { |
||||
self.postMessage({ type: 'error', error: error?.stack || String(error) }); |
||||
exitCode = 1; |
||||
} |
||||
} finally { |
||||
if (persistent && fileData) { |
||||
try { |
||||
await saveFileData(storageName, fileData); |
||||
} catch (error) { |
||||
self.postMessage({ type: 'error', error: error?.stack || String(error) }); |
||||
exitCode = 1; |
||||
} |
||||
} |
||||
self.postMessage({ type: 'exit', code: exitCode }); |
||||
} |
||||
}; |
||||
@ -0,0 +1,10 @@ |
||||
import { defineConfig } from 'vite'; |
||||
|
||||
export default defineConfig({ |
||||
server: { |
||||
host: '127.0.0.1', |
||||
}, |
||||
worker: { |
||||
format: 'es', |
||||
}, |
||||
}); |
||||
@ -0,0 +1,6 @@ |
||||
<?php |
||||
|
||||
function main(): void |
||||
{ |
||||
proc_open('true', [], $pipes); |
||||
} |
||||
@ -0,0 +1,6 @@ |
||||
<?php |
||||
|
||||
function main(): void |
||||
{ |
||||
pcntl_signal(SIGTERM, static function (): void {}); |
||||
} |
||||
@ -0,0 +1,6 @@ |
||||
<?php |
||||
|
||||
function main(): void |
||||
{ |
||||
stream_socket_server('tcp://127.0.0.1:0'); |
||||
} |
||||
@ -0,0 +1,106 @@ |
||||
<?php |
||||
|
||||
namespace TypePhpTest\Build; |
||||
|
||||
use PHPUnit\Framework\TestCase; |
||||
use RuntimeException; |
||||
use TypePhp\Build\WasiProjectConfig; |
||||
|
||||
final class WasiProjectConfigTest extends TestCase |
||||
{ |
||||
private string $directory; |
||||
|
||||
protected function setUp(): void |
||||
{ |
||||
$this->directory = sys_get_temp_dir() . '/typephp-wasi-project-' . bin2hex(random_bytes(6)); |
||||
mkdir($this->directory . '/src', 0777, true); |
||||
file_put_contents($this->directory . '/src/main.php', '<?php function main(): void {}'); |
||||
} |
||||
|
||||
protected function tearDown(): void |
||||
{ |
||||
@unlink($this->directory . '/src/main.php'); |
||||
@unlink($this->directory . '/project.yml'); |
||||
@rmdir($this->directory . '/src'); |
||||
@rmdir($this->directory); |
||||
} |
||||
|
||||
public function testProjectPathsAreResolvedRelativeToYaml(): void |
||||
{ |
||||
file_put_contents($this->directory . '/project.yml', <<<'YAML' |
||||
name: demo |
||||
mode: bin |
||||
target-platform: wasm32-wasip2 |
||||
build-dir: cache/build |
||||
output: dist/demo.wasm |
||||
sources: |
||||
- src |
||||
wasm: true |
||||
wasm-browser-dir: web/generated |
||||
YAML); |
||||
|
||||
$config = WasiProjectConfig::load( |
||||
'project.yml', |
||||
null, |
||||
$this->directory, |
||||
'/default-build', |
||||
); |
||||
|
||||
self::assertSame(realpath($this->directory . '/project.yml'), $config->input); |
||||
self::assertSame($this->directory . '/cache/build', $config->buildDir); |
||||
self::assertSame($this->directory . '/dist/demo.wasm', $config->output); |
||||
self::assertSame($this->directory . '/web/generated', $config->browserDir); |
||||
self::assertSame('browser', $config->profile); |
||||
self::assertTrue(WasiProjectConfig::isWasmEnabled($this->directory . '/project.yml')); |
||||
} |
||||
|
||||
public function testSingleFileKeepsBuilderOutputDefaults(): void |
||||
{ |
||||
$config = WasiProjectConfig::load( |
||||
'src/main.php', |
||||
'custom-build', |
||||
$this->directory, |
||||
'/default-build', |
||||
); |
||||
|
||||
self::assertSame($this->directory . '/custom-build', $config->buildDir); |
||||
self::assertNull($config->output); |
||||
self::assertNull($config->browserDir); |
||||
self::assertSame('browser', $config->profile); |
||||
} |
||||
|
||||
public function testCliCanSelectComponentOnlyOutput(): void |
||||
{ |
||||
file_put_contents($this->directory . '/project.yml', <<<'YAML' |
||||
name: demo |
||||
wasm: true |
||||
wasm-browser-dir: generated |
||||
sources: |
||||
- src |
||||
YAML); |
||||
|
||||
$config = WasiProjectConfig::load( |
||||
'project.yml', |
||||
null, |
||||
$this->directory, |
||||
'/default-build', |
||||
'component', |
||||
); |
||||
|
||||
self::assertSame('component', $config->profile); |
||||
self::assertNull($config->browserDir); |
||||
} |
||||
|
||||
public function testPreviewOneProjectIsRejected(): void |
||||
{ |
||||
file_put_contents($this->directory . '/project.yml', <<<'YAML' |
||||
target-platform: wasm32-wasi |
||||
sources: |
||||
- src |
||||
YAML); |
||||
|
||||
$this->expectException(RuntimeException::class); |
||||
$this->expectExceptionMessage('must target wasm32-wasip2'); |
||||
WasiProjectConfig::load('project.yml', null, $this->directory, '/default-build'); |
||||
} |
||||
} |
||||
@ -0,0 +1,144 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\Build; |
||||
|
||||
use RuntimeException; |
||||
use Symfony\Component\Yaml\Yaml; |
||||
|
||||
final readonly class WasiProjectConfig |
||||
{ |
||||
private function __construct( |
||||
public string $input, |
||||
public string $buildDir, |
||||
public ?string $output, |
||||
public ?string $browserDir, |
||||
public string $profile, |
||||
) { |
||||
} |
||||
|
||||
public static function load( |
||||
string $input, |
||||
?string $cliBuildDir, |
||||
string $workingDirectory, |
||||
string $defaultBuildDir, |
||||
?string $cliProfile = null, |
||||
): self { |
||||
$input = self::absolutePath($input, $workingDirectory); |
||||
$realInput = realpath($input); |
||||
if ($realInput === false || !is_file($realInput)) { |
||||
throw new RuntimeException("WASI input does not exist: {$input}"); |
||||
} |
||||
|
||||
$projectDir = dirname($realInput); |
||||
$config = null; |
||||
if (preg_match('/\.ya?ml$/i', $realInput) === 1) { |
||||
$config = Yaml::parseFile($realInput); |
||||
if (!is_array($config)) { |
||||
throw new RuntimeException('WASI project YAML root must be a map'); |
||||
} |
||||
} |
||||
|
||||
$buildDir = $cliBuildDir; |
||||
if ($buildDir === null && is_array($config) && !empty($config['build-dir'])) { |
||||
$buildDir = (string) $config['build-dir']; |
||||
$buildDir = self::absolutePath($buildDir, $projectDir); |
||||
} |
||||
$buildDir ??= $defaultBuildDir; |
||||
$buildDir = self::absolutePath($buildDir, $workingDirectory); |
||||
|
||||
if (!is_array($config)) { |
||||
return new self($realInput, $buildDir, null, null, self::normalizeProfile($cliProfile ?? 'browser')); |
||||
} |
||||
|
||||
$target = (string) ($config['target-platform'] ?? 'wasm32-wasip2'); |
||||
if (!in_array($target, ['wasm32-wasip2', 'wasm32-unknown-wasip2'], true)) { |
||||
throw new RuntimeException('A WASI project must target wasm32-wasip2'); |
||||
} |
||||
|
||||
$mode = strtolower((string) ($config['mode'] ?? $config['build-mode'] ?? $config['type'] ?? 'bin')); |
||||
if (!in_array($mode, ['bin', 'binary', 'cli'], true)) { |
||||
throw new RuntimeException('A WASI project must use bin mode'); |
||||
} |
||||
|
||||
if (!empty($config['output'])) { |
||||
$output = self::absolutePath((string) $config['output'], $projectDir); |
||||
$extension = pathinfo($output, PATHINFO_EXTENSION); |
||||
if ($extension === '') { |
||||
$output .= '.wasm'; |
||||
} elseif (strcasecmp($extension, 'wasm') !== 0) { |
||||
throw new RuntimeException('A WASI project output must use the .wasm extension'); |
||||
} |
||||
} else { |
||||
$name = trim((string) ($config['name'] ?? 'app')); |
||||
if ($name === '' || str_contains($name, '/') || str_contains($name, '\\')) { |
||||
throw new RuntimeException('A WASI project name must be a non-empty file name'); |
||||
} |
||||
$output = $projectDir . DIRECTORY_SEPARATOR . $name . '.wasm'; |
||||
} |
||||
|
||||
$wasm = $config['wasm'] ?? true; |
||||
$configProfile = 'browser'; |
||||
if (is_string($wasm)) { |
||||
$configProfile = $wasm; |
||||
} elseif (is_array($wasm) && !empty($wasm['profile'])) { |
||||
$configProfile = (string) $wasm['profile']; |
||||
} elseif (!is_bool($wasm) && !is_array($wasm)) { |
||||
throw new RuntimeException('The `wasm` project option must be a boolean, profile name, or map'); |
||||
} |
||||
$profile = self::normalizeProfile($cliProfile ?? $configProfile); |
||||
|
||||
$browserPath = $config['wasm-browser-dir'] ?? null; |
||||
if (is_array($wasm) && !empty($wasm['browser-dir'])) { |
||||
$browserPath = $wasm['browser-dir']; |
||||
} |
||||
$browserDir = $profile === 'browser' && !empty($browserPath) |
||||
? self::absolutePath((string) $browserPath, $projectDir) |
||||
: null; |
||||
|
||||
return new self($realInput, $buildDir, $output, $browserDir, $profile); |
||||
} |
||||
|
||||
public static function isWasmEnabled(string $path): bool |
||||
{ |
||||
if (!is_file($path) || preg_match('/\.ya?ml$/i', $path) !== 1) { |
||||
return false; |
||||
} |
||||
try { |
||||
$config = Yaml::parseFile($path); |
||||
} catch (\Throwable) { |
||||
return false; |
||||
} |
||||
if (!is_array($config) || !array_key_exists('wasm', $config)) { |
||||
return false; |
||||
} |
||||
$wasm = $config['wasm']; |
||||
if ($wasm === false || $wasm === null) { |
||||
return false; |
||||
} |
||||
return !is_array($wasm) || ($wasm['enabled'] ?? true) !== false; |
||||
} |
||||
|
||||
private static function normalizeProfile(string $profile): string |
||||
{ |
||||
$profile = strtolower(trim($profile)); |
||||
$profile = match ($profile) { |
||||
'', 'web' => 'browser', |
||||
default => $profile, |
||||
}; |
||||
if (!in_array($profile, ['browser', 'component'], true)) { |
||||
throw new RuntimeException("Unsupported WASI output profile `{$profile}`; expected browser or component"); |
||||
} |
||||
return $profile; |
||||
} |
||||
|
||||
private static function absolutePath(string $path, string $baseDirectory): string |
||||
{ |
||||
if ($path === '') { |
||||
throw new RuntimeException('WASI project paths must not be empty'); |
||||
} |
||||
if ($path[0] === '/' || $path[0] === '\\' || preg_match('/^[A-Za-z]:[\\\\\/]/', $path) === 1) { |
||||
return $path; |
||||
} |
||||
return rtrim($baseDirectory, '/\\') . DIRECTORY_SEPARATOR . $path; |
||||
} |
||||
} |
||||
Loading…
Reference in new issue