Перейти к содержимому

API

У выполнения API только один класс: Phasis\Engine. Всё остальное (JsValue, JsFunction, JsObject, …) относится к внутреннему устройству движка и доступно только хост-функциям, которые обеспечивают взаимодействие между PHP↔JS. Ещё одна публичная поверхность — отдельный парсер 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, семейство буферов массивов, 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, если программа не заканчивается выражением). Правила описаны в разделе Преобразование значений.

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. См. раздел Исключения.

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

Загрузчик разрешает только относительные и абсолютные пути; см. раздел Известные ограничения.

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); варианты обхода описаны в разделе Общие объекты.

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

Любое другое имя молча игнорируется; ограничений на длину строк, вывод в консоль или время выполнения нет.

Лимит циклов пока применяется не везде. Циклы, выполняемые на виртуальной машине байт-кода, а это тела функций и программы верхнего уровня, принимаемые компилятором байт-кода, не учитываются, поэтому while (true) {} работает, пока его не остановит PHP (issue #19). Не рассчитывайте, что этот лимит остановит недоверенный код; используйте PHP max_execution_time или отдельный процесс.

При достижении лимита ошибку можно перехватить с помощью JS try / catch (как Error с сообщением "Maximum loop iterations exceeded"). Если ее никто не перехватит, она дойдет до PHP как Phasis\Exceptions\InternalError, наследующий Phasis\Exceptions\RuntimeError, а не JsThrowable.

В движке зашиты еще два предела, которые нельзя настроить во время выполнения: Phasis\Runtime\CallStack::maxDepth (4,096 кадров JS) и ограничение глубины транспилированных замыканий JsToPhp (8,192 кадров PHP). При превышении обоих возникает ошибка "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) на верхнем уровне, выполняет cb до возврата из eval() — цикл использует виртуальное время: часы переходят к следующему запланированному сроку без ожидания 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(), использующим ext-curl из PHP в качестве транспорта. Три перехватчика позволяют заменить транспорт, проверять запросы или включить хранение файлов cookie. Каждый принимает функцию PHP или метод объекта; описание формата значений см. в разделе Веб-API.

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 обходится недорого (около нескольких миллисекунд при холодном запуске), стандартная библиотека устанавливается лениво. Для большинства сценариев встраивания достаточно одного долго работающего движка на процесс. При использовании несколькими арендаторами создавайте отдельный движок для каждого арендатора или запроса, чтобы глобальные переменные между ними не утекали. Сейчас надежнее всего создать новый движок для чистого состояния (см. reset).

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