
# API

执行 API 只有一个类：`Phasis\Engine`。其他所有内容（`JsValue`、`JsFunction`、`JsObject`、…）都属于引擎内部，只向连接 PHP↔JS 的宿主函数公开。另一个公开接口是独立的 [解析器API](https://phasis.inline0.dev/zh/docs/parser)（`Phasis\Parser\Parser` 和 `Phasis\Ast` 类），它只解析源代码，不执行代码。

## 构造

```php
use Phasis\Engine;

$engine = new Engine();                 // lazy mode (default)
$engine = new Engine(eager: true);      // install every built-in up front
```

新建的 `Engine` 会公开完整的标准库——`Object`、`Array`、`String`、`Number`、`Symbol`、`BigInt`、`Promise`、`RegExp`、`Error` 以及核心语言内置功能都会立即安装，因此规范能够正常工作（没有 `Function.prototype` 就无法使用类，没有 `Promise` 就无法使用 async/await，没有 `RegExp` 就无法使用正则表达式字面量）。其他所有内容——`Math`、`JSON`、`Map`、`Set`、`Date`、`TypedArray` 和 array-buffer 系列、`Atomics`、`Proxy`、`Reflect`、`console`、`Intl`、weak-ref 系列、`Temporal`、`ShadowRealm`、Web Platform Pack 和 Fetch Pack——都会注册为占位访问器，并在首次读取时实例化。读取全局对象时无需特殊处理：`Map`、`typeof Map`、`'Map' in globalThis`、`Object.keys(globalThis).includes('Map')` 都能正常工作；首次读取后，占位符会被常规数据描述符替换。

如果确实需要预先惰性加载之前的行为，可传入 `eager: true`——构造时安装所有内置功能，并让完整的描述符接口可供内省。符合性测试运行框架（`bin/test262`、`bin/wpt`）会以预先加载模式构造引擎，以便 test262/WPT 能看到预期的描述符结构。

## 求值

```php
public function eval(string $source): mixed
```

将 `$source` 作为经典脚本在引擎的全局作用域中解析并执行，然后运行事件循环直到队列清空。返回最后一个求值表达式的值，并将其转换为 PHP（如果程序末尾没有表达式，则返回 `null`）。转换规则见[值转换](https://phasis.inline0.dev/zh/docs/interop/values)。

```php
$result = $engine->eval('1 + 2');           // 3
$result = $engine->eval('JSON.stringify({a: 1})');  // '{"a":1}'
$result = $engine->eval('[1,2,3].map(x => x * 2)'); // [2, 4, 6] (PHP array)
```

Promise 不会被解包：`eval('Promise.resolve(5)')` 会返回一个空数组。由于 `eval()` 返回时事件循环已经清空，请将已完成的值存入全局变量，再通过第二次 `eval()` 读取：

```php
$engine->eval('let out; (async () => { out = await Promise.resolve(9); })();');
$engine->eval('out');   // 9
```

未被捕获的显式 JS `throw` 会作为 `Phasis\Exceptions\JsThrowable` 显现，其消息为 `"Name: message"`，而其 `$jsValue` 属性保存抛出的 `JsValue`。引擎自身引发的错误，例如读取 `null` 的属性或使用未定义变量，会显示为 `Phasis\Exceptions\TypeError`、`ReferenceError` 或 `RangeError`。这些错误都继承自 `Phasis\Exceptions\RuntimeError`；解析错误则继承自 `Phasis\Exceptions\SyntaxError`。参见[异常](#exceptions)。

```php
try {
    $engine->eval('throw new Error("boom")');
} catch (\Phasis\Exceptions\JsThrowable $e) {
    echo $e->getMessage();
    // Error: boom
}
```

## evalAsModule

```php
public function evalAsModule(string $source, ?string $path = null): mixed
```

将 `$source` 作为 ES 模块求值：支持 `import` / `export`、顶层 `await` 和 `import.meta`，相对模块标识符以 `$path`（绝对文件路径）为基准解析。运行事件循环的方式与 `eval()` 相同，并且始终返回 `null`；请通过全局变量或控制台缓冲区读取结果。

```php
$path = __DIR__ . '/entry.mjs';
$engine->evalAsModule(file_get_contents($path), $path);
```

加载器只解析相对路径和绝对路径；参见[已知限制](https://phasis.inline0.dev/zh/docs/compatibility/limitations#module-loader)。

## execFile

```php
public function execFile(string $path): mixed
```

从磁盘读取文件并将其内容传给 `eval()`，因此无论文件扩展名是什么，都会作为经典脚本运行；扩展名为 `import` / `export` 的文件会导致 SyntaxError。文件的实际路径会成为动态 `import()` 标识符的基准路径。模块文件请使用 `evalAsModule()`。

```php
$engine->execFile(__DIR__ . '/script.js');
```

如果无法读取文件，PHP 会发出警告，而 `execFile()` 会抛出普通的 `\RuntimeException`（“Cannot read file: …”）。

## setGlobal

```php
public function setGlobal(string $name, mixed $value): void
```

绑定一个全局变量，供之后所有 JS 求值使用。PHP 值会转换为 JS：

| PHP 类型 | JS 类型 |
|---|---|
| `null` | `null` |
| `bool` | `boolean` |
| `int` / `float` | `number` |
| `string` | `string`（即使它对应一个函数，例如 `'strlen'`） |
| 数组（列表） | `Array`（副本） |
| 数组（关联数组） | `Object`（副本） |
| `Closure`、可调用对象、`[$object, 'method']` | `function` |
| 其他任何对象、生成器或资源 | `undefined` |

数组会被复制：JS 对数组所做的更改不会反映到 PHP 数组中。暂时还不能传递普通的 PHP 对象（[问题 #18](https://github.com/inline0/phasis/issues/18)）；变通方法见[共享对象](https://phasis.inline0.dev/zh/docs/interop/shared-objects)。

```php
$engine->setGlobal('config', ['debug' => true, 'version' => '1.0']);
$engine->setGlobal('log', fn(string $msg) => error_log($msg));
```

在 JS 内：

```javascript
console.log(config.version);   // "1.0"
log('hello');                  // calls the PHP closure
```

## 调用

```php
public function call(string $name, mixed ...$args): mixed
```

调用一个全局 JS 函数，并传入 PHP 参数（转换方式与 `setGlobal` 相同）。返回值会转换回 PHP。调用未定义的名称会抛出 `Phasis\Exceptions\ReferenceError`；调用不是函数的全局变量会抛出 `Phasis\Exceptions\TypeError`。

```php
$engine->eval('function add(a, b) { return a + b; }');
echo $engine->call('add', 2, 3);   // 5
```

## 控制台输出

```php
public function getConsoleOutput(): string
public function getConsoleLines(): array
public function clearConsole(): void
```

`console.log`、`console.info`、`console.warn`、`console.error` 和 `console.debug` 不会直接输出内容。每次调用都会向引擎中的缓冲区追加一行格式化文本。`getConsoleLines()` 会以字符串列表的形式返回缓冲行，`getConsoleOutput()` 会用 `\n` 将它们连接起来，而 `clearConsole()` 会清空缓冲区。多次调用 `eval()` 之间不会清空缓冲区。

```php
$engine->eval('console.log("a", 1); console.warn({ b: 2 })');
$engine->getConsoleLines();    // ['a 1', '{ b: 2 }']
echo $engine->getConsoleOutput();
// a 1
// { b: 2 }
$engine->clearConsole();
```

## setLimit

```php
public function setLimit(string $limit, int $value): void
```

设置一个可在运行时调整的资源限制。只有一项：

| 限制 | 默认值 | 说明 |
|---|---|---|
| `maxLoopIterations` | `100000` | 树遍历解释器每次运行 `for` / `while` / `do…while` 循环时允许的最大迭代次数 |

```php
$engine->setLimit('maxLoopIterations', 1_000_000);
```

其他名称都会被静默忽略；字符串长度、控制台输出和运行时间都不受限制。

循环限制目前尚未在所有地方生效。由字节码 VM 运行的循环（包括函数体和字节码编译器接受的顶层程序）不会计数，因此 `while (true) {}` 会一直运行，直到 PHP 将其停止（[问题 #19](https://github.com/inline0/phasis/issues/19)）。不要依赖此限制来阻止不受信任的代码；请使用 PHP 的 `max_execution_time` 或单独的进程。

达到限制时，错误可由 JS `try` / `catch` 捕获（作为一条消息为“Maximum loop iterations exceeded”的 `Error`）。如果没有捕获，它会以 `Phasis\Exceptions\InternalError` 的形式传到 PHP，并继承自 `Phasis\Exceptions\RuntimeError`，而不是 `JsThrowable`。

引擎还内置了两个无法在运行时调整的上限：`Phasis\Runtime\CallStack::maxDepth`（4,096 个 JS 栈帧）和 JsToPhp 转译闭包的深度保护（8,192 个 PHP 栈帧）。超过任一上限都会抛出“Maximum call stack size exceeded”，并同样以 `InternalError` 的形式传到 PHP。这些上限远高于任何正常的 JS 程序所需；提高上限需要修改代码，不能通过配置实现。

## 重置

```php
public function reset(): void
```

清除所有用户定义的全局变量和控制台缓冲区，并重建标准库和事件循环。

对于以默认惰性模式构造的引擎，`reset()` 目前会丢失所有惰性安装的内置功能：之后 `Map`、`JSON`、`Math`、`console`、`fetch` 等都会变成未定义（[问题 #22](https://github.com/inline0/phasis/issues/22)）。此问题修复前，请改为构造一个新的 `Engine`，或使用 `eager: true` 构造；这种模式下 `reset()` 可以正常工作。

## 事件循环

引擎自带一个 JavaScript 事件循环，全局对象上提供 `setTimeout`、`setInterval`、`clearTimeout`、`clearInterval` 和 `queueMicrotask`。微任务（Promise 处理程序、`queueMicrotask`）会在每个宏任务之间执行完毕；计时器则通过每个 realm 的任务队列按截止时间顺序触发。

```php
public function runEventLoop(): void
public function tickEventLoop(): bool
public function pendingTaskCount(): int
public function getEventLoop(): \Phasis\Runtime\EventLoop
```

`eval()` 和 `evalAsModule()` 会在同步代码执行完毕后自动调用 `runEventLoop()`。因此，在顶层安排 `setTimeout(cb, 5000)` 的脚本会在 `eval()` 返回前运行 `cb`——事件循环使用**虚拟时间**，将时钟直接跳到下一个待处理的截止时间，而不是实际等待 PHP。符合 `node script.js` 的语义。

如果要进行实时嵌入（长期运行的服务器、UI 集成、根据实时时钟进行调度），`tickEventLoop()` 会运行一次循环迭代（执行完微任务、触发到期的计时器）然后返回。`pendingTaskCount()` 会报告尚未完成的计时器；嵌入方可以自行驱动事件循环：

```php
while ($engine->pendingTaskCount() > 0) {
    $engine->tickEventLoop();
    usleep(1000);
}
```

失控的 `setInterval` 会受到限制：`runEventLoop()` 最多运行 100,000 个周期，然后抛出普通的 `\RuntimeException`——JS 代码中的错误会以清晰的 PHP 错误显示，而不会导致卡死。该间隔计时器仍处于调度状态，因此此引擎上的后续每次 `eval()` 都会抛出相同错误；之后请丢弃该引擎。计时器回调中的未捕获错误会通过 `error_log()` 发出，而事件循环会继续执行；单个计时器出错不会终止程序。

## Fetch 钩子

Phasis 默认启用 `fetch()`，并以 PHP 的 `ext-curl` 作为底层传输。三个钩子可让嵌入方替换传输方式、控制请求，或启用 Cookie 存储。三者都接受 PHP 可调用对象或对象上的方法；值的结构约定见[Web API](https://phasis.inline0.dev/zh/docs/compatibility/web-apis)。

### setFetchTransport

```php
public function setFetchTransport(callable $transport): void
```

替换默认的 CurlTransport。可调用对象会收到请求描述符和可选的 `AbortSignal` JsObject，并返回响应描述符。

```php
// Use Guzzle as the transport instead of curl
$client = new GuzzleHttp\Client();
$engine->setFetchTransport(function (array $req, $signal) use ($client) {
    $r = $client->request($req['method'], $req['url'], [
        'headers' => $req['headers'],
        'body' => $req['body'],
        'http_errors' => false,
    ]);
    return [
        'status' => $r->getStatusCode(),
        'statusText' => $r->getReasonPhrase(),
        'headers' => array_map(
            fn ($name, $values) => array_map(fn ($v) => [$name, $v], $values),
            array_keys($r->getHeaders()),
            $r->getHeaders(),
        ),
        'body' => (string) $r->getBody(),
    ];
});
```

常见的嵌入模式：在测试中安装模拟传输，在生产环境中安装真实的 curl 传输。

### setFetchPolicy

```php
public function setFetchPolicy(callable $hook): void
```

请求发送前的策略钩子。接收 `Request` JsObject；抛出异常可拒绝请求，返回修改后的 `Request` 可重写请求，返回 `null` 或不返回值则表示允许。

```php
// Block non-HTTPS and inject auth on a specific host
$engine->setFetchPolicy(function ($req) {
    $url = $req->get('url')->value;
    if (str_starts_with($url, 'http://')) {
        throw new \RuntimeException('HTTPS only');
    }
    if (str_contains($url, 'api.example.com')) {
        $req->get('headers')->call('set', 'Authorization', 'Bearer ...');
    }
    return null;
});
```

### setCookieJar

```php
public function setCookieJar(mixed $jar): void
```

选择启用 Cookie 存储。接受任何公开 `get(string $url): string` 和 `set(string $url, string $header): void` 的对象——可在 PHP 端或 JS 端实现。

```php
class SimpleJar
{
    private array $cookies = [];
    public function get(string $url): string { return implode('; ', $this->cookies); }
    public function set(string $url, string $header): void { $this->cookies[] = $header; }
}
$engine->setCookieJar(new SimpleJar());
```

如果没有 `setCookieJar()`，`fetch` 会忽略传入的 `Set-Cookie` 标头和传出的 `Cookie` 标头——默认情况下**不使用 Cookie**。

## 异常

| 类 | 继承自 | 适用情况 |
|---|---|---|
| `Phasis\Exceptions\JsThrowable` | `RuntimeError` | 显式 JS `throw` 未被捕获 |
| `Phasis\Exceptions\TypeError`、`ReferenceError`、`RangeError` | `RuntimeError` | 引擎自身引发的错误（例如 `null.x` 或未定义变量）未被捕获 |
| `Phasis\Exceptions\InternalError` | `RuntimeError` | 超过循环限制或调用栈上限 |
| `Phasis\Exceptions\RuntimeError` | `\RuntimeException` | 上述类的基类 |
| `Phasis\Parser\ParseError` | `Phasis\Exceptions\SyntaxError`（`\RuntimeException`） | 源代码无法解析 |
| `\RuntimeException` |  | `execFile()` 无法读取文件；事件循环达到 100,000 周期上限 |

若要捕获脚本的所有错误，可捕获 `Phasis\Exceptions\RuntimeError` 和 `Phasis\Exceptions\SyntaxError`，或者直接捕获 `\RuntimeException`。

你自己的 PHP 宿主函数抛出的异常情况有所不同：目前这些异常会原样通过 JS `try` / `catch` 传到 `eval()` 的调用方，并保留其原始类（[问题 #20](https://github.com/inline0/phasis/issues/20)）。参见[宿主函数](https://phasis.inline0.dev/zh/docs/interop/host-functions#throwing-back-into-js)。

## 生命周期

构造 `Engine` 的开销很低（冷启动只需几毫秒），标准库也会惰性安装。对于大多数嵌入场景，每个进程使用一个长期运行的引擎即可。对于多租户用途，请为每个租户或请求创建单独的引擎，以免全局变量在租户间泄漏。目前，创建新引擎是可靠的清理方式（参见[reset](#reset)）。

```php
foreach ($requests as $req) {
    $engine = new Engine();
    $engine->setGlobal('request', ['path' => $req->path, 'query' => $req->query]);
    $engine->execFile($req->scriptPath);
}
```
