Preskočiť na obsah

API

Vykonávacie API tvorí jedna trieda: Phasis\Engine. Všetko ostatné (JsValue, JsFunction, JsObject, …) je interné pre engine a sprístupňuje sa len hostiteľským funkciám, ktoré prepájajú PHP↔JS. Jediným ďalším verejným rozhraním je samostatný parser API (Phasis\Parser\Parser a triedy Phasis\Ast), ktorý parsuje zdrojový kód bez jeho vykonania.

Vytvorenie

PHP
use Phasis\Engine;

$engine = new Engine();                 // lazy mode (default)
$engine = new Engine(eager: true);      // install every built-in up front

Čerstvá inštancia Engine sprístupňuje celú štandardnú knižnicu — Object, Array, String, Number, Symbol, BigInt, Promise, RegExp, Error a základné jazykové intrinzické funkcie sa inštalujú okamžite, aby fungovala špecifikácia (bez Function.prototype nemôžete mať triedy, bez Promise async/await a bez RegExp regulárne výrazy v literáloch). Všetko ostatné — Math, JSON, Map, Set, Date, TypedArray a rodina array-buffer, Atomics, Proxy, Reflect, console, Intl, rodina weak-ref, Temporal, ShadowRealm, Web Platform Pack a Fetch Pack — sa registruje ako zástupný prístupový prvok a vytvorí sa až pri prvom čítaní. Čítanie globálnej premennej je transparentné: Map, typeof Map, 'Map' in globalThis, Object.keys(globalThis).includes('Map') fungujú a po prvom čítaní sa zástupný prvok nahradí bežným opisovačom dátovej vlastnosti.

Ak potrebujete správanie spred zavedenia lenivého načítavania, zadajte eager: true — všetky vstavané funkcie sa nainštalujú pri vytvorení a ich úplné deskriptory sú dostupné na introspekciu. Testovací rámec zhody (bin/test262, bin/wpt) vytvára enginy v režime eager, aby test262/WPT videli očakávané tvary deskriptorov.

eval

PHP
public function eval(string $source): mixed

Parsuje a vykoná $source ako klasický skript v globálnom rozsahu enginu a potom spúšťa event loop, kým sa nevyprázdni. Vráti hodnotu posledného vyhodnoteného výrazu prevedenú na PHP (alebo null, ak program nemal záverečný výraz). Pravidlá nájdete v časti Prevod hodnôt.

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 sa nerozbaľuje: eval('Promise.resolve(5)') vráti prázdne pole. Keďže event loop je vyčerpaný v čase, keď sa eval() vráti, uložte vyriešenú hodnotu do globálnej premennej a načítajte ju druhým volaním eval():

PHP
$engine->eval('let out; (async () => { out = await Promise.resolve(9); })();');
$engine->eval('out');   // 9

Ak explicitné JS throw nič nezachytí, prejaví sa ako Phasis\Exceptions\JsThrowable, ktorého správa je "Name: message" a vlastnosť $jsValue obsahuje vyhodenú hodnotu JsValue. Chyby, ktoré vyvolá samotný engine, napríklad čítanie vlastnosti hodnoty null alebo nedefinovanej premennej, sa namiesto toho prejavia ako Phasis\Exceptions\TypeError, ReferenceError alebo RangeError. Všetky tieto triedy dedia od Phasis\Exceptions\RuntimeError; chyby parsovania dedia od Phasis\Exceptions\SyntaxError. Pozrite si Výnimky.

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

Vyhodnotí $source ako modul ES: k dispozícii sú import / export a najvyššia úroveň await a import.meta; relatívne špecifikátory sa rozlišujú voči $path (absolútnej ceste k súboru). Event loop sa spúšťa ako pri eval() a vždy sa vráti null; výsledky čítajte z globálnych premenných alebo vyrovnávacej pamäte konzoly.

PHP
$path = __DIR__ . '/entry.mjs';
$engine->evalAsModule(file_get_contents($path), $path);

Načítavač rozlišuje iba relatívne a absolútne cesty; pozrite si Známe obmedzenia.

execFile

PHP
public function execFile(string $path): mixed

Načíta súbor z disku a odovzdá jeho obsah do eval(), takže sa spustí ako klasický skript bez ohľadu na príponu; súbor s import / export zlyhá s chybou SyntaxError. Skutočná cesta k súboru sa stane základom pre dynamické špecifikátory import(). Pre súbory modulov použite evalAsModule().

PHP
$engine->execFile(__DIR__ . '/script.js');

Ak sa súbor nedá prečítať, PHP vydá varovanie a execFile() vyvolá obyčajnú chybu \RuntimeException ("Cannot read file: …").

setGlobal

PHP
public function setGlobal(string $name, mixed $value): void

Naviaže globálnu premennú viditeľnú vo všetkých nasledujúcich vyhodnoteniach JS. Hodnoty PHP sa prevedú na JS:

