본문으로 건너뛰기

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 없이는 클래스를 사용할 수 없고, Promise 없이는 async/await를 사용할 수 없으며, RegExp 없이는 정규식 리터럴을 사용할 수 없습니다). 그 밖의 모든 항목인 Math, JSON, Map, Set, Date, TypedArray 및 배열 버퍼 계열, Atomics, Proxy, Reflect, console, Intl, 약한 참조 계열, 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 (목록) Array (복사본)
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로 오류를 잡을 수 있습니다(메시지가 "Maximum loop iterations exceeded"인 Error로 처리됨). 아무것도 잡지 않으면 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 등이 정의되지 않습니다(이슈 #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)을 예약하는 스크립트는 eval()가 반환되기 전에 cb을 실행합니다. 루프는 잠들지 않고 다음 대기 중인 기한으로 시계를 이동하는 가상 시간을 사용합니다(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()이 활성화되어 있으며, 기본 전송 계층으로 PHP의 ext-curl을 사용합니다. 세 가지 훅으로 임베더가 전송 계층을 바꾸거나 요청을 제한하거나 쿠키 저장을 활성화할 수 있습니다. 세 훅 모두 PHP 호출 가능 객체 또는 객체의 메서드를 받습니다. 값 형태 계약은 Web 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()의 호출자에게 원래 클래스와 함께 전달됩니다(이슈 #20). 호스트 함수를 참조하세요.

수명 주기

Engine은 생성 비용이 낮고(콜드 상태에서 약 몇 ms), 표준 라이브러리는 지연 설치됩니다. 대부분의 임베딩 시나리오에서는 프로세스당 하나의 장기 실행 엔진이면 충분합니다. 멀티테넌트 환경에서는 전역 변수가 테넌트 간에 새어 나가지 않도록 각 테넌트나 요청에 자체 엔진을 제공하세요. 현재로서는 새 엔진을 생성하는 것이 확실한 초기화 방법입니다(재설정 참조).

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