انتقل إلى المحتوى

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، ولا async/await من دون Promise، ولا صيغ regex الحرفية من دون RegExp). أما سائر المكوّنات — Math، JSON، Map، Set، Date، TypedArray وعائلة array-buffer، وAtomics، Proxy، Reflect، console، Intl، وعائلة weak-ref، وTemporal، ShadowRealm، وحزمة Web Platform، وحزمة Fetch — فتُسجّل كأدوات وصول نائبة، ولا تُنشأ فعليًا إلا عند أول قراءة. قراءة المتغير العام شفافة: تعمل 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);

يُتجاهل أي اسم آخر بصمت؛ فلا توجد حدود لطول السلسلة النصية أو مخرجات وحدة التحكم أو وقت التنفيذ الفعلي.

لم يُطبّق حدّ التكرار في كل المواضع بعد. لا تُحتسب حلقات آلة البايت كود، التي تشمل أجسام الدوال والبرامج العليا التي يقبلها مُصرّف البايت كود؛ لذا يواصل while (true) {} التنفيذ حتى يوقفه PHP (المشكلة #19). لا تعتمد عليه لإيقاف الشيفرة غير الموثوقة؛ استخدم max_execution_time الخاص بـPHP أو عملية منفصلة.

يمكن التقاط الخطأ الناتج عند بلوغ الحد بواسطة try / catch في JS (على هيئة Error برسالة "Maximum loop iterations exceeded"). وإذا لم يلتقطه شيء، يصل إلى 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) في المستوى الأعلى ينفّذ cb قبل أن يُعيد eval() النتيجة — إذ تستخدم الحلقة وقتًا افتراضيًا، فتقدّم ساعتها إلى الموعد المعلّق التالي بدلًا من الانتظار PHP. وهذا يطابق دلالات node script.js.

للتضمين في الوقت الفعلي (الخوادم طويلة الأمد، تكامل UI، والجدولة وفق ساعة فعلية)، ينفّذ tickEventLoop() دورة واحدة (تفريغ المهام الدقيقة وإطلاق المؤقتات المستحقة) ثم يعود. يُبلغ pendingTaskCount() عن المؤقتات المعلّقة؛ ويمكن للمضيف تشغيل آلية المعالجة الخاصة به:

PHP
while ($engine->pendingTaskCount() > 0) {
    $engine->tickEventLoop();
    usleep(1000);
}

يُقيَّد تشغيل setInterval المنفلت: يضع runEventLoop() حدًا أقصى يبلغ 100,000 دورة ثم يطرح \RuntimeException عاديًا — فيظهر خطأ PHP واضح بدلًا من تعليق ناتج عن خطأ في كود JS. ويبقى الفاصل الزمني مجدولًا، لذلك يطرح كل استدعاء لاحق لـ eval() على المحرك نفسه الخطأ ذاته؛ تخلّص من المحرك بعد ذلك. وتُصدر الأخطاء غير الملتقطة في رد نداء المؤقت عبر error_log()، وتواصل الحلقة تفريغ المهام؛ فمؤقت واحد معطوب لا يوقف البرنامج.

خطافات Fetch

يأتي Phasis مع تفعيل fetch() افتراضيًا، ويستخدم ext-curl الخاص بـ PHP كوسيلة نقل أساسية. تتيح ثلاثة خطافات للمضيف تبديل وسيلة النقل أو التحكم في الطلبات أو تفعيل تخزين ملفات تعريف الارتباط. تقبل الخطافات الثلاثة إما دالة قابلة للاستدعاء من PHP أو طريقة على كائن؛ راجع واجهات Web APIs للاطلاع على مواصفات شكل القيم.

setFetchTransport

PHP
public function setFetchTransport(callable $transport): void

استبدل CurlTransport الافتراضي. تتلقى الدالة واصف طلب وكائن JsObject اختياريًا من AbortSignal، وتُعيد واصف استجابة.

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

خطاف سياسة يُنفّذ قبل الطلب. يتلقى كائن JsObject من Request؛ ويمكنه طرح خطأ للرفض، أو إعادة 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() قراءة الملف؛ بلغ event loop الحد الأقصى البالغ 100,000 دورة

لالتقاط كل حالات فشل البرنامج النصي، التقط Phasis\Exceptions\RuntimeError وPhasis\Exceptions\SyntaxError، أو استخدم ببساطة \RuntimeException.

تختلف الاستثناءات التي تطرحها دوال المضيف PHP الخاصة بك؛ إذ تمر حاليًا عبر try / catch في JS وتصل إلى مستدعي eval() دون تغيير، مع الاحتفاظ بفئتها الأصلية (المشكلة #20). راجع دوال المضيف.

دورة الحياة

إن إنشاء Engine سريع (بضعة مللي ثوانٍ عند التشغيل البارد)، وتُثبّت المكتبة القياسية بتحميل كسول. تكفي وحدة محرك واحدة طويلة الأمد لكل عملية في معظم سيناريوهات التضمين. أما الاستخدام متعدد المستأجرين، فأنشئ محركًا لكل مستأجر أو طلب كي لا تتسرب المتغيرات العامة فيما بينهم. ويظل إنشاء محرك جديد الوسيلة الموثوقة للحصول على حالة نظيفة حاليًا (راجع reset).

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