API
La API de ejecución consta de una sola clase: Phasis\Engine. Todo lo demás (JsValue, JsFunction, JsObject, …) es interno del motor y solo se expone a las funciones anfitrionas que conectan PHP↔JS. La otra interfaz pública es el analizador independiente API (las clases Phasis\Parser\Parser y Phasis\Ast), que analiza código fuente sin ejecutarlo.
Construcción
use Phasis\Engine;
$engine = new Engine(); // lazy mode (default)
$engine = new Engine(eager: true); // install every built-in up front
Una instancia nueva de Engine expone la biblioteca estándar completa: Object, Array, String, Number, Symbol, BigInt, Promise, RegExp, Error y las funciones intrínsecas del lenguaje base se instalan de inmediato para que la especificación funcione (no puede haber clases sin Function.prototype, async/await sin Promise ni literales de expresión regular sin RegExp). Todo lo demás —Math, JSON, Map, Set, Date, TypedArray y la familia de búferes de arrays, Atomics, Proxy, Reflect, console, Intl, la familia de referencias débiles, Temporal, ShadowRealm, Web Platform Pack y Fetch Pack— se registra como un accesor de marcador de posición y se materializa en la primera lectura. La lectura del objeto global es transparente: Map, typeof Map, 'Map' in globalThis y Object.keys(globalThis).includes('Map') funcionan, y tras la primera lectura el marcador de posición se sustituye por un descriptor de datos normal.
Pase eager: true si necesita específicamente el comportamiento anterior a la carga diferida: todas las funciones integradas se instalan al construir la instancia y toda la superficie de descriptores queda visible para la introspección. El arnés de conformidad (bin/test262, bin/wpt) construye los motores en modo inmediato para que test262/WPT vean las formas de descriptor esperadas.
eval
public function eval(string $source): mixed
Analiza y ejecuta $source como un script clásico en el ámbito global del motor y, después, ejecuta el bucle de eventos hasta vaciarlo. Devuelve el valor de la última expresión evaluada, convertido a PHP (o null si el programa no termina con una expresión). Consulte las reglas en Conversión de valores.
$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)
Una Promise no se desenvuelve: eval('Promise.resolve(5)') devuelve un array vacío. Como el bucle de eventos ya se ha agotado cuando termina eval(), guarde el valor resuelto en una variable global y léalo con un segundo eval():
$engine->eval('let out; (async () => { out = await Promise.resolve(9); })();');
$engine->eval('out'); // 9
Un JS throw explícito que no captura nada aparece como Phasis\Exceptions\JsThrowable, cuyo mensaje es "Name: message" y cuya propiedad $jsValue contiene el JsValue lanzado. Los errores que genera el propio motor, como leer una propiedad de null o usar una variable no definida, aparecen como Phasis\Exceptions\TypeError, ReferenceError o RangeError. Todos estos extienden Phasis\Exceptions\RuntimeError; los errores de análisis extienden Phasis\Exceptions\SyntaxError. Consulte Excepciones.
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
Evalúa $source como un módulo ES: están disponibles import / export y await y import.meta de nivel superior; los especificadores relativos se resuelven respecto a $path (una ruta de archivo absoluta). Ejecuta el bucle de eventos como eval() y siempre devuelve null; lea los resultados mediante variables globales o el búfer de la consola.
$path = __DIR__ . '/entry.mjs';
$engine->evalAsModule(file_get_contents($path), $path);
El cargador solo resuelve rutas relativas y absolutas; consulte Limitaciones conocidas.
execFile
public function execFile(string $path): mixed
Lee un archivo del disco y pasa su contenido a eval(), por lo que se ejecuta como script clásico, sea cual sea su extensión; un archivo con import / export produce un SyntaxError. La ruta real del archivo se convierte en la base para los especificadores import() dinámicos. Use evalAsModule() para archivos de módulos.
$engine->execFile(__DIR__ . '/script.js');
Si no se puede leer el archivo, PHP emite una advertencia y execFile() lanza un \RuntimeException simple ("Cannot read file: …").
setGlobal
public function setGlobal(string $name, mixed $value): void
Vincula una variable global visible en todas las evaluaciones posteriores de JS. Los valores de PHP se convierten a JS:
| Tipo de PHP | Tipo de JS |
|---|---|
null |
null |
bool |
boolean |
int / float |
number |
string |
string (incluso cuando nombra una función, como 'strlen') |
| array (lista) | Array (una copia) |
| array (asociativo) | Object (una copia) |
Closure, objeto invocable, [$object, 'method'] |
function |
| cualquier otro objeto, generador o recurso | undefined |
Los arrays se copian: los cambios que JS les haga no se reflejan en el array PHP. Aún no se pueden pasar objetos PHP simples (incidencia #18); consulte Objetos compartidos para ver soluciones alternativas.
$engine->setGlobal('config', ['debug' => true, 'version' => '1.0']);
$engine->setGlobal('log', fn(string $msg) => error_log($msg));
Dentro de JS:
console.log(config.version); // "1.0"
log('hello'); // calls the PHP closure
call
public function call(string $name, mixed ...$args): mixed
Llama a una función global JS con argumentos PHP (convertidos como en setGlobal). El valor devuelto se convierte de nuevo a PHP. Llamar a un nombre no definido lanza Phasis\Exceptions\ReferenceError; llamar a una variable global que no sea una función lanza Phasis\Exceptions\TypeError.
$engine->eval('function add(a, b) { return a + b; }');
echo $engine->call('add', 2, 3); // 5
Salida de la consola
public function getConsoleOutput(): string
public function getConsoleLines(): array
public function clearConsole(): void
console.log, console.info, console.warn, console.error y console.debug no imprimen nada. Cada llamada añade una línea con formato a un búfer del motor. getConsoleLines() devuelve las líneas almacenadas como una lista de cadenas; getConsoleOutput() las devuelve unidas con \n, y clearConsole() vacía el búfer. El búfer no se vacía entre llamadas a 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
Establece un límite de recursos ajustable durante la ejecución. Hay exactamente uno:
| Límite | Predeterminado | Descripción |
|---|---|---|
maxLoopIterations |
100000 |
número máximo de iteraciones por ejecución de un bucle for / while / do…while del intérprete que recorre el árbol |
$engine->setLimit('maxLoopIterations', 1_000_000);
Cualquier otro nombre se ignora sin aviso; no hay límites para la longitud de las cadenas, la salida de la consola ni el tiempo de ejecución.
El límite de bucle todavía no se aplica en todas partes. Los bucles que se ejecutan en la VM de bytecode, que abarca cuerpos de funciones y programas de nivel superior que acepta el compilador de bytecode, nunca se cuentan, así que while (true) {} se ejecuta hasta que PHP lo detiene (incidencia #19). No confíe en este límite para detener código no fiable; use PHP con max_execution_time o un proceso aparte.
Cuando se alcanza el límite, una expresión JS try / catch puede capturar el error (como un Error con el mensaje "Maximum loop iterations exceeded"). Si no se captura, llega a PHP como Phasis\Exceptions\InternalError, que extiende Phasis\Exceptions\RuntimeError, no JsThrowable.
El motor incorpora otros dos límites que no se pueden ajustar durante la ejecución: Phasis\Runtime\CallStack::maxDepth (4,096 marcos JS) y el límite de profundidad de cierres transpilados JsToPhp (8,192 marcos PHP). Al superarlos, ambos lanzan "Maximum call stack size exceeded", que también llega a PHP como InternalError. Estos límites están muy por encima de los valores de cualquier programa JS legítimo; aumentarlos requiere un cambio en el código, no un ajuste de configuración.
reset
public function reset(): void
Borra todas las variables globales definidas por el usuario y el búfer de la consola, y reconstruye la biblioteca estándar y el bucle de eventos.
En un motor creado en el modo diferido predeterminado, reset() elimina actualmente todas las funciones integradas instaladas de forma diferida: después, Map, JSON, Math, console, fetch y las demás quedan sin definir (incidencia #22). Hasta que se corrija, cree una instancia nueva de Engine o constrúyala con eager: true; en ese modo reset() funciona.
Bucle de eventos
El motor incluye un bucle de eventos JavaScript con setTimeout, setInterval, clearTimeout, clearInterval y queueMicrotask en el objeto global. Las microtareas (controladores de Promise, queueMicrotask) se agotan entre cada macrotarea; los temporizadores se activan según la fecha límite mediante una cola de tareas por ámbito.
public function runEventLoop(): void
public function tickEventLoop(): bool
public function pendingTaskCount(): int
public function getEventLoop(): \Phasis\Runtime\EventLoop
eval() y evalAsModule() llaman automáticamente a runEventLoop() cuando termina el código síncrono. Así, un script que programa setTimeout(cb, 5000) en el nivel superior se ejecuta cb antes de que eval() devuelva el control: el bucle usa tiempo virtual y adelanta el reloj hasta el siguiente plazo pendiente en vez de esperar PHP. Coincide con la semántica de node script.js.
Para una integración en tiempo real (servidores de larga duración, integración con UI, programación según un reloj real), tickEventLoop() ejecuta una iteración (agota las microtareas y activa los temporizadores vencidos) y devuelve el control. pendingTaskCount() informa de los temporizadores pendientes; el entorno anfitrión puede gestionar su propio ciclo:
while ($engine->pendingTaskCount() > 0) {
$engine->tickEventLoop();
usleep(1000);
}
Un setInterval descontrolado tiene un límite: runEventLoop() se detiene tras 100,000 ciclos y lanza un \RuntimeException simple; el fallo en el código JS se muestra como un error PHP claro en vez de bloquear la ejecución. El intervalo sigue programado, así que todas las llamadas posteriores a eval() en el mismo motor lanzan el mismo error; descarte el motor después. Los errores no capturados en una función de temporizador se emiten mediante error_log() y el bucle sigue procesando tareas; un temporizador defectuoso no detiene el programa.
Hooks de Fetch
Phasis incluye fetch() activado de forma predeterminada y utiliza ext-curl de PHP como transporte subyacente. Tres hooks permiten al entorno anfitrión cambiar el transporte, controlar las solicitudes o activar el almacenamiento de cookies. Los tres aceptan una función PHP o un método de un objeto; consulte API web para conocer el formato de los valores.
setFetchTransport
public function setFetchTransport(callable $transport): void
Sustituye el CurlTransport predeterminado. La función recibe un descriptor de solicitud y un AbortSignal JsObject opcional; devuelve un descriptor de respuesta.
// 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(),
];
});
Un patrón habitual de integración: instalar un transporte simulado en las pruebas y el transporte curl real en producción.
setFetchPolicy
public function setFetchPolicy(callable $hook): void
Hook de política previa a la solicitud. Recibe el JsObject Request; puede lanzar una excepción para denegar, devolver un Request modificado para reescribir o devolver null/ningún valor para permitirla.
// 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
Almacén de cookies opcional. Acepta cualquier objeto que exponga get(string $url): string y set(string $url, string $header): void, tanto del lado de PHP como de 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());
Sin setCookieJar(), fetch ignora tanto las cabeceras Set-Cookie entrantes como las Cookie salientes: de forma predeterminada, no hay cookies.
Excepciones
| Clase | Extiende | Cuándo |
|---|---|---|
Phasis\Exceptions\JsThrowable |
RuntimeError |
un JS throw explícito que no se ha capturado |
Phasis\Exceptions\TypeError, ReferenceError, RangeError |
RuntimeError |
un error generado por el propio motor (por ejemplo, null.x o una variable no definida) que no se ha capturado |
Phasis\Exceptions\InternalError |
RuntimeError |
se supera el límite de bucle o de pila de llamadas |
Phasis\Exceptions\RuntimeError |
\RuntimeException |
clase base de las anteriores |
Phasis\Parser\ParseError |
Phasis\Exceptions\SyntaxError (\RuntimeException) |
el código fuente no se puede analizar |
\RuntimeException |
execFile() no puede leer el archivo; el bucle de eventos alcanza su límite de 100,000 ciclos |
Para capturar todos los errores de un script, capture Phasis\Exceptions\RuntimeError y Phasis\Exceptions\SyntaxError, o simplemente \RuntimeException.
Las excepciones lanzadas por sus propias funciones anfitrionas PHP son distintas: actualmente atraviesan JS try / catch y llegan sin cambios a quien llama a eval(), con su clase original (incidencia #20). Consulte Funciones anfitrionas.
Ciclo de vida
Engine se construye rápidamente (unos pocos ms en frío) y la biblioteca estándar se instala de forma diferida. Para la mayoría de los casos de integración, basta con un motor de larga duración por proceso. Para un uso multiinquilino, asigne a cada inquilino o solicitud su propio motor, para que no se filtren variables globales entre ellos. Actualmente, crear un motor nuevo es la forma fiable de empezar desde cero (consulte reset).
foreach ($requests as $req) {
$engine = new Engine();
$engine->setGlobal('request', ['path' => $req->path, 'query' => $req->query]);
$engine->execFile($req->scriptPath);
}