API
Die Ausführungs-API besteht aus einer Klasse: Phasis\Engine. Alles andere (JsValue, JsFunction, JsObject, …) ist intern und wird nur Host-Funktionen zugänglich gemacht, die zwischen PHP↔JS vermitteln. Die einzige weitere öffentliche Schnittstelle ist der eigenständige Parser API (Phasis\Parser\Parser und die Phasis\Ast-Klassen), der Quellcode parst, ohne ihn auszuführen.
Konstruktion
use Phasis\Engine;
$engine = new Engine(); // lazy mode (default)
$engine = new Engine(eager: true); // install every built-in up front
Eine neue Engine stellt die vollständige Standardbibliothek bereit – Object, Array, String, Number, Symbol, BigInt, Promise, RegExp, Error und die grundlegenden Sprachintrinsics werden sofort installiert, damit die Spezifikation funktioniert (ohne Function.prototype gibt es keine Klassen, ohne Promise kein async/await und ohne RegExp keine Regex-Literale). Alles Weitere – Math, JSON, Map, Set, Date, TypedArray und die ArrayBuffer-Familie, Atomics, Proxy, Reflect, console, Intl, die WeakRef-Familie, Temporal, ShadowRealm, das Web Platform Pack und das Fetch Pack – wird als Platzhalter-Accessor registriert und beim ersten Lesen angelegt. Das Lesen der globalen Variable ist transparent: Map, typeof Map, 'Map' in globalThis, Object.keys(globalThis).includes('Map') funktionieren alle, und nach dem ersten Zugriff wird der Platzhalter durch einen regulären Daten-Descriptor ersetzt.
Übergib eager: true, wenn du ausdrücklich das Verhalten vor der verzögerten Initialisierung brauchst – alle integrierten Funktionen werden beim Erzeugen mit der vollständigen Descriptor-Oberfläche für die Introspektion installiert. Die Konformitätstestumgebung (bin/test262, bin/wpt) erstellt Engines im Eager-Modus, damit test262/WPT die erwarteten Descriptor-Formen sieht.
eval
public function eval(string $source): mixed
Parst $source als klassisches Skript im globalen Gültigkeitsbereich der Engine, führt es aus und lässt anschließend die Ereignisschleife laufen, bis sie leer ist. Der Wert des zuletzt ausgewerteten Ausdrucks wird in PHP konvertiert zurückgegeben (oder null, wenn das Programm keinen abschließenden Ausdruck hatte). Die Regeln findest du unter Wertkonvertierung.
$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)
Ein Promise wird nicht entpackt: eval('Promise.resolve(5)') gibt ein leeres Array zurück. Da die Ereignisschleife bis zur Rückgabe von eval() abgearbeitet wurde, speichere den aufgelösten Wert in einer globalen Variable und lies ihn mit einem zweiten Aufruf von eval() aus:
$engine->eval('let out; (async () => { out = await Promise.resolve(9); })();');
$engine->eval('out'); // 9
Ein explizites JS-throw, das von nichts abgefangen wird, erscheint als Phasis\Exceptions\JsThrowable; dessen Meldung lautet "Name: message" und seine Eigenschaft $jsValue enthält das ausgelöste JsValue. Vom Engine selbst ausgelöste Fehler, etwa beim Zugriff auf eine Eigenschaft von null oder bei einer undefinierten Variable, erscheinen stattdessen als Phasis\Exceptions\TypeError, ReferenceError oder RangeError. Alle diese Klassen erweitern Phasis\Exceptions\RuntimeError; Parse-Fehler erweitern Phasis\Exceptions\SyntaxError. Siehe Ausnahmen.
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
Wertet $source als ES-Modul aus: import / export, Toplevel-await und import.meta stehen zur Verfügung, und relative Bezeichner werden relativ zu $path aufgelöst (ein absoluter Dateipfad). Die Ereignisschleife läuft wie bei eval(); zurückgegeben wird immer null. Lies Ergebnisse über globale Variablen oder den Konsolenpuffer aus.
$path = __DIR__ . '/entry.mjs';
$engine->evalAsModule(file_get_contents($path), $path);
Der Loader löst nur relative und absolute Pfade auf; siehe Bekannte Einschränkungen.
execFile
public function execFile(string $path): mixed
Liest eine Datei von der Festplatte und übergibt ihren Inhalt an eval(). Dadurch wird sie unabhängig von ihrer Dateiendung als klassisches Skript ausgeführt; eine Datei mit import / export schlägt mit einem SyntaxError fehl. Der tatsächliche Pfad der Datei dient als Basis für dynamische import()-Bezeichner. Verwende evalAsModule() für Moduldateien.
$engine->execFile(__DIR__ . '/script.js');
Kann die Datei nicht gelesen werden, gibt PHP eine Warnung aus und execFile() löst einen einfachen \RuntimeException-Fehler aus („Cannot read file: …“).
setGlobal
public function setGlobal(string $name, mixed $value): void
Bindet eine globale Variable, die bei allen nachfolgenden JS-Auswertungen sichtbar ist. PHP-Werte werden in JS konvertiert:
| Typ von PHP | JS-Typ |
|---|---|
null |
null |
bool |
boolean |
int / float |
number |
string |
string (auch wenn es eine Funktion bezeichnet, etwa 'strlen') |
| Array (Liste) | Array (Kopie) |
| Array (assoziativ) | Object (Kopie) |
Closure, aufrufbares Objekt, [$object, 'method'] |
function |
| jedes andere Objekt, jeder Generator oder jede Ressource | undefined |
Arrays werden kopiert: Änderungen, die JS daran vornimmt, sind im PHP-Array nicht sichtbar. Einfache PHP-Objekte lassen sich derzeit nicht übergeben (Issue #18); Workarounds findest du unter Gemeinsam genutzte Objekte.
$engine->setGlobal('config', ['debug' => true, 'version' => '1.0']);
$engine->setGlobal('log', fn(string $msg) => error_log($msg));
Innerhalb von JS:
console.log(config.version); // "1.0"
log('hello'); // calls the PHP closure
call
public function call(string $name, mixed ...$args): mixed
Ruft eine globale JS-Funktion mit PHP Argumenten auf (konvertiert wie bei setGlobal). Der Rückgabewert wird zurück in PHP konvertiert. Der Aufruf eines nicht definierten Namens löst Phasis\Exceptions\ReferenceError aus; ist eine globale Variable keine Funktion, löst der Aufruf Phasis\Exceptions\TypeError aus.
$engine->eval('function add(a, b) { return a + b; }');
echo $engine->call('add', 2, 3); // 5
Konsolenausgabe
public function getConsoleOutput(): string
public function getConsoleLines(): array
public function clearConsole(): void
console.log, console.info, console.warn, console.error und console.debug geben nichts aus. Jeder Aufruf fügt dem Puffer der Engine eine formatierte Zeile hinzu. getConsoleLines() gibt die gepufferten Zeilen als Liste von Zeichenfolgen zurück, getConsoleOutput() gibt sie zusammengefügt mit \n zurück, und clearConsole() leert den Puffer. Zwischen Aufrufen von eval() wird der Puffer nicht geleert.
$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
Legt ein zur Laufzeit anpassbares Ressourcenlimit fest. Es gibt genau eines:
| Limit | Standardwert | Beschreibung |
|---|---|---|
maxLoopIterations |
100000 |
maximale Iterationen pro Schleifendurchlauf von for / while / do…while im Tree-Walking-Interpreter |
$engine->setLimit('maxLoopIterations', 1_000_000);
Jeder andere Name wird stillschweigend ignoriert; es gibt keine Limits für Zeichenfolgenlänge, Konsolenausgabe oder Laufzeit.
Das Schleifenlimit wird noch nicht überall durchgesetzt. Schleifen, die auf der Bytecode-VM laufen, also Funktionsrümpfe und Programme auf oberster Ebene, die der Bytecode-Compiler akzeptiert, werden nie gezählt. Daher läuft while (true) {}, bis PHP es stoppt (Issue #19). Verlass dich nicht darauf, damit nicht vertrauenswürdigen Code zu stoppen; nutze PHP's max_execution_time oder einen separaten Prozess.
Wird das Limit erreicht, kann ein JS try / catch den Fehler abfangen (als Error mit der Meldung "Maximum loop iterations exceeded"). Fängt ihn nichts ab, erreicht er PHP als Phasis\Exceptions\InternalError, das Phasis\Exceptions\RuntimeError erweitert, nicht JsThrowable.
Zwei weitere Obergrenzen sind fest in der Engine verankert und nicht zur Laufzeit anpassbar: Phasis\Runtime\CallStack::maxDepth (4,096 JS-Frames) und die Tiefenbegrenzung für transpilierte JsToPhp-Closures (8,192 PHP-Frames). Bei Überschreitung werfen beide "Maximum call stack size exceeded"; der Fehler erreicht PHP ebenfalls als InternalError. Sie liegen weit über dem Bedarf jedes legitimen JS-Programms — sie zu erhöhen erfordert eine Codeänderung, keine Konfiguration.
reset
public function reset(): void
Löscht alle benutzerdefinierten globalen Variablen und den Konsolenpuffer und erstellt die Standardbibliothek sowie die Ereignisschleife neu.
Bei einer Engine, die im standardmäßigen Lazy-Modus erstellt wurde, verliert reset() derzeit alle verzögert installierten Built-ins: Danach sind Map, JSON, Math, console, fetch und alle weiteren nicht definiert (Issue #22). Bis das behoben ist, erstelle stattdessen eine neue Engine oder erstelle sie mit eager: true; dort funktioniert reset().
Ereignisschleife
Die Engine enthält eine JavaScript-Ereignisschleife mit setTimeout, setInterval, clearTimeout, clearInterval und queueMicrotask auf dem globalen Objekt. Microtasks (Promise-Handler, queueMicrotask) werden zwischen jedem Macrotask abgearbeitet; Timer werden nach Fälligkeit über eine Task-Warteschlange pro Realm ausgelöst.
public function runEventLoop(): void
public function tickEventLoop(): bool
public function pendingTaskCount(): int
public function getEventLoop(): \Phasis\Runtime\EventLoop
eval() und evalAsModule() rufen runEventLoop() automatisch auf, sobald der synchrone Code abgeschlossen ist. Ein Skript, das auf oberster Ebene setTimeout(cb, 5000) plant, führt daher cb aus, bevor eval() zurückkehrt. Die Schleife verwendet virtuelle Zeit und stellt ihre Uhr auf den nächsten anstehenden Zeitpunkt vor, statt PHP zu warten. Entspricht der Semantik von node script.js.
Für die Einbettung in Echtzeitanwendungen (lang laufende Server, UI-Integration, Planung anhand einer Echtzeituhr) führt tickEventLoop() eine Iteration aus (arbeitet Microtasks ab und löst fällige Timer aus) und kehrt zurück. pendingTaskCount() meldet ausstehende Timer; ein Embedder kann seine eigene Schleife steuern:
while ($engine->pendingTaskCount() > 0) {
$engine->tickEventLoop();
usleep(1000);
}
Eine außer Kontrolle geratene setInterval ist begrenzt: runEventLoop() wird nach 100,000 Zyklen abgebrochen und wirft ein einfaches \RuntimeException — der Fehler im JS-Code wird als klarer PHP-Fehler angezeigt, statt die Ausführung aufzuhängen. Das Intervall bleibt geplant, sodass jeder spätere Aufruf von eval() auf derselben Engine denselben Fehler wirft; verwirf die Engine danach. Nicht abgefangene Fehler in einem Timer-Callback werden über error_log() ausgegeben, und die Schleife arbeitet weiter. Ein fehlerhafter Timer beendet das Programm nicht.
Fetch-Hooks
Phasis enthält standardmäßig aktivierte fetch(); als zugrunde liegender Transport dient ext-curl von PHP. Drei Hooks ermöglichen es einem Embedder, den Transport auszutauschen, Anfragen zu steuern oder die Cookie-Speicherung zu aktivieren. Alle drei akzeptieren entweder ein aufrufbares PHP oder eine Methode auf einem Objekt. Siehe Web-APIs für den Vertrag zur Wertestruktur.
setFetchTransport
public function setFetchTransport(callable $transport): void
Ersetzt den standardmäßigen CurlTransport. Der Aufruf erhält einen Request-Deskriptor und ein optionales AbortSignal JsObject und gibt einen Response-Deskriptor zurück.
// 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(),
];
});
Ein gängiges Einbettungsmuster: In Tests wird ein Mock-Transport installiert, in der Produktion der echte Curl-Transport.
setFetchPolicy
public function setFetchPolicy(callable $hook): void
Hook für Richtlinienprüfungen vor der Anfrage. Erhält das Request JsObject; kann zum Ablehnen einen Fehler werfen, zum Umschreiben ein geändertes Request zurückgeben oder zum Zulassen null/keinen Wert zurückgeben.
// 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
Optionaler Cookie-Speicher. Akzeptiert jedes Objekt, das get(string $url): string und set(string $url, string $header): void bereitstellt — auf PHP- oder JS-Seite.
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());
Ohne setCookieJar() ignoriert fetch sowohl eingehende Set-Cookie- als auch ausgehende Cookie-Header — standardmäßig werden keine Cookies verwendet.
Ausnahmen
| Klasse | Erweitert | Wann |
|---|---|---|
Phasis\Exceptions\JsThrowable |
RuntimeError |
ein expliziter JS-throw, den nichts abgefangen hat |
Phasis\Exceptions\TypeError, ReferenceError, RangeError |
RuntimeError |
ein von der Engine selbst ausgelöster Fehler (zum Beispiel null.x oder eine nicht definierte Variable), den nichts abgefangen hat |
Phasis\Exceptions\InternalError |
RuntimeError |
das Schleifenlimit oder die Aufrufstapel-Obergrenze wurde überschritten |
Phasis\Exceptions\RuntimeError |
\RuntimeException |
Basisklasse der obigen Klassen |
Phasis\Parser\ParseError |
Phasis\Exceptions\SyntaxError (\RuntimeException) |
der Quelltext lässt sich nicht parsen |
\RuntimeException |
execFile() kann die Datei nicht lesen; die Ereignisschleife hat ihre Obergrenze von 100,000 Zyklen erreicht |
Um jeden Fehler eines Skripts abzufangen, fange Phasis\Exceptions\RuntimeError und Phasis\Exceptions\SyntaxError ab oder fange einfach \RuntimeException ab.
Ausnahmen, die von deinen eigenen PHP-Hostfunktionen ausgelöst werden, verhalten sich anders: Sie durchlaufen derzeit JS-try / catch und erreichen den Aufrufer von eval() unverändert und mit ihrer ursprünglichen Klasse (Issue #20). Siehe Hostfunktionen.
Lebenszyklus
Engine lässt sich schnell erstellen (kalt in wenigen ms), und die Standardbibliothek wird verzögert installiert. Für die meisten Einbettungsszenarien reicht eine langlebige Engine pro Prozess. Bei Nutzung durch mehrere Mandanten solltest du jedem Mandanten oder jeder Anfrage eine eigene Engine geben, damit keine globalen Variablen zwischen ihnen durchsickern. Eine neue Engine zu erstellen, ist derzeit der verlässlichste Neustart (siehe reset).
foreach ($requests as $req) {
$engine = new Engine();
$engine->setGlobal('request', ['path' => $req->path, 'query' => $req->query]);
$engine->execFile($req->scriptPath);
}