API
Середовище виконання має один клас API: Phasis\Engine. Усе інше (JsValue, JsFunction, JsObject, …) є внутрішньою частиною рушія й доступне лише хост-функціям, що з’єднують PHP↔JS. Інша публічна поверхня — окремий парсер API (Phasis\Parser\Parser і класи Phasis\Ast), який розбирає вихідний код без виконання.
Створення
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 бачили очікувані форми дескрипторів.
оцінювання
public function eval(string $source): mixed
Розбирає та виконує $source як класичний скрипт у глобальній області видимості рушія, а потім запускає цикл подій до його спорожнення. Повертає значення останнього обчисленого виразу, перетворене на PHP (або null, якщо в програмі немає завершального виразу). Правила описано в розділі Перетворення значень.
$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():
$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. Див. Винятки.
try {
$engine->eval('throw new Error("boom")');
} catch (\Phasis\Exceptions\JsThrowable $e) {
echo $e->getMessage();
// Error: boom
}
evalAsModule
public function evalAsModule(string $source, ?string $path = null): mixed
Обчислює $source як модуль ES: доступні import / export, конструкції верхнього рівня await і import.meta, а відносні специфікатори розв’язуються відносно $path (абсолютного шляху до файлу). Цикл подій працює як у eval(); метод завжди повертає null. Щоб прочитати результати, використовуйте глобальні змінні або буфер консолі.
$path = __DIR__ . '/entry.mjs';
$engine->evalAsModule(file_get_contents($path), $path);
Завантажувач розв’язує лише відносні й абсолютні шляхи; див. Відомі обмеження.
execFile
public function execFile(string $path): mixed
Читає файл із диска й передає його вміст до eval(), тож він запускається як класичний скрипт незалежно від розширення; файл із import / export спричинить SyntaxError. Реальний шлях до файлу стає базою для динамічних специфікаторів import(). Для файлів модулів використовуйте evalAsModule().
$engine->execFile(__DIR__ . '/script.js');
Якщо файл не вдається прочитати, PHP виводить попередження, а execFile() генерує звичайний \RuntimeException («Не вдалося прочитати файл: …»).
setGlobal
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); обхідні шляхи описано в розділі Спільні об’єкти.
$engine->setGlobal('config', ['debug' => true, 'version' => '1.0']);
$engine->setGlobal('log', fn(string $msg) => error_log($msg));
Усередині JS:
console.log(config.version); // "1.0"
log('hello'); // calls the PHP closure
виклик
public function call(string $name, mixed ...$args): mixed
Викликає глобальну функцію JS з аргументами PHP (перетвореними так само, як у setGlobal). Повернене значення перетворюється назад на PHP. Виклик невизначеного імені спричиняє Phasis\Exceptions\ReferenceError; виклик глобальної змінної, яка не є функцією, — Phasis\Exceptions\TypeError.
$engine->eval('function add(a, b) { return a + b; }');
echo $engine->call('add', 2, 3); // 5
Виведення консолі
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().
$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
public function setLimit(string $limit, int $value): void
Встановлює змінюваний під час виконання ліміт ресурсу. Такий ліміт лише один:
| Ліміт | Типове значення | Опис |
|---|---|---|
maxLoopIterations |
100000 |
максимальна кількість ітерацій за один запуск циклів for / while / do…while в інтерпретаторі з обходом дерева |
$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; їх можна збільшити зміною коду, а не параметра конфігурації.
скидання
public function reset(): void
Очищає всі визначені користувачем глобальні змінні й буфер консолі, а також повторно створює стандартну бібліотеку та цикл подій.
У рушії, створеному в типовому лінивому режимі, reset() наразі втрачає всі ліниво встановлені вбудовані засоби: після цього Map, JSON, Math, console, fetch та решта стають невизначеними (проблема #22). Поки це не виправлено, створіть натомість новий Engine або створіть його з eager: true, де reset() працює.
Цикл подій
Рушій постачається з циклом подій JavaScript, що містить setTimeout, setInterval, clearTimeout, clearInterval і queueMicrotask у глобальному об’єкті. Мікрозавдання (обробники Promise, queueMicrotask) виконуються між кожними макрозавданнями; таймери спрацьовують у порядку дедлайнів через чергу завдань окремого середовища.
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() повідомляє про таймери, що залишилися; вбудовувач може керувати власним циклом:
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
public function setFetchTransport(callable $transport): void
Замінює CurlTransport за замовчуванням. Викликаний об’єкт отримує дескриптор запиту й необов’язковий JsObject AbortSignal; повертає дескриптор відповіді.
// 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
public function setFetchPolicy(callable $hook): void
Перехоплювач політики перед надсиланням запиту. Отримує JsObject Request; може згенерувати помилку, щоб відхилити запит, повернути змінений Request для перезапису або повернути null чи нічого, щоб дозволити запит.
// 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
public function setCookieJar(mixed $jar): void
Сховище cookie, яке потрібно ввімкнути окремо. Приймає будь-який об’єкт із get(string $url): string і set(string $url, string $header): void — на боці PHP або JS.
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 обходиться дешево (кілька мс під час першого запуску), а стандартна бібліотека завантажується за потреби. Для більшості сценаріїв вбудовування достатньо одного довготривалого рушія на процес. Для роботи з кількома клієнтами створюйте окремий рушій для кожного клієнта або запиту, щоб глобальні змінні між ними не передавалися. Наразі надійний спосіб почати з чистого стану — створити новий рушій (див. скидання).
foreach ($requests as $req) {
$engine = new Engine();
$engine->setGlobal('request', ['path' => $req->path, 'query' => $req->query]);
$engine->execFile($req->scriptPath);
}