typ PHP typ JS
null null
bool boolean
int / float number
string string (aj keď pomenúva funkciu, napríklad 'strlen')
pole (zoznam) Array (kópia)
pole (asociatívne) Object (kópia)
Closure, volateľný objekt, [$object, 'method'] function
akýkoľvek iný objekt, generátor alebo prostriedok undefined

Polia sa kopírujú: zmeny, ktoré v nich vykoná JS, sa v poli PHP neprejavia. Jednoduché objekty PHP zatiaľ nemožno odovzdať (issue #18); alternatívne riešenia nájdete v časti Zdieľané objekty.

PHP
$engine->setGlobal('config', ['debug' => true, 'version' => '1.0']);
$engine->setGlobal('log', fn(string $msg) => error_log($msg));

V rámci JS:

JavaScript
console.log(config.version);   // "1.0"
log('hello');                  // calls the PHP closure

call

PHP
public function call(string $name, mixed ...$args): mixed

Zavolá globálnu funkciu JS s argumentmi PHP (prevedenými rovnako ako pri setGlobal). Návratová hodnota sa prevedie späť na PHP. Volanie nedefinovaného názvu vyvolá Phasis\Exceptions\ReferenceError; volanie globálnej premennej, ktorá nie je funkciou, vyvolá Phasis\Exceptions\TypeError.

PHP
$engine->eval('function add(a, b) { return a + b; }');
echo $engine->call('add', 2, 3);   // 5

Výstup konzoly

PHP
public function getConsoleOutput(): string
public function getConsoleLines(): array
public function clearConsole(): void

console.log, console.info, console.warn, console.error a console.debug nič nevypisujú. Každé volanie pridá do vyrovnávacej pamäte enginu jeden naformátovaný riadok. getConsoleLines() vráti uložené riadky ako zoznam reťazcov, getConsoleOutput() ich vráti spojené pomocou \n a clearConsole() vyprázdni pamäť. Pamäť sa medzi volaniami eval() nevymaže.

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

Nastaví limit zdrojov, ktorý možno meniť za behu. Existuje presne jeden:

Limit Predvolená hodnota Opis
maxLoopIterations 100000 maximálny počet iterácií na jedno spustenie slučky for / while / do…while v interpreteri so stromovým prechodom
PHP
$engine->setLimit('maxLoopIterations', 1_000_000);

Každý iný názov sa potichu ignoruje; neexistujú limity dĺžky reťazca, výstupu konzoly ani času behu.

Limit slučky sa zatiaľ nevynucuje všade. Počet iterácií slučiek vykonávaných vo VM bajtkódu, ktorý pokrýva telá funkcií a programy najvyššej úrovne prijaté kompilátorom bajtkódu, sa nikdy nesleduje, takže while (true) {} beží, kým ho nezastaví PHP (issue #19). Nespoliehajte sa naň pri zastavovaní nedôveryhodného kódu; použite PHP s max_execution_time alebo samostatný proces.

Po dosiahnutí limitu môže chybu zachytiť blok JS try / catch (ako Error so správou "Maximum loop iterations exceeded"). Ak ju nič nezachytí, dostane sa do PHP ako Phasis\Exceptions\InternalError, ktorý dedí od Phasis\Exceptions\RuntimeError, nie od JsThrowable.

V engine sú pevne nastavené aj dva ďalšie limity, ktoré nemožno meniť za behu: Phasis\Runtime\CallStack::maxDepth (4,096 rámcov JS) a ochranný limit hĺbky transpilo­vaných uzáverov JsToPhp (8,192 rámcov PHP). Pri prekročení oba vyvolajú "Maximum call stack size exceeded", ktorá sa prejaví aj v PHP ako InternalError. Tieto limity sú výrazne vyššie než pri akomkoľvek legitímnom programe JS — ich zvýšenie si vyžaduje zmenu kódu, nie nastavenia.

reset

PHP
public function reset(): void

Vymaže všetky globálne premenné definované používateľom a vyrovnávaciu pamäť konzoly a znova vytvorí štandardnú knižnicu aj event loop.

V engine vytvorenom v predvolenom lenivom režime reset() momentálne odstráni všetky lenivo nainštalované vstavané funkcie: potom sú Map, JSON, Math, console, fetch a ostatné nedefinované (issue #22). Kým sa to neopraví, vytvorte radšej novú inštanciu Engine alebo ju vytvorte s eager: true, kde reset() funguje.

Event loop

Engine obsahuje vlastný event loop JavaScript s setTimeout, setInterval, clearTimeout, clearInterval a queueMicrotask na globálnom objekte. Mikrotasky (obslužné rutiny Promise, queueMicrotask) sa vyprázdnia medzi každou makroúlohou; časovače sa spúšťajú v poradí termínov pomocou frontu úloh pre každú realm.

PHP
public function runEventLoop(): void
public function tickEventLoop(): bool
public function pendingTaskCount(): int
public function getEventLoop(): \Phasis\Runtime\EventLoop

eval() a evalAsModule() automaticky zavolajú runEventLoop() po dokončení synchrónneho kódu. Skript, ktorý naplánuje setTimeout(cb, 5000) na najvyššej úrovni, teda spustí cb ešte pred návratom eval() — loop používa virtuálny čas a posunie hodiny na najbližší čakajúci termín namiesto PHP. Zodpovedá sémantike node script.js.

Pri integrácii v reálnom čase (dlho bežiace servery, integrácia UI, plánovanie podľa skutočných hodín) spustí tickEventLoop() jednu iteráciu (vyprázdni mikrotasky, spustí všetky časovače, ktorých termín nastal) a vráti sa. pendingTaskCount() oznamuje zostávajúce časovače; integrátor si môže vytvoriť vlastný cyklus:

PHP
while ($engine->pendingTaskCount() > 0) {
    $engine->tickEventLoop();
    usleep(1000);
}

Nekonečne bežiaci setInterval má stanovený limit: runEventLoop() sa zastaví po 100,000 cykloch a vyvolá obyčajnú chybu \RuntimeException — chyba v kóde JS sa prejaví ako zrozumiteľná chyba PHP namiesto zablokovania. Interval zostáva naplánovaný, takže každé ďalšie volanie eval() v tom istom engine vyvolá rovnakú chybu; engine potom zahoďte. Nezachytené chyby v spätnom volaní časovača sa odošlú cez error_log() a loop pokračuje vo vyprázdňovaní frontu; jedna chybná úloha nezastaví program.

Háčiky Fetch

Phasis štandardne obsahuje zapnutú funkciu fetch(), ktorá ako transport používa ext-curl z PHP. Tri háčiky umožňujú nahradiť transport, filtrovať požiadavky alebo zapnúť ukladanie súborov cookie. Každý prijíma volateľný prvok PHP alebo metódu objektu; pravidlá tvaru hodnôt nájdete v časti Web API.

setFetchTransport

PHP
public function setFetchTransport(callable $transport): void

Nahradí predvolený CurlTransport. Volateľný prvok prijíma opisovač požiadavky a voliteľný AbortSignal JsObject; vracia opisovač odpovede.

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(),
    ];
});

