API
De uitvoeringsAPI is één klasse: Phasis\Engine. Al het overige (JsValue, JsFunction, JsObject, …) is intern voor de engine en wordt alleen beschikbaar gemaakt voor hostfuncties die PHP↔JS overbruggen. Het enige andere openbare oppervlak is de zelfstandige parserAPI (Phasis\Parser\Parser en de Phasis\Ast-klassen), die broncode parseert zonder die uit te voeren.
Constructie
use Phasis\Engine;
$engine = new Engine(); // lazy mode (default)
$engine = new Engine(eager: true); // install every built-in up front
Een nieuwe Engine stelt de volledige standaardbibliotheek beschikbaar: Object, Array, String, Number, Symbol, BigInt, Promise, RegExp, Error en de kernintrinsics van de taal worden direct geïnstalleerd, zodat de specificatie werkt (je kunt geen classes hebben zonder Function.prototype, geen async/await zonder Promise en geen regex-literals zonder RegExp). Al het overige — Math, JSON, Map, Set, Date, TypedArray en de array-bufferfamilie, Atomics, Proxy, Reflect, console, Intl, de weak-ref-familie, Temporal, ShadowRealm, het Web Platform Pack en het Fetch Pack — wordt geregistreerd als een accessor met een plaatshouder en wordt aangemaakt bij de eerste uitlezing. Het uitlezen van de globale variabele verloopt transparant: Map, typeof Map, 'Map' in globalThis, Object.keys(globalThis).includes('Map') werken allemaal, en na de eerste uitlezing wordt de plaatshouder vervangen door een gewone data descriptor.
Geef eager: true mee als je specifiek het gedrag van vóór lazy loading nodig hebt: elke ingebouwde functie wordt bij de constructie geïnstalleerd en de volledige descriptor is zichtbaar bij introspectie. De conformiteitsharness (bin/test262, bin/wpt) maakt engines aan in eager mode, zodat test262/WPT de verwachte descriptorvormen zien.
eval
public function eval(string $source): mixed
Parseert $source en voert het uit als een klassiek script in de globale scope van de engine; daarna wordt de event loop uitgevoerd totdat die leeg is. De waarde van de laatst geëvalueerde expressie wordt teruggegeven, omgezet naar PHP (of null als het programma geen afsluitende expressie had). Zie Waarden omzetten voor de regels.
$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)
Een Promise wordt niet uitgepakt: eval('Promise.resolve(5)') geeft een lege array terug. Omdat de event loop is leeggemaakt wanneer eval() terugkeert, sla je de afgehandelde waarde op in een globale variabele en lees je die uit met een tweede aanroep van eval():
$engine->eval('let out; (async () => { out = await Promise.resolve(9); })();');
$engine->eval('out'); // 9
Een expliciete JS-throw die nergens wordt opgevangen, verschijnt als Phasis\Exceptions\JsThrowable, met als bericht "Name: message" en met de gegooide JsValue in de property $jsValue. Fouten die de engine zelf genereert, bijvoorbeeld bij het uitlezen van een property van null of van een niet-gedefinieerde variabele, verschijnen als Phasis\Exceptions\TypeError, ReferenceError of RangeError. Deze zijn allemaal afgeleid van Phasis\Exceptions\RuntimeError; parsefouten van Phasis\Exceptions\SyntaxError. Zie Uitzonderingen.
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
Evalueert $source als een ES-module: import / export, top-level await en import.meta zijn beschikbaar en relatieve specifiers worden opgelost ten opzichte van $path (een absoluut bestandspad). De event loop wordt net als bij eval() uitgevoerd en er wordt altijd null teruggegeven; lees resultaten via globale variabelen of de consolebuffer.
$path = __DIR__ . '/entry.mjs';
$engine->evalAsModule(file_get_contents($path), $path);
De loader lost alleen relatieve en absolute paden op; zie Bekende beperkingen.
execFile
public function execFile(string $path): mixed
Leest een bestand van schijf en geeft de inhoud door aan eval(), zodat die als klassiek script wordt uitgevoerd, ongeacht de extensie. Een bestand met import / export veroorzaakt een SyntaxError. Het werkelijke pad van het bestand wordt de basis voor dynamische specifiers van import(). Gebruik evalAsModule() voor modulebestanden.
$engine->execFile(__DIR__ . '/script.js');
Als het bestand niet kan worden gelezen, geeft PHP een waarschuwing en gooit execFile() een gewone \RuntimeException ("Cannot read file: …").
setGlobal
public function setGlobal(string $name, mixed $value): void
Koppelt een globale variabele die zichtbaar is voor alle volgende evaluaties van JS. Waarden van PHP worden omgezet naar JS:
| Type PHP | Type JS |
|---|---|
null |
null |
bool |
boolean |
int / float |
number |
string |
string (ook als het een functie bij naam noemt, zoals 'strlen') |
| array (lijst) | Array (een kopie) |
| array (associatief) | Object (een kopie) |
Closure, aanroepbaar object, [$object, 'method'] |
function |
| elk ander object, generator of resource | undefined |
Arrays worden gekopieerd: wijzigingen die JS erin aanbrengt, zijn niet zichtbaar in de array van PHP. Gewone objecten van PHP kunnen nog niet worden doorgegeven (issue #18); zie Gedeelde objecten voor oplossingen.
$engine->setGlobal('config', ['debug' => true, 'version' => '1.0']);
$engine->setGlobal('log', fn(string $msg) => error_log($msg));
Binnen JS:
console.log(config.version); // "1.0"
log('hello'); // calls the PHP closure
call
public function call(string $name, mixed ...$args): mixed
Roept een globale functie van JS aan met PHP-argumenten (omgezet zoals bij setGlobal). De geretourneerde waarde wordt terug omgezet naar PHP. Een naam aanroepen die niet is gedefinieerd, veroorzaakt Phasis\Exceptions\ReferenceError; een globale variabele aanroepen die geen functie is, veroorzaakt Phasis\Exceptions\TypeError.
$engine->eval('function add(a, b) { return a + b; }');
echo $engine->call('add', 2, 3); // 5
Console-uitvoer
public function getConsoleOutput(): string
public function getConsoleLines(): array
public function clearConsole(): void
console.log, console.info, console.warn, console.error en console.debug worden niet afgedrukt. Elke aanroep voegt één opgemaakte regel toe aan een buffer in de engine. getConsoleLines() geeft de regels in de buffer als een lijst met tekenreeksen terug, getConsoleOutput() voegt ze samen met \n ertussen en clearConsole() leegt de buffer. De buffer wordt niet gewist tussen aanroepen van 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
Stelt een tijdens runtime aanpasbare resourcelimiet in. Er is er precies één:
| Limiet | Standaard | Beschrijving |
|---|---|---|
maxLoopIterations |
100000 |
maximaal aantal iteraties per uitvoering van een for / while / do…while-lus door de tree-walking-interpreter |
$engine->setLimit('maxLoopIterations', 1_000_000);
Elke andere naam wordt stilzwijgend genegeerd; er zijn geen limieten voor tekenreekslengte, console-uitvoer of wandkloktijd.
De luslimiet wordt nog niet overal afgedwongen. Lussen die op de bytecode-VM draaien, waaronder functie-bodies en topprogramma's die de bytecodecompiler accepteert, worden nooit geteld. Daarom draait while (true) {} door totdat PHP het stopt (issue #19). Vertrouw er niet op om niet-vertrouwde code te stoppen; gebruik de max_execution_time van PHP of een afzonderlijk proces.
Wanneer de limiet wordt bereikt, kan de fout worden opgevangen door een JS-try / catch (als een Error met het bericht "Maximum loop iterations exceeded"). Als niets de fout opvangt, bereikt die PHP als Phasis\Exceptions\InternalError, dat is afgeleid van Phasis\Exceptions\RuntimeError, niet van JsThrowable.
Twee extra bovengrenzen zijn in de engine ingebouwd en kunnen niet tijdens runtime worden aangepast: Phasis\Runtime\CallStack::maxDepth (4,096 frames van JS) en de dieptebewaking voor JsToPhp-transpiled closures (8,192 frames van PHP). Bij overschrijding gooien beide "Maximum call stack size exceeded"; ook die fout bereikt PHP als InternalError. De grenzen liggen ruim boven wat een legitiem JS-programma nodig heeft; verhogen vereist een codewijziging en kan niet via configuratie.
reset
public function reset(): void
Wist alle door de gebruiker gedefinieerde globale variabelen en de consolebuffer, en bouwt de standaardbibliotheek en de event loop opnieuw op.
Bij een engine die in de standaardmodus met lazy loading is gemaakt, verliest reset() momenteel alle ingebouwde functies die lazy zijn geïnstalleerd. Daarna zijn Map, JSON, Math, console, fetch en de rest niet gedefinieerd (issue #22). Maak tot dit is opgelost liever een nieuwe Engine aan, of maak er een aan met eager: true; dan werkt reset() wel.
Eventloop
De engine wordt geleverd met een JavaScript-eventloop met setTimeout, setInterval, clearTimeout, clearInterval en queueMicrotask op het globale object. Microtasks (Promise-handlers, queueMicrotask) worden tussen elke macrotask afgehandeld; timers worden op deadlinevolgorde uitgevoerd via een taakwachtrij per realm.
public function runEventLoop(): void
public function tickEventLoop(): bool
public function pendingTaskCount(): int
public function getEventLoop(): \Phasis\Runtime\EventLoop
eval() en evalAsModule() roepen runEventLoop() automatisch aan nadat de synchrone code is voltooid. Een script dat op topniveau setTimeout(cb, 5000) plant, wordt dus uitgevoerd cb voordat eval() terugkeert — de eventloop gebruikt virtuele tijd en verzet de klok naar de volgende openstaande deadline in plaats van PHP te wachten. Komt overeen met de semantiek van node script.js.
Voor realtime-inbedding (langlopende servers, integratie met UI, planning op basis van een echte klok) voert tickEventLoop() één iteratie uit (microtasks afhandelen, eventuele timers uitvoeren) en keert terug. pendingTaskCount() meldt openstaande timers; een embedder kan zijn eigen eventloop aansturen:
while ($engine->pendingTaskCount() > 0) {
$engine->tickEventLoop();
usleep(1000);
}
Een op hol geslagen setInterval wordt begrensd: runEventLoop() stopt na 100,000 cycli en gooit een gewone \RuntimeException — de fout in de code van JS wordt zichtbaar als een duidelijke PHP-fout in plaats van als een vastloper. Het interval blijft ingepland, dus elke latere eval() op dezelfde engine gooit dezelfde fout; werp de engine daarna weg. Niet-opgevangen fouten in een timercallback worden via error_log() doorgegeven en de eventloop blijft taken afhandelen; één slechte timer stopt het programma niet.
Fetch-hooks
Phasis wordt standaard geleverd met fetch() ingeschakeld en gebruikt PHP's ext-curl als onderliggend transport. Met drie hooks kan een embedder het transport vervangen, verzoeken filteren of cookieopslag inschakelen. Alle drie accepteren een callable van het type PHP of een methode op een object; zie Web-API's voor de contractvorm van de waarden.
setFetchTransport
public function setFetchTransport(callable $transport): void
Vervang de standaard CurlTransport. De callable ontvangt een verzoekdescriptor en een optioneel AbortSignal JsObject; en retourneert een responsdescriptor.
// 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(),
];
});
Een veelgebruikte manier om dit in te bedden: installeer een mocktransport in tests en het echte curl-transport in productie.
setFetchPolicy
public function setFetchPolicy(callable $hook): void
Beleids-hook vóór verzending. Ontvangt de Request JsObject; kan een fout gooien om te weigeren, een aangepaste Request retourneren om het verzoek te herschrijven, of null/geen waarde retourneren om toe te staan.
// 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
Optionele cookieopslag. Accepteert elk object dat get(string $url): string en set(string $url, string $header): void beschikbaar stelt — aan de PHP- of JS-kant.
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());
Zonder setCookieJar() negeert fetch zowel inkomende Set-Cookie- als uitgaande Cookie-headers — standaard zijn er geen cookies.
Uitzonderingen
| Klasse | Uitbreiding van | Wanneer |
|---|---|---|
Phasis\Exceptions\JsThrowable |
RuntimeError |
een expliciete JS throw die niet is opgevangen |
Phasis\Exceptions\TypeError, ReferenceError, RangeError |
RuntimeError |
een fout die de engine zelf heeft veroorzaakt (bijvoorbeeld null.x of een ongedefinieerde variabele) en die niet is opgevangen |
Phasis\Exceptions\InternalError |
RuntimeError |
de looplimiet of de maximale aanroepstack is overschreden |
Phasis\Exceptions\RuntimeError |
\RuntimeException |
basisklasse van bovenstaande klassen |
Phasis\Parser\ParseError |
Phasis\Exceptions\SyntaxError (\RuntimeException) |
de broncode kan niet worden geparseerd |
\RuntimeException |
execFile() kan het bestand niet lezen; de eventloop heeft de limiet van 100,000 cycli bereikt |
Vang elke fout van een script op met Phasis\Exceptions\RuntimeError en Phasis\Exceptions\SyntaxError, of gebruik eenvoudigweg \RuntimeException.
Uitzonderingen die door je eigen PHP-hostfuncties worden gegooid, werken anders: ze gaan momenteel ongewijzigd door JS try / catch en bereiken de aanroeper van eval() met hun oorspronkelijke klasse (issue #20). Zie Hostfuncties.
Levenscyclus
Engine is snel te construeren (~enkele ms bij een koude start) en de standaardbibliotheek wordt pas geladen wanneer die nodig is. Voor de meeste inbeddingsscenario's volstaat één langlevende engine per proces. Geef bij gebruik met meerdere tenants elke tenant of elk verzoek een eigen engine, zodat globale variabelen niet tussen tenants lekken. Een nieuwe engine maken is momenteel de betrouwbare manier om met een schone lei te beginnen (zie reset).
foreach ($requests as $req) {
$engine = new Engine();
$engine->setGlobal('request', ['path' => $req->path, 'query' => $req->query]);
$engine->execFile($req->scriptPath);
}