コンテンツへスキップ

API

実行APIは1つのクラス、Phasis\Engineです。それ以外(JsValue、JsFunction、JsObject、…)はすべてエンジン内部専用で、PHP↔JSを橋渡しするホスト関数にのみ公開されます。もう1つの公開APIは、単独で使えるパーサーAPI(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を返します)。変換規則は値の変換を参照してください。

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を継承します。例外を参照してください。

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);

ローダーが解決できるのは相対パスと絶対パスのみです。既知の制限を参照してください。

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)。回避方法は共有オブジェクトを参照してください。

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)。信頼できないコードの停止手段として頼らないでください。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)。修正されるまでは、新しい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を参照してください。

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)。ホスト関数を参照してください。

ライフサイクル

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

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