Bežný vzor integrácie: v testoch nainštalovať falošný transport a v produkcii skutočný curl transport.

setFetchPolicy

PHP
public function setFetchPolicy(callable $hook): void

Háčik politiky pred odoslaním požiadavky. Prijíma Request JsObject; odmietne ju vyhodením výnimky, prepíše vrátením upraveného Request alebo povolí vrátením null či bez hodnoty.

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

Voliteľné úložisko súborov cookie. Prijíma ľubovoľný objekt so get(string $url): string a set(string $url, string $header): void — na strane PHP alebo 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());

Bez setCookieJar() fetch ignoruje prichádzajúce hlavičky Set-Cookie aj odchádzajúce hlavičky Cookie — predvolene sa nepoužívajú žiadne súbory cookie.

Výnimky

Trieda Dedi od Kedy
Phasis\Exceptions\JsThrowable RuntimeError explicitné JS throw, ktoré nič nezachytilo
Phasis\Exceptions\TypeError, ReferenceError, RangeError RuntimeError chyba vyvolaná samotným enginom (napríklad null.x alebo nedefinovaná premenná), ktorú nič nezachytilo
Phasis\Exceptions\InternalError RuntimeError bol prekročený limit slučky alebo limit zásobníka volaní
Phasis\Exceptions\RuntimeError \RuntimeException základná trieda vyššie uvedených tried
Phasis\Parser\ParseError Phasis\Exceptions\SyntaxError (\RuntimeException) zdrojový kód sa nedá parsovať
\RuntimeException execFile() nedokáže prečítať súbor; event loop dosiahol limit 100,000 cyklov

Ak chcete zachytiť každú chybu skriptu, zachyťte Phasis\Exceptions\RuntimeError a Phasis\Exceptions\SyntaxError alebo jednoducho \RuntimeException.

Výnimky vyvolané vo vlastných hostiteľských funkciách PHP sú odlišné: v súčasnosti prechádzajú cez JS try / catch a dostanú sa nezmenené k volajúcemu eval(), pričom si zachovajú pôvodnú triedu (problém č. 20). Pozrite si Hostiteľské funkcie.

Životný cyklus

Vytvorenie Engine je nenáročné (za studena trvá približne niekoľko ms) a štandardná knižnica sa načítava až na požiadanie. Pri väčšine scenárov vloženia stačí jeden dlho žijúci engine na proces. Pri použití s viacerými nájomníkmi vytvorte pre každého nájomníka alebo požiadavku vlastný engine, aby medzi nimi neunikali globálne premenné. Vytvorenie nového enginu je v súčasnosti spoľahlivý spôsob, ako začať s čistým stavom (pozrite si reset).

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