API
لا يتألف التنفيذ API إلا من صنف واحد: Phasis\Engine. وكل ما سواه (JsValue، JsFunction، JsObject، …) داخلي للمحرك، ولا يُتاح إلا لدوال المضيف التي تربط بين PHP↔JS. والسطح العام الآخر الوحيد هو المحلّل المستقل API (Phasis\Parser\Parser وأصناف Phasis\Ast)، الذي يحلّل المصدر من دون تنفيذه.
الإنشاء
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 أشكال الواصفات المتوقعة.
التقييم
public function eval(string $source): mixed
يحلّل $source وينفّذه كسكربت تقليدي ضمن النطاق العام للمحرك، ثم يشغّل حلقة الأحداث حتى تفرغ. ويعيد قيمة آخر تعبير جرى تقييمه بعد تحويلها إلى PHP (أو null إن لم يتضمن البرنامج تعبيرًا أخيرًا). راجع تحويل القيم للاطلاع على القواعد.
$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() ثانٍ:
$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. راجع الاستثناءات.
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
يقيّم $source بوصفه وحدة ES: تتاح import / export، وawait وimport.meta على المستوى الأعلى، وتُحل المعرّفات النسبية بالاستناد إلى $path (مسار ملف مطلق). يشغّل حلقة الأحداث كما في eval() ويعيد دائمًا null؛ ويمكن قراءة النتائج عبر المتغيرات العامة أو مخزن وحدة التحكم.
$path = __DIR__ . '/entry.mjs';
$engine->evalAsModule(file_get_contents($path), $path);
لا يحل المحمّل إلا المسارات النسبية والمطلقة؛ راجع القيود المعروفة.
execFile
public function execFile(string $path): mixed
يقرأ ملفًا من القرص ويمرّر محتواه إلى eval()، فيُشغّل كسكربت تقليدي مهما كان امتداده؛ والملف الذي يتضمن import / export يُصدر خطأ SyntaxError. ويصبح المسار الحقيقي للملف أساسًا لمعرّفات import() الديناميكية. استخدم evalAsModule() لملفات الوحدات.
$engine->execFile(__DIR__ . '/script.js');
إذا تعذرت قراءة الملف، يصدر PHP تحذيرًا، ويُطلق execFile() خطأ \RuntimeException عاديًا ("Cannot read file: …").
setGlobal
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)؛ راجع الكائنات المشتركة للحلول البديلة.
$engine->setGlobal('config', ['debug' => true, 'version' => '1.0']);
$engine->setGlobal('log', fn(string $msg) => error_log($msg));
داخل JS:
console.log(config.version); // "1.0"
log('hello'); // calls the PHP closure
الاستدعاء
public function call(string $name, mixed ...$args): mixed
يستدعي دالة JS عامة مع وسائط PHP (تحوّل كما في setGlobal). وتُحوّل القيمة المعادة إلى PHP. يؤدي استدعاء اسم غير معرّف إلى إطلاق Phasis\Exceptions\ReferenceError؛ أما استدعاء متغير عام ليس دالة فيؤدي إلى إطلاق Phasis\Exceptions\TypeError.
$engine->eval('function add(a, b) { return a + b; }');
echo $engine->call('add', 2, 3); // 5
مخرجات وحدة التحكم
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().
$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
يضبط حدًا للموارد يمكن تغييره أثناء التشغيل. يوجد حد واحد فقط:
| الحد | القيمة الافتراضية | الوصف |
|---|---|---|
maxLoopIterations |
100000 |
الحد الأقصى لعدد التكرارات في كل تشغيل لحلقة for / while / do…while بواسطة المفسّر الذي يتبع بنية الشجرة |
$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 مشروع؛ وتغييرهما يتطلب تعديل الكود، لا إعدادًا.
إعادة الضبط
public function reset(): void
يمسح جميع المتغيرات العامة التي عرّفها المستخدم ومخزن وحدة التحكم المؤقت، ويعيد إنشاء المكتبة القياسية وحلقة الأحداث.
في المحرك المُنشأ بوضع التحميل الكسول الافتراضي، يفقد reset() حاليًا كل العناصر المضمّنة التي ثُبّتت بتحميل كسول؛ وبعدها تصبح Map وJSON وMath وconsole وfetch وغيرها غير معرّفة (المشكلة #22). إلى أن يُصلح ذلك، أنشئ Engine جديدًا بدلًا منه، أو أنشئه باستخدام eager: true، حيث تعمل reset().
حلقة الأحداث
يتضمن المحرك حلقة أحداث JavaScript مع setTimeout وsetInterval وclearTimeout وclearInterval وqueueMicrotask على الكائن العام. تُنفّذ المهام الدقيقة (معالجات Promise وqueueMicrotask) بين كل مهمة كبرى؛ وتُطلق المؤقتات حسب ترتيب مواعيدها عبر قائمة مهام خاصة بكل مجال.
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() عن المؤقتات المعلّقة؛ ويمكن للمضيف تشغيل آلية المعالجة الخاصة به:
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
public function setFetchTransport(callable $transport): void
استبدل CurlTransport الافتراضي. تتلقى الدالة واصف طلب وكائن JsObject اختياريًا من AbortSignal، وتُعيد واصف استجابة.
// 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
public function setFetchPolicy(callable $hook): void
خطاف سياسة يُنفّذ قبل الطلب. يتلقى كائن JsObject من Request؛ ويمكنه طرح خطأ للرفض، أو إعادة Request معدّل لإعادة الكتابة، أو إعادة null/عدم إعادة قيمة للسماح.
// 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
مخزن اختياري لملفات تعريف الارتباط. يقبل أي كائن يوفّر get(string $url): string وset(string $url, string $header): void — سواء من جهة PHP أو 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());
من دون 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).
foreach ($requests as $req) {
$engine = new Engine();
$engine->setGlobal('request', ['path' => $req->path, 'query' => $req->query]);
$engine->execFile($req->scriptPath);
}