Перейти до вмісту

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 і сімейство 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) створює рушії в eager-режимі, щоб 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 («Не вдалося прочитати файл: …»).

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

Усі інші назви мовчки ігноруються; для довжини рядків, виведення консолі чи реального часу лімітів немає.

Ліміт циклів поки що застосовується не всюди. Цикли, що виконуються у байт-кодовій VM, зокрема цикли в тілах функцій і програмах верхнього рівня, які приймає компілятор байт-коду, не підраховуються. Тому while (true) {} працює, доки його не зупинить PHP (проблема #19). Не покладайтеся на цей ліміт для зупинки ненадійного коду; використовуйте PHP — max_execution_time або окремий процес.

Коли ліміт досягнуто, помилку можна перехопити за допомогою JS try / catch (як Error із повідомленням «Перевищено максимальну кількість ітерацій циклу»). Якщо її ніщо не перехопить, вона надійде до PHP як Phasis\Exceptions\InternalError, що успадковує Phasis\Exceptions\RuntimeError, а не JsThrowable.

У рушій вбудовано ще два ліміти, які не можна змінити під час виконання: Phasis\Runtime\CallStack::maxDepth (4,096 фреймів JS) і захист глибини транспільованих замикань JsToPhp (8,192 фреймів PHP). У разі перевищення обидва генерують помилку «Перевищено максимальний розмір стека викликів», яка також надходить до PHP як InternalError. Їхні значення значно перевищують потреби будь-якої легітимної програми JS; їх можна збільшити зміною коду, а не параметра конфігурації.

скидання

PHP
public function reset(): void

Очищає всі визначені користувачем глобальні змінні й буфер консолі, а також повторно створює стандартну бібліотеку та цикл подій.

У рушії, створеному в типовому лінивому режимі, reset() наразі втрачає всі ліниво встановлені вбудовані засоби: після цього Map, JSON, Math, console, fetch та решта стають невизначеними (проблема #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 за замовчуванням. Викликаний об’єкт отримує дескриптор запиту й необов’язковий JsObject AbortSignal; повертає дескриптор відповіді.

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

Перехоплювач політики перед надсиланням запиту. Отримує JsObject Request; може згенерувати помилку, щоб відхилити запит, повернути змінений 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). Див. розділ Хост-функції.

Життєвий цикл

Створення Engine обходиться дешево (кілька мс під час першого запуску), а стандартна бібліотека завантажується за потреби. Для більшості сценаріїв вбудовування достатньо одного довготривалого рушія на процес. Для роботи з кількома клієнтами створюйте окремий рушій для кожного клієнта або запиту, щоб глобальні змінні між ними не передавалися. Наразі надійний спосіб почати з чистого стану — створити новий рушій (див. скидання).

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