
# API

実行APIは1つのクラス、`Phasis\Engine`です。それ以外（`JsValue`、`JsFunction`、`JsObject`、…）はすべてエンジン内部専用で、PHP↔JSを橋渡しするホスト関数にのみ公開されます。もう1つの公開APIは、単独で使える[パーサーAPI](https://phasis.inline0.dev/ja/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`、ArrayBuffer関連、`Atomics`、`Proxy`、`Reflect`、`console`、`Intl`、WeakRef関連、`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/ja/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()`が戻る時点でイベントループの処理は完了しているため、確定した値をグローバルに保存し、2回目の`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`が使え、相対specifierは`$path`（絶対ファイルパス）を基準に解決されます。`eval()`と同様にイベントループを実行し、常に`null`を返します。結果はグローバル変数またはコンソールバッファから読み取ってください。

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

ローダーが解決できるのは相対パスと絶対パスのみです。[既知の制限](https://phasis.inline0.dev/ja/docs/compatibility/limitations#module-loader)を参照してください。

## execFile

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

ディスクからファイルを読み込み、その内容を`eval()`に渡すため、拡張子にかかわらず通常のスクリプトとして実行されます。`import` / `export`を含むファイルはSyntaxErrorになります。ファイルの実パスが、動的な`import()`specifierの基準になります。モジュールファイルには`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` | 関数名を表す場合（`'strlen'`など）も含め、`string` |
| 配列（リスト） | `Array`（コピー） |
| 配列（連想配列） | `Object`（コピー） |
| `Closure`、呼び出し可能なオブジェクト、`[$object, 'method']` | `function` |
| その他のオブジェクト、ジェネレーター、リソース | `undefined` |

配列はコピーされるため、JSによる変更はPHP側の配列には反映されません。通常のPHPオブジェクトはまだ渡せません（[issue #18](https://github.com/inline0/phasis/issues/18)）。回避方法は[共有オブジェクト](https://phasis.inline0.dev/ja/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`は出力しません。呼び出しごとに、エンジン上のバッファに書式化された1行が追加されます。`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
```

実行時に変更できるリソース上限を設定します。上限は1つだけです。

| 上限 | デフォルト | 説明 |
|---|---|---|
| `maxLoopIterations` | `100000` | ツリーウォーキングインタープリターが実行する`for` / `while` / `do…while`ループの実行ごとの最大反復回数 |

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

それ以外の名前は何も通知されずに無視されます。文字列の長さ、コンソール出力、実時間に対する上限はありません。

ループ上限はまだすべての箇所で適用されるわけではありません。バイトコードVMで実行されるループ（関数本体や、バイトコードコンパイラーが受け付けるトップレベルプログラムが対象）はカウントされないため、`while (true) {}`はPHPが停止するまで実行されます（[issue #19](https://github.com/inline0/phasis/issues/19)）。信頼できないコードの停止手段として頼らないでください。PHPの`max_execution_time`を使うか、別プロセスで実行してください。

上限に達したとき、JSの`try` / `catch`でエラーを捕捉できます（メッセージが「Maximum loop iterations exceeded」の`Error`として）。捕捉されなければ、`Phasis\Exceptions\RuntimeError`を継承する`Phasis\Exceptions\InternalError`としてPHPに到達します。`JsThrowable`ではありません。

エンジンには、実行時に変更できない上限が2つ組み込まれています。`Phasis\Runtime\CallStack::maxDepth`（JSフレーム4,096個）と、JsToPhpによるトランスパイル済みクロージャーの深度ガード（PHPフレーム8,192個）です。どちらも上限を超えると「Maximum call stack size exceeded」をスローし、これもPHPに`InternalError`として到達します。通常のJSプログラムが達する値を大きく上回っており、引き上げるには設定変更ではなくコードの変更が必要です。

## リセットする

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

ユーザー定義のグローバル変数とコンソールバッファをすべて消去し、標準ライブラリとイベントループを再構築します。

デフォルトの遅延モードで構築したエンジンでは、現在`reset()`を実行すると、遅延インストールされた組み込み機能がすべて失われます。その後は`Map`、`JSON`、`Math`、`console`、`fetch`などが未定義になります（[issue #22](https://github.com/inline0/phasis/issues/22)）。修正されるまでは、新しい`Engine`を構築するか、`eager: true`を指定して構築してください。この場合は`reset()`が動作します。

## イベントループ

エンジンにはJavaScriptイベントループが組み込まれており、グローバルオブジェクトに`setTimeout`、`setInterval`、`clearTimeout`、`clearInterval`、`queueMicrotask`が用意されています。マイクロタスク（Promiseハンドラー、`queueMicrotask`）は各マクロタスクの間に処理されます。タイマーは、レルムごとのタスクキューを使い、期限の早い順に実行されます。

```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()`は1回だけ反復処理（マイクロタスクを処理し、期限に達したタイマーを実行）して戻ります。`pendingTaskCount()`は未処理のタイマーを報告するため、組み込み側で独自のポンプ処理を進められます。

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

暴走した`setInterval`にも上限があります。`runEventLoop()`は100,000サイクルで打ち切り、通常の`\RuntimeException`をスローします。これにより、JSコードの不具合がハングではなく明確なPHPエラーとして表れます。インターバルはスケジュールされたままなので、同じエンジンで後から`eval()`を呼び出しても同じエラーが発生します。その後はエンジンを破棄してください。タイマーコールバック内の未捕捉エラーは`error_log()`経由で出力され、ループは処理を続けます。1つの不正なタイマーでプログラム全体が停止することはありません。

## Fetchフック

Phasisでは`fetch()`がデフォルトで有効になっており、基盤となる通信処理にはPHPの`ext-curl`を使用します。組み込み側で通信処理を差し替え、リクエストを制御し、Cookieの保存を有効にするためのフックが3つあります。いずれもPHPの呼び出し可能オブジェクト、またはオブジェクトのメソッドを受け取ります。値の形式については[Web APIs](https://phasis.inline0.dev/ja/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()`の呼び出し元に届きます（[issue #20](https://github.com/inline0/phasis/issues/20)）。[ホスト関数](https://phasis.inline0.dev/ja/docs/interop/host-functions#throwing-back-into-js)を参照してください。

## ライフサイクル

`Engine`の構築は短時間（コールド時でも数ミリ秒程度）で、標準ライブラリは遅延インストールされます。多くの組み込み用途では、プロセスごとにエンジンを1つ長時間稼働させれば十分です。マルチテナント環境では、グローバル変数が共有されないよう、テナントまたはリクエストごとにエンジンを用意してください。現在、確実に初期状態に戻すには新しいエンジンを構築する方法があります（[reset](#reset)を参照）。

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