Към съдържанието

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, async/await без Promise или regex литерали без 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) създава енджините в нетърпелив режим, така че 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);

Всяко друго име се игнорира безшумно; няма ограничения за дължината на низовете, изхода на конзолата или времето за изпълнение.

Ограничението за цикли все още не се прилага навсякъде. Циклите, изпълнявани от байткод VM — включително в телата на функции и програмите от най-горно ниво, приети от компилатора за байткод — не се броят, затова while (true) {} се изпълнява, докато PHP не го спре (проблем № 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. Три обработчика позволяват на вграждащия код да смени транспорта, да контролира заявките или да включи съхранението на бисквитки. И трите приемат извикваем обект от тип 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

Хранилище за бисквитки, което се включва изрично. Приема всеки обект с 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 — по подразбиране няма бисквитки.

Изключения

Клас Наследява Кога
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 е евтино (~няколко ms при първо стартиране), а стандартната библиотека се зарежда отложено. За повечето сценарии на вграждане е достатъчна една дългосрочно работеща машина за процес. При използване от няколко клиента създавайте отделна машина за всеки клиент или заявка, за да не изтичат глобални променливи между тях. Днес най-надеждният начин да започнете начисто е да създадете нова машина (вижте нулиране).

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