
# API

실행 API은 하나의 클래스인 `Phasis\Engine`입니다. 나머지(`JsValue`, `JsFunction`, `JsObject`, …)는 모두 엔진 내부용이며 PHP↔JS을 연결하는 호스트 함수에만 노출됩니다. 그 밖의 유일한 공개 인터페이스는 독립형 [파서 API](https://phasis.inline0.dev/ko/docs/parser)(`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`). 변환 규칙은 [값 변환](https://phasis.inline0.dev/ko/docs/interop/values)을 참조하세요.

```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`을 상속합니다. [예외](#exceptions)를 참조하세요.

```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);
```

로더는 상대 경로와 절대 경로만 해석합니다. [알려진 제한 사항](https://phasis.inline0.dev/ko/docs/compatibility/limitations#module-loader)을 참조하세요.

## 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](https://github.com/inline0/phasis/issues/18)). 우회 방법은 [공유 객체](https://phasis.inline0.dev/ko/docs/interop/shared-objects)를 참조하세요.

```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](https://github.com/inline0/phasis/issues/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](https://github.com/inline0/phasis/issues/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](https://phasis.inline0.dev/ko/docs/compatibility/web-apis)를 참조하세요.

### 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](https://github.com/inline0/phasis/issues/20)). [호스트 함수](https://phasis.inline0.dev/ko/docs/interop/host-functions#throwing-back-into-js)를 참조하세요.

## 수명 주기

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

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