> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# WebRTC من الداخل: كيف تعمل مكالمات الفيديو في المتصفح
- URL: https://arabroot.io/articles/webrtc-من-الداخل-كيف-تعمل-مكالمات-الفيديو-في-المتصفح/
- Published: 2026-10-09T17:00:00.000Z
- Updated: 2026-10-09T13:17:51.000Z
- Description: كيف تبدأ مكالمة الفيديو بين متصفحين وكيف تشفر وتتكيف مع الشبكة، مع مكالمة حقيقية نقرأ رسائلها وأرقامها سطراً بسطر.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, مجالات متخصصة, الشبكات والوصول, الاستضافة الذاتية

عندما تضغط زر الاتصال في Google Meet أو في Mattermost أو في أي تطبيق مكالمات يعمل من المتصفح، فإن المتصفح يطلب منك إذن الكاميرا، ثم بعد ثانية أو ثانيتين ترى صورة زميلك وتسمع صوته، ولا تحتاج إلى تثبيت أي برنامج أو إضافة، والذي يقوم بكل هذا هو WebRTC، أي مجموعة من الواجهات البرمجية في المتصفح ومن البروتوكولات تحتها صممت لنقل الصوت والصورة والبيانات بين جهازين مباشرة في الزمن الحقيقي. وقد شرحنا في [مكالمات WebRTC خلف NAT](https://arabroot.io/articles/%D9%85%D9%83%D8%A7%D9%84%D9%85%D8%A7%D8%AA-webrtc-%D8%AE%D9%84%D9%81-nat-%D8%B4%D8%B1%D8%AD-ice-%D9%88-stun-%D9%88-turn/) كيف يجد الطرفان طريقاً بينهما عبر ICE وSTUN وTURN، ولماذا تتصل المكالمة أحياناً ولا يصل الصوت، أما هذا المقال فيقف خطوة قبل ذلك، أي عند المطور الذي يريد أن يفهم ما الذي يحدث داخل المتصفح عندما يكتب `new RTCPeerConnection()`، وما الذي يجب أن يبنيه بنفسه، وما الذي يحصل عليه جاهزاً.

وسوف نناقش في هذا المقال ما يلي:

- ما هو WebRTC، ومن يكتب معاييره في W3C وفي IETF.
- الواجهات الثلاث في المتصفح: `getUserMedia` و`RTCPeerConnection` و`RTCDataChannel`.
- دورة حياة المكالمة خطوة خطوة، ولماذا لا يحدد WebRTC طريقة التنسيق Signaling ويتركها لك.
- التشفير الإلزامي عبر DTLS-SRTP، وما يراه سيرفرك وما لا يراه، والتشفير بين الطرفين End-to-End عندما يكون في الوسط سيرفر SFU.
- الصيغ Codecs الإلزامية، ثم Simulcast وSVC باختصار.
- قنوات البيانات Data Channels، وتقدير السرعة ومخزن التذبذب Jitter Buffer، وكيف تقرأ الأرقام من `getStats()` ومن صفحة `chrome://webrtc-internals`.
- تجربة عملية: صفحة مكالمة فيديو بين شخصين مع سيرفر تنسيق صغير بلغة Node، نقرأ فيها العرض والجواب SDP وبصمة الشهادة والأرقام، ثم نكسر التنسيق عمداً لنرى ما يحدث.
- WHIP وWHEP في البث، ومتى تستخدم سيرفر SFU جاهزاً مثل LiveKit ومتى تبني بنفسك.

## ما هو WebRTC ومن يكتب معاييره؟

الاسم اختصار Web Real-Time Communication، وهو ليس منتجاً ولا مكتبة واحدة، وإنما معياران يكمل أحدهما الآخر: الأول هو الواجهة البرمجية API التي يستدعيها كود JavaScript في الصفحة، ويكتبها اتحاد W3C في وثيقة [WebRTC: Real-Time Communication in Browsers](https://www.w3.org/TR/webrtc/?ref=arabroot.io)، والثاني هو البروتوكولات التي تتحرك على الشبكة تحت هذه الواجهة، أي كيف يتفق الطرفان وكيف يجدان الطريق وكيف يشفران الصوت، وتكتبها هيئة IETF في مجموعة من وثائق RFC. وقد أعلنت الجهتان معاً في [26 يناير 2021](https://www.w3.org/press-releases/2021/webrtc-rec/?ref=arabroot.io) أن WebRTC أصبح معياراً رسمياً، فصارت الواجهة توصية W3C Recommendation، وصدرت في الشهر نفسه حزمة وثائق IETF، وأولها [RFC 8825](https://www.rfc-editor.org/rfc/rfc8825?ref=arabroot.io) الذي يعطي نظرة عامة على البروتوكولات ويربط بقية الوثائق ببعضها، ثم حدثت W3C التوصية بنسخة جديدة في 13 مارس 2025، وهي النسخة الحالية على الرابط نفسه.

وهذا التقسيم مهم لك كمطور، لأنه يحدد أين تبحث عندما تواجه مشكلة: إذا كان السؤال عن دالة أو حدث في JavaScript، مثل متى يطلق `icecandidate` أو ما الحالات التي يمر بها `connectionState`، فالإجابة في وثيقة W3C، وإذا كان السؤال عن شيء على الشبكة، مثل لماذا تفشل مصافحة DTLS أو ما معنى سطر في ال SDP، فالإجابة في وثائق RFC، وسوف نربط كل فكرة في هذا المقال بالوثيقة التي تعرفها.

## ثلاث واجهات في المتصفح تكفي لمكالمة كاملة

كل ما يحتاجه تطبيق مكالمات من المتصفح ثلاث واجهات، والصورة التالية تبين ما تفعله كل واحدة، وأين يقف كودك أنت بينها:

![مخطط لمتصفح فيه ثلاث بطاقات: getUserMedia يأخذ الكاميرا والميكروفون بعد إذن المستخدم ويعطي MediaStream فيه مسارات صوت وصورة، وRTCPeerConnection يستقبل هذه المسارات ويتولى العرض والجواب وICE والتشفير DTLS-SRTP والصيغ وتقدير السرعة، وRTCDataChannel يعمل على الاتصال نفسه لنقل الرسائل والملفات، وفوقها كود JavaScript الخاص بك يرسل العرض والجواب إلى سيرفر التنسيق الذي يمررها إلى المتصفح الآخر، ومن RTCPeerConnection يخرج طريق مشفر للصوت والصورة والبيانات إلى المتصفح الآخر مباشرة](https://arabroot.io/content/images/2026/10/webrtc-basics-01-apis.webp)

الواجهات الثلاث يعطيك إياها المتصفح، أما رسائل التنسيق وسيرفرها فعليك أنت

### getUserMedia: الكاميرا والميكروفون

الدالة `navigator.mediaDevices.getUserMedia()` لا تعرفها وثيقة WebRTC نفسها، وإنما وثيقة مستقلة اسمها [Media Capture and Streams](https://www.w3.org/TR/mediacapture-streams/?ref=arabroot.io)، وهي تطلب من المستخدم إذن الكاميرا أو الميكروفون، فإذا وافق أعادت كائناً من نوع `MediaStream` فيه مسار Track لكل جهاز، مسار للصوت ومسار للصورة، وإذا رفض أو لم توجد كاميرا رمت خطأ يجب أن يعالجه كودك. ولاحظ أن الواجهة كلها معرفة في المعيار بالخاصية `SecureContext`، أي أنها لا تعمل إلا في صفحة آمنة، وهذا يعني HTTPS في الإنتاج، أما أثناء التطوير فيعامل المتصفح `localhost` والعناوين `127.0.0.0/8` كأصل موثوق كما في [معيار Secure Contexts](https://www.w3.org/TR/secure-contexts/?ref=arabroot.io)، لذلك تعمل التجربة في آخر المقال على `http://127.0.0.1` دون شهادة، ولن تعمل نفس الصفحة إذا فتحتها من جهاز آخر عبر `http://192.168.1.20`.

### RTCPeerConnection: الاتصال نفسه

هذا هو قلب WebRTC، وكائن واحد منه يمثل اتصالاً واحداً بين متصفحك وطرف واحد آخر، فتضيف إليه المسارات بالدالة `addTrack()`، ويكتب لك وصف الجلسة بالدالتين `createOffer()` و`createAnswer()`، ويجمع العناوين المرشحة ويختبرها عبر ICE، ويجري مصافحة DTLS ويشفر الوسائط، ويختار الصيغة ويضبط معدل البت Bitrate بحسب الشبكة، ويعطيك الأرقام عبر `getStats()`. وكل هذا يحدث داخل المتصفح دون أن تكتب سطراً واحداً عن UDP أو التشفير، والذي عليك فقط أن تنقل وصف الجلسة بين الطرفين، كما سيأتي.

### RTCDataChannel: البيانات على نفس الاتصال

الواجهة الثالثة تفتح قناة لنقل أي بيانات، نصاً أو بايتات، على نفس الاتصال الذي تمر عليه الوسائط، فتستخدمها للمحادثة النصية أثناء المكالمة أو لإرسال ملف أو لحالة لعبة، وتنشئها بالدالة `pc.createDataChannel()`، ثم ترسل ب `send()` وتستقبل في الحدث `message` بنفس طريقة WebSocket تقريباً، والفرق أن الرسائل تذهب مباشرة إلى الطرف الآخر ولا تمر عبر سيرفرك، وسوف نعود إليها في [قسم قنوات البيانات](#data-channels).

## المكالمة خطوة خطوة

لنفرض أن لديك متصفحين، المتصل A والمستقبل B، وسيرفر تنسيق صغيراً كتبته بنفسك، فالصورة التالية تبين ما يحدث من لحظة طلب الكاميرا إلى أن تصل الصورة، وهي نفس الخطوات التي سوف نراها في التجربة العملية:

![مخطط تسلسلي بثلاثة أعمدة: المتصل A على اليمين وسيرفر التنسيق في الوسط والمستقبل B على اليسار، وفيه تسع خطوات مرقمة: 1 getUserMedia وإنشاء RTCPeerConnection، 2 createOffer وsetLocalDescription، 3 العرض SDP يمر عبر السيرفر إلى B، 4 B يستدعي setRemoteDescription وgetUserMedia وcreateAnswer وsetLocalDescription، 5 الجواب يعود عبر السيرفر، 6 العناوين المرشحة تمر في الاتجاهين، ثم في منطقة خضراء لا يمر فيها السيرفر: 7 فحوص ICE، 8 مصافحة DTLS ومقارنة البصمة، 9 الصوت والصورة عبر SRTP وقناة البيانات عبر SCTP](https://arabroot.io/content/images/2026/10/webrtc-basics-02-call-lifecycle.webp)

ثلاث خطوات فقط تمر عبر سيرفرك، والباقي بين المتصفحين مباشرة

1. يطلب A الكاميرا والميكروفون عبر `getUserMedia()`، وينشئ `RTCPeerConnection` ويضيف إليه المسارات.
2. يستدعي A الدالة `createOffer()` فيحصل على عرض Offer مكتوب بصيغة SDP، ثم يثبته عنده ب `setLocalDescription()`، وفي هذه اللحظة يبدأ المتصفح جمع العناوين المرشحة.
3. يرسل كود A العرض إلى سيرفر التنسيق، والسيرفر يمرره إلى B كما هو.
4. يثبت B العرض ب `setRemoteDescription()`، ويطلب الكاميرا، ويكتب جواباً Answer ب `createAnswer()` ويثبته عنده.
5. يعود الجواب عبر السيرفر إلى A الذي يثبته ب `setRemoteDescription()`، وبهذا يعرف كل طرف صيغ الآخر وبصمة شهادته.
6. كل عنوان مرشح يكتشفه أي طرف يصل في الحدث `icecandidate`، فيرسله الكود عبر السيرفر، ويضيفه الطرف الآخر ب `addIceCandidate()`.
7. يختبر ICE أزواج العناوين مباشرة بين المتصفحين، ويختار زوجاً يعمل.
8. على الزوج المختار يجري الطرفان مصافحة DTLS، ويتحقق كل منهما أن شهادة الطرف الآخر تطابق البصمة التي وصلت في ال SDP.
9. يبدأ الصوت والصورة عبر SRTP، وتفتح قناة البيانات عبر SCTP، وكل ذلك على نفس الطريق المباشر.

### التنسيق Signaling: لماذا لا يحدده WebRTC؟

أول ما يفاجئ من يبدأ مع WebRTC أنه لا يوجد في المتصفح أي شيء يرسل العرض إلى الطرف الآخر، فالدالة `createOffer()` تعطيك نصاً، ثم تتركك. وهذا مقصود، فقد ذكر [RFC 9429](https://www.rfc-editor.org/rfc/rfc9429?ref=arabroot.io) الذي يعرف JSEP، أي JavaScript Session Establishment Protocol، أن تصميم المكالمة في WebRTC يركز على التحكم في طريق الوسائط، ويترك سلوك التنسيق للتطبيق قدر الإمكان، والسبب أن التطبيقات المختلفة تفضل بروتوكولات مختلفة، فتطبيق يريد أن يتصل ببدالة هاتف تعمل ب SIP، وآخر يكفيه WebSocket بسيط، وثالث يستخدم طلبات HTTP عادية. فالذي يحدده المعيار هو المحتوى، أي وصف الجلسة SDP الذي يعرفه [RFC 8866](https://www.rfc-editor.org/rfc/rfc8866?ref=arabroot.io)، ونموذج العرض والجواب الذي شرحنا فكرته في [عرض وجواب SDP في فقرة واحدة](https://arabroot.io/articles/%D9%85%D9%83%D8%A7%D9%84%D9%85%D8%A7%D8%AA-webrtc-%D8%AE%D9%84%D9%81-nat-%D8%B4%D8%B1%D8%AD-ice-%D9%88-stun-%D9%88-turn/#sdp)، أما كيف يصل هذا المحتوى ومن يسمح له بالاتصال بمن، فهو قرارك أنت.

وقد يتساءل البعض: إذا كان سيرفر التنسيق مجرد ساعي بريد، فهل يكفي أن أنشره بأي شكل؟ والإجابة لا، لأن هذا الساعي يحمل بصمة شهادة DTLS لكل طرف، ومن يستطيع تغيير البصمة في الطريق يستطيع أن يقف في وسط المكالمة كما سوف نرى في [قسم البصمة](#fingerprint)، لذلك يجب أن يعمل سيرفر التنسيق عبر HTTPS أو WSS، وأن يتحقق من هوية المستخدم قبل أن يمرر رسائله، تماماً مثل أي واجهة API في تطبيقك. وإذا وضعته خلف Reverse Proxy فتذكر أن اتصال WebSocket يحتاج ترويستي `Upgrade` و`Connection` كما شرحنا في [دليل تطبيقك خلف Reverse Proxy](https://arabroot.io/articles/%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D9%83-%D8%AE%D9%84%D9%81-reverse-proxy/#websocket).

### حالات العرض والجواب signalingState

يتتبع `RTCPeerConnection` مكانه في نموذج العرض والجواب في الخاصية `signalingState`، فيبدأ كل طرف في الحالة `stable`، وعندما يثبت المتصل عرضه ينتقل إلى `have-local-offer`، وعندما يثبت المستقبل العرض القادم ينتقل إلى `have-remote-offer`، ثم يعود كل منهما إلى `stable` عندما يكتمل الجواب. وهذه الحالات هي أول ما تنظر إليه عندما لا تبدأ المكالمة، فإذا بقي المتصل في `have-local-offer` فالجواب لم يصل إليه، وهذا بالضبط ما سوف نصنعه عمداً في [التجربة](#lab-no-answer).

### ICE والطريق بين المتصفحين

الخطوتان 6 و7 هما موضوع مقال كامل، فقد شرحنا في [مكالمات WebRTC خلف NAT](https://arabroot.io/articles/%D9%85%D9%83%D8%A7%D9%84%D9%85%D8%A7%D8%AA-webrtc-%D8%AE%D9%84%D9%81-nat-%D8%B4%D8%B1%D8%AD-ice-%D9%88-stun-%D9%88-turn/#ice) كيف يجمع ICE [العناوين المرشحة بأنواعها](https://arabroot.io/articles/%D9%85%D9%83%D8%A7%D9%84%D9%85%D8%A7%D8%AA-webrtc-%D8%AE%D9%84%D9%81-nat-%D8%B4%D8%B1%D8%AD-ice-%D9%88-stun-%D9%88-turn/#candidate-types) ويختبرها [بفحوص الاتصال](https://arabroot.io/articles/%D9%85%D9%83%D8%A7%D9%84%D9%85%D8%A7%D8%AA-webrtc-%D8%AE%D9%84%D9%81-nat-%D8%B4%D8%B1%D8%AD-ice-%D9%88-stun-%D9%88-turn/#connectivity-checks)، ودور [STUN](https://arabroot.io/articles/%D9%85%D9%83%D8%A7%D9%84%D9%85%D8%A7%D8%AA-webrtc-%D8%AE%D9%84%D9%81-nat-%D8%B4%D8%B1%D8%AD-ice-%D9%88-stun-%D9%88-turn/#stun) و[TURN](https://arabroot.io/articles/%D9%85%D9%83%D8%A7%D9%84%D9%85%D8%A7%D8%AA-webrtc-%D8%AE%D9%84%D9%81-nat-%D8%B4%D8%B1%D8%AD-ice-%D9%88-stun-%D9%88-turn/#turn)، والذي يهمك هنا كمطور سطر واحد: الخيار `iceServers` الذي تمرره عند إنشاء `RTCPeerConnection` هو المكان الذي تضع فيه عناوين STUN وTURN، وفي التجربة تركناه فارغاً لأن المتصفحين على نفس الجهاز، أما في الإنتاج فلا تقم بذلك، والسبب أن من يجلس خلف جدار حماية يمنع UDP لن تصله المكالمة أبداً دون TURN.

## التشفير إلزامي: DTLS-SRTP

في أنظمة الصوت عبر الإنترنت التقليدية كان التشفير SRTP خياراً يفعله المسؤول أو ينساه، فيمر الصوت عبر RTP نصاً واضحاً، أما في WebRTC فالتشفير ليس إعداداً يمكن نسيانه، فقد نص [RFC 8827](https://www.rfc-editor.org/rfc/rfc8827?ref=arabroot.io) الذي يعرف معمارية الأمان في WebRTC على أن كل قناة وسائط يجب أن تحمى ب SRTP وSRTCP، وأن الوسائط لا يجوز أن ترسل عبر RTP غير مشفر، وأن المتصفح لا يقبل صيغ تشفير فارغة NULL، وأن مفاتيح SRTP تأتي من مصافحة DTLS، أي DTLS-SRTP كما في [RFC 5764](https://www.rfc-editor.org/rfc/rfc5764?ref=arabroot.io)، وأن الطريقة القديمة التي تكتب المفاتيح داخل ال SDP نفسه، واسمها SDES، ممنوعة، وأن كل قناة بيانات يجب أن تحمى ب DTLS. والصورة التالية تبين هذه الطبقات، وماذا يرى كل من في الطريق:

![على اليمين طبقات اتصال WebRTC: إطارات الصوت والصورة Opus وVP8 وH.264 فوق RTP فوق SRTP الذي يشفر المحتوى ويوقع الترويسة، ورسائل قناة البيانات فوق SCTP فوق DTLS، ومفاتيح SRTP تأتي من مصافحة DTLS، والكل فوق ICE وUDP، وتحتها ما يفرضه RFC 8827 وما ظهر في التجربة DTLS 1.3 وSRTP_AES128_CM_HMAC_SHA1_80. وعلى اليسار: سيرفر التنسيق يرى ال SDP والعناوين والبصمة ولا يرى الصوت والصورة ويستطيع تبديل البصمة، ومن يراقب الشبكة يرى العناوين والمنافذ وأحجام الحزم وتوقيتها وترويسات RTP ولا يرى المحتوى](https://arabroot.io/content/images/2026/10/webrtc-basics-03-security-layers.webp)

كل ما يمر بين المتصفحين مشفر، والبصمة التي تربط التشفير بالطرف الآخر تمر في رسائل التنسيق

والتوزيع هنا يشبه ما شرحناه في [تبادل المفاتيح والتشفير الهجين](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%B4%D9%81%D9%8A%D8%B1-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86-%D8%A7%D9%84%D9%87%D8%A7%D8%B4-%D9%88%D9%83%D9%84%D9%85%D8%A7%D8%AA-%D8%A7%D9%84%D9%85%D8%B1%D9%88%D8%B1-%D9%88%D8%A7%D9%84%D9%85%D9%81%D8%A7%D8%AA%D9%8A%D8%AD/#key-exchange): مصافحة DTLS، وهي TLS نفسه معدلاً ليعمل فوق UDP، تتفق على مفاتيح جلسة عبر تبادل مفاتيح بالمنحنيات البيضاوية، ثم يستخرج المتصفح منها مفاتيح SRTP كما يحدد [RFC 5764](https://www.rfc-editor.org/rfc/rfc5764?ref=arabroot.io)، فيشفر بها كل حزمة صوت وصورة تشفيراً متماثلاً سريعاً كما في [RFC 3711](https://www.rfc-editor.org/rfc/rfc3711?ref=arabroot.io). ولاحظ أن SRTP يشفر محتوى الحزمة فقط، أما ترويسة RTP، وفيها رقم المصدر SSRC ورقم التسلسل والتوقيت، فتبقى مقروءة وموقعة لا يمكن تغييرها، لأن الشبكة والسيرفرات الوسيطة تحتاجها لترتيب الحزم، وهذا يعني أن من يراقب الشبكة يعرف أن هناك مكالمة، ومن أي عنوان إلى أي عنوان، ومتى يتكلم الشخص ومتى يصمت من أحجام الحزم، ولكنه لا يسمع الكلام ولا يرى الصورة.

### بصمة الشهادة a=fingerprint: لماذا لا تحتاج شهادة من جهة إصدار؟

في HTTPS يتحقق المتصفح من شهادة الموقع عبر سلسلة ثقة تنتهي بجهة إصدار معروفة كما شرحنا في [سلسلة الثقة](https://arabroot.io/articles/%D8%A7%D9%84%D8%B4%D9%87%D8%A7%D8%AF%D8%A7%D8%AA-%D8%A7%D9%84%D8%B1%D9%82%D9%85%D9%8A%D8%A9-tls-%D9%88-x509-%D9%88%D8%B5%D9%8A%D8%BA-pem-%D9%88-der/#chain-of-trust)، أما في WebRTC فكل متصفح يصنع لنفسه شهادة موقعة ذاتياً Self-Signed لكل اتصال، ولا يوجد أي جهة إصدار، فكيف يعرف الطرف الآخر أن الشهادة التي وصلته في مصافحة DTLS هي شهادة صاحبه وليست شهادة مخترق في الطريق؟ والإجابة أن كل طرف يكتب هاش شهادته في ال SDP في سطر `a=fingerprint`، فإذا وصلت الشهادة في المصافحة حسب المتصفح الهاش ورفض الاتصال إذا لم يطابق البصمة، وسوف نرى في التجربة البصمة نفسها في العرض وفي `getStats()`.

وهذا يعني أن الثقة كلها تنتقل إلى قناة التنسيق، وقد ذكر [القسم 9.1 من RFC 8827](https://www.rfc-editor.org/rfc/rfc8827?ref=arabroot.io#section-9.1) ذلك صراحة: إذا لم يكن الاتصال بسيرفر التنسيق محمياً ب HTTPS فأي مخترق في الطريق يستطيع تبديل البصمات والوقوف بين الطرفين، وحتى مع HTTPS يستطيع سيرفر التنسيق نفسه أن يفعل ذلك إذا لم توجد طريقة مستقلة للتحقق من المفاتيح. لذلك فالقاعدة العملية: سيرفر التنسيق جزء من أمان المكالمة وليس مجرد ناقل، فاحمه كما تحمي سيرفر الدخول.

### التشفير بين الطرفين End-to-End عندما يكون في الوسط سيرفر SFU

كل ما سبق صحيح في المكالمة المباشرة بين متصفحين، أما في المكالمة الجماعية فالشائع أن يكون في الوسط سيرفر SFU كما شرحنا في [Mesh وSFU وMCU](https://arabroot.io/articles/%D9%85%D9%83%D8%A7%D9%84%D9%85%D8%A7%D8%AA-webrtc-%D8%AE%D9%84%D9%81-nat-%D8%B4%D8%B1%D8%AD-ice-%D9%88-stun-%D9%88-turn/#topologies)، وهنا تتغير الصورة، لأن كل متصفح يفتح اتصال WebRTC مع السيرفر وليس مع الأشخاص الآخرين، فتنتهي مصافحة DTLS عند السيرفر، ويفك السيرفر SRTP عند كل قفزة Hop ثم يعيد تشفيره للمستقبل، أي أن الصوت والصورة يكونان واضحين داخل ذاكرة السيرفر. وهذا ما يصفه [RFC 9605](https://www.rfc-editor.org/rfc/rfc9605?ref=arabroot.io) في مقدمته: نحتاج طبقتين من التشفير، تشفيراً بين القفزات Hop-by-Hop تراه السيرفرات لأنها تحتاج الترويسات ورسائل RTCP، وتشفيراً بين الطرفين End-to-End للمحتوى نفسه لا يستطيع السيرفر فكه، والصورة التالية تبين الفرق:

![جزءان: في الأعلى دون تشفير بين الطرفين، Alice ترسل إلى سيرفر SFU عبر DTLS-SRTP في القفزة الأولى، والسيرفر يفك التشفير فيرى الصورة ويستطيع تسجيلها، ثم يشفر من جديد إلى Bob في القفزة الثانية، وفي الأسفل مع Encoded Transform وSFrame: الإطار مشفر بمفتاح K داخل SRTP، والسيرفر يمرره دون أن يملك المفتاح فلا يرى إلا الترويسات والأحجام، والمفتاح K يوزعه التطبيق بين Alice وBob ولا يمر عبر السيرفر](https://arabroot.io/content/images/2026/10/webrtc-basics-04-sfu-e2ee.webp)

مع SFU عادي يرى السيرفر الوسائط، ومع التشفير بين الطرفين لا يرى إلا الترويسات

ولتطبيق ذلك في المتصفح يحتاج كودك أن يصل إلى الإطار المرمز قبل أن يغلفه المتصفح في SRTP، فيشفره بمفتاحه، ثم يفكه عند المستقبل بعد SRTP وقبل فك الترميز، وهذا ما تعطيه واجهة [WebRTC Encoded Transform](https://www.w3.org/TR/webrtc-encoded-transform/?ref=arabroot.io) عبر الكائن `RTCRtpScriptTransform` الذي يمرر الإطارات إلى Worker، والوثيقة ما زالت مسودة عمل Working Draft في W3C، وفيها أيضاً تعريف لتحويل جاهز مبني على SFrame. وقبلها أضاف Chrome واجهة أقدم اسمها Insertable Streams عبر الدالة `createEncodedStreams()`، وفي تجربتنا على Chrome 154 كانت الواجهتان موجودتين كما سوف ترى في [مخرج الصيغ](#lab-codecs). أما صيغة التشفير نفسها فقد وحدها [RFC 9605](https://www.rfc-editor.org/rfc/rfc9605?ref=arabroot.io) الصادر في أغسطس 2024 باسم SFrame.

والجزء الصعب هنا ليس التشفير وإنما المفاتيح، فمن يولد المفتاح K؟ وكيف يصل إلى كل مشارك دون أن يمر عبر السيرفر؟ وماذا يحدث عندما يغادر شخص الاجتماع؟ ولهذا تقول [وثيقة التشفير في LiveKit](https://docs.livekit.io/transport/encryption/?ref=arabroot.io) بوضوح إن توليد المفاتيح وتخزينها وتوزيعها مسؤوليتك، وإن LiveKit لا يخزن المفاتيح ولا ينقلها ولا يستطيع ذلك. فإذا قال لك مزود خدمة إن مكالماته مشفرة، فاسأله: مشفرة إلى أين؟ إلى سيرفره، أم إلى الطرف الآخر؟

## الصيغ Codecs الإلزامية

الصيغة Codec هي الخوارزمية التي تضغط الصوت أو الصورة قبل الإرسال وتفكها عند الاستقبال، ولكي يتصل أي متصفح بأي متصفح آخر دون تحويل بينهما حدد المعيار حداً أدنى يجب أن يدعمه الجميع: في الصوت يلزم [RFC 7874](https://www.rfc-editor.org/rfc/rfc7874?ref=arabroot.io) كل طرف WebRTC بصيغة [Opus](https://www.rfc-editor.org/rfc/rfc6716?ref=arabroot.io) وبصيغتي G.711 المسماتين PCMA وPCMU، الأولى لأنها الأفضل للصوت عبر الإنترنت، والثانيتان لأنهما لغة الهواتف التقليدية فيسهل ربط المكالمة ببدالة هاتف، وفي الفيديو يلزم [RFC 7742](https://www.rfc-editor.org/rfc/rfc7742?ref=arabroot.io) كل متصفح بصيغتي [VP8](https://www.rfc-editor.org/rfc/rfc6386?ref=arabroot.io) وH.264 بالملف Constrained Baseline. وكل ما زاد على ذلك، مثل VP9 وAV1، اختياري يتفق عليه الطرفان في العرض والجواب، فالمتصل يكتب في العرض كل الصيغ التي يدعمها مرتبة بتفضيله، والمستقبل يختار منها. وإذا أردت أن تفهم ما تعنيه الصيغة ومعدل البت والإطار المفتاحي في الفيديو عموماً فقد شرحناها في [مفاهيم الفيديو للمبتدئين](https://arabroot.io/articles/%D9%85%D9%81%D8%A7%D9%87%D9%8A%D9%85-%D8%A7%D9%84%D9%81%D9%8A%D8%AF%D9%8A%D9%88-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#codec)، ومنه أيضاً [أي ترميز يعمل في أي متصفح](https://arabroot.io/articles/%D9%85%D9%81%D8%A7%D9%87%D9%8A%D9%85-%D8%A7%D9%84%D9%81%D9%8A%D8%AF%D9%8A%D9%88-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#browsers).

### Simulcast وSVC باختصار

في المكالمة الجماعية مع SFU قد يكون أحد المشاركين على ألياف سريعة وآخر على شبكة جوال ضعيفة، ولا يستطيع السيرفر أن يغير جودة الصورة بنفسه لأنه لا يفك الترميز، فكيف يعطي كل واحد ما يناسبه؟ هناك طريقتان: الأولى Simulcast كما في [RFC 8853](https://www.rfc-editor.org/rfc/rfc8853?ref=arabroot.io)، وفيها يرمز المرسل صورة الكاميرا نفسها عدة مرات بأحجام مختلفة ويرسلها كلها، فيختار السيرفر لكل مستقبل النسخة المناسبة، والثانية SVC، أي Scalable Video Coding، وفيها ترميز واحد مكون من طبقات، طبقة أساسية وطبقات تضيف التفاصيل أو عدد الإطارات، فيحذف السيرفر الطبقات العليا لمن لا يتحملها، وتضبطها في المتصفح عبر الخاصية `scalabilityMode` التي تعرفها [وثيقة SVC في W3C](https://www.w3.org/TR/webrtc-svc/?ref=arabroot.io)، وهي ما زالت مسودة عمل:

![جزءان: في الأعلى Simulcast، المرسل يرسل ثلاث نسخ 720p و360p و180p إلى سيرفر SFU، والسيرفر يرسل 720p لجهاز على شبكة سريعة و180p لهاتف على شبكة جوال ضعيفة، وفي الأسفل SVC، المرسل يرسل ترميزاً واحداً من ثلاث طبقات مثل L3T3، والسيرفر يرسل كل الطبقات للجهاز السريع والطبقة الأساسية فقط للهاتف](https://arabroot.io/content/images/2026/10/webrtc-basics-05-simulcast-svc.webp)

في Simulcast يدفع المرسل ثمن عدة نسخ، وفي SVC ترميز واحد يحذف منه السيرفر ما لا يلزم

وفي المكالمة المباشرة بين شخصين لا تحتاج أياً منهما، لأن المرسل يعرف حال المستقبل الوحيد ويضبط الجودة له مباشرة، ولذلك ظهر في تجربتنا `scalabilityMode=L1T1`، أي طبقة مكانية واحدة وطبقة زمنية واحدة، وهي القيمة التي تعني في الوثيقة أن SVC معطل.

## قنوات البيانات Data Channels

قناة البيانات تبدو من JavaScript مثل WebSocket، ولكنها تحتها شيء مختلف تماماً، فقد عرفها [RFC 8831](https://www.rfc-editor.org/rfc/rfc8831?ref=arabroot.io) بأنها بروتوكول SCTP يعمل داخل DTLS كما في [RFC 8261](https://www.rfc-editor.org/rfc/rfc8261?ref=arabroot.io)، وDTLS يعمل فوق ICE وUDP، أي على نفس الطريق ونفس التشفير الذي تمر عليه الوسائط. وقد اختير SCTP لأنه يعطيك في الاتصال الواحد عدة قنوات مستقلة، فلا تتأخر رسالة في قناة بسبب رسالة ضائعة في قناة أخرى، ولأنه يسمح لكل قناة أن تختار طريقة التسليم:

- **مرتبة وموثوقة Ordered + Reliable:** وهي الافتراضية، فكل رسالة تصل وبالترتيب، وتناسب المحادثة النصية ونقل الملفات.
- **غير مرتبة أو غير موثوقة:** تمرر للدالة `createDataChannel()` الخيار `ordered: false` مع `maxRetransmits` أو `maxPacketLifeTime`، فإذا ضاعت رسالة لا يعاد إرسالها، أو يعاد مرة محدودة، وتناسب موقع اللاعب في لعبة، لأن الموقع القديم بعد وصول موقع أحدث لا قيمة له.

وهذه بالضبط الأمثلة التي يذكرها المعيار في [قسم حالات الاستخدام](https://www.rfc-editor.org/rfc/rfc8831?ref=arabroot.io#section-3): لعبة في الزمن الحقيقي ترسل المواقع عبر قناة غير موثوقة وحالة اللعبة المهمة عبر قناة موثوقة، ونقل الملفات بين شخصين أثناء المحادثة، والمحادثة النصية أثناء مكالمة صوت أو صورة. ولفتح القناة هناك طريقتان، إما أن ينشئها طرف فيرسل المتصفح رسالة فتح للطرف الآخر عبر بروتوكول DCEP الذي يعرفه [RFC 8832](https://www.rfc-editor.org/rfc/rfc8832?ref=arabroot.io) فيصل إليه الحدث `datachannel`، وإما أن يتفق الطرفان مسبقاً ويستخدم كل منهما `negotiated: true` مع رقم `id` نفسه، وهذه الطريقة الثانية هي التي استخدمناها في التجربة لأنها أبسط عندما يكون الكود نفسه عند الطرفين.

## جودة المكالمة: تقدير السرعة ومخزن التذبذب

الشبكة بين طرفي المكالمة لا تبقى على حال، فالسرعة المتاحة تنقص عندما يبدأ أحد في البيت مشاهدة فيديو، والحزم تتأخر بمقادير مختلفة، وبعضها يضيع، ولو أرسل المتصفح الصورة بمعدل ثابت لتجمدت المكالمة عند أول ازدحام. لذلك يعمل داخل `RTCPeerConnection` نظامان لا تراهما، والصورة التالية تبين كيف يعملان:

![المرسل على اليمين يرسل حزماً عبر الشبكة إلى المستقبل على اليسار فتصل بتباعد غير منتظم، وعند المستقبل مخزن التذبذب يجمع الحزم وينتظر قليلاً ثم يشغلها بانتظام، والمستقبل يرسل تغذية راجعة RTCP من نوع transport-cc فيها وقت وصول كل حزمة وما ضاع منها، والمرسل يقدر منها السرعة المتاحة ويطلب من المرمز خفض معدل البت أو رفعه، وفي الأسفل أرقام getStats من تجربتنا: availableOutgoingBitrate نحو 3.3 Mbps وVP8 بدقة 640×480 بين 404 و436 kbps وRTT نحو 1 ms ومخزن الصوت بين 93 و97 ms وpacketsLost صفر](https://arabroot.io/content/images/2026/10/webrtc-basics-06-quality.webp)

المستقبل يخبر المرسل بما وصل ومتى، والمرسل يغير معدل البت، ومخزن التذبذب يخفي اختلاف التأخير

### تقدير السرعة Bandwidth Estimation

يرسل المستقبل تقارير RTCP يخبر فيها المرسل بوقت وصول كل حزمة، فإذا بدأ التأخير يزيد بين حزمة وأخرى فهذا يعني أن طابوراً يمتلئ في Router ما في الطريق قبل أن تبدأ الحزم بالضياع، فيخفض المرسل معدل البت مبكراً، وإذا استقر التأخير رفعه تدريجياً. وأشهر خوارزمية لذلك هي Google Congestion Control الموصوفة في [مسودة IETF](https://datatracker.ietf.org/doc/html/draft-ietf-rmcat-gcc-02?ref=arabroot.io) لم تصبح RFC، وستجد في عرض Chrome في التجربة السطر `a=rtcp-fb:96 transport-cc` والامتداد [transport-wide-cc](https://datatracker.ietf.org/doc/html/draft-holmer-rmcat-transport-wide-cc-extensions-01?ref=arabroot.io)، وهما الإعلان عن هذا النوع من التقارير. وتجد في العرض نفسه سطري `nack` و`nack pli`، والأول يطلب إعادة إرسال حزمة ضائعة، والثاني يطلب إطاراً مفتاحياً جديداً عندما يفسد ما يكفي من الصورة، وكلاهما من رسائل [RFC 4585](https://www.rfc-editor.org/rfc/rfc4585?ref=arabroot.io)، أما إعادة الإرسال نفسها فتمر في تدفق منفصل اسمه `rtx` كما في [RFC 4588](https://www.rfc-editor.org/rfc/rfc4588?ref=arabroot.io). والنتيجة التي تراها أنت هي الرقم `availableOutgoingBitrate` في `getStats()`، أي تقدير المتصفح للسرعة المتاحة الآن.

### مخزن التذبذب Jitter Buffer

التذبذب Jitter هو اختلاف التأخير بين الحزم، فلو خرجت حزم الصوت من المرسل كل 20 ملي ثانية بانتظام قد تصل واحدة بعد 30 وأخرى بعد 45 وثالثة بعد 31، ولو شغلها المستقبل فور وصولها لسمعت صوتاً متقطعاً، لذلك يحتفظ المستقبل بالحزم في مخزن صغير ينتظر فيه قليلاً، ثم يشغلها بانتظام، ويكبر هذا المخزن عندما يزيد التذبذب ويصغر عندما يهدأ، والثمن تأخير إضافي بسيط. وتعرف [وثيقة الإحصاءات](https://www.w3.org/TR/webrtc-stats/?ref=arabroot.io) الرقمين `jitter` و`jitterBufferDelay`، ومن الثاني مقسوماً على `jitterBufferEmittedCount` تعرف متوسط ما تنتظره كل عينة في المخزن، وهو ما حسبناه في التجربة.

### getStats وchrome://webrtc-internals

عندما يشتكي مستخدم من أن الصورة متقطعة، فلا تخمن، وإنما اقرأ الأرقام: الدالة `pc.getStats()` تعيد تقريراً فيه عشرات الكائنات، وأهمها `outbound-rtp` لما ترسله، و`inbound-rtp` لما تستقبله ومعه `packetsLost` و`jitter`، و`candidate-pair` للطريق المختار ومعه `currentRoundTripTime`، و`transport` لحالة DTLS، و`codec` للصيغة المستخدمة فعلاً. وقد شرحنا في [كيف تعرف أن المكالمة مرت عبر TURN](https://arabroot.io/articles/%D9%85%D9%83%D8%A7%D9%84%D9%85%D8%A7%D8%AA-webrtc-%D8%AE%D9%84%D9%81-nat-%D8%B4%D8%B1%D8%AD-ice-%D9%88-stun-%D9%88-turn/#browser-check) كيف تصل من هذا التقرير إلى نوع العنوان المختار. وإذا كنت تشخص على جهازك فافتح في Chrome صفحة `chrome://webrtc-internals` في تبويب جديد أثناء المكالمة، فتجد كل اتصال مفتوح وأحداثه بالترتيب والجداول نفسها مع رسوم بيانية لكل رقم، والصورة التالية من التجربة:

![صفحة chrome://webrtc-internals أثناء مكالمة التجربة: رابط الصفحة 127.0.0.1:18480 وحالة ICE completed وحالة الاتصال connected وحالة التنسيق stable والزوج المختار 127.0.0.1:62336 مع 127.0.0.1:62354، ثم أحداث setLocalDescription للعرض وsetRemoteDescription للجواب، ثم جداول الإحصاءات: outbound-rtp للصوت بصيغة opus وللصورة بصيغة VP8 مع scalabilityMode=L1T1 وencoderImplementation=libvpx، وinbound-rtp، وشهادتين، والزوج المختار بحالة succeeded، وقناة البيانات chat مفتوحة](https://arabroot.io/content/images/2026/10/webrtc-basics-08-webrtc-internals.webp)

صفحة webrtc-internals تعرض كل اتصال مفتوح في المتصفح وأحداثه وإحصاءاته

## تجربة عملية: مكالمة فيديو بين متصفحين وسيرفر تنسيق صغير

الآن سوف نبني كل ما سبق بأقل كود ممكن: صفحة HTML واحدة فيها مكالمة فيديو بين شخصين وقناة بيانات، وسيرفر تنسيق بلغة Node يعمل في Container على المنفذ `127.0.0.1:18480`، ثم نفتح الصفحة في متصفحين ونقرأ ما يحدث. والسيرفر نفسه يقدم الصفحة أيضاً، حتى يكون المصدر واحداً.

### سيرفر التنسيق: غرف لشخصين

نبدأ بمجلد التجربة، ونكتب فيه الملف `server.mjs`، وهو كل سيرفر التنسيق، ويستخدم مكتبة [ws 8.22.0](https://github.com/websockets/ws/releases/tag/8.22.0?ref=arabroot.io) لاتصالات WebSocket:

```bash
mkdir lab-webrtc && cd lab-webrtc
```

```javascript
// Minimal signalling server: rooms of two people, every message is passed to the other one as is.
// It also serves the call page (index.html) on the same port.
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { WebSocketServer } from 'ws';

const page = readFileSync(new URL('./index.html', import.meta.url));
const http = createServer((req, res) => {
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
  res.end(page);
});

const wss = new WebSocketServer({ server: http, path: '/ws' });
const rooms = new Map();

wss.on('connection', (ws, req) => {
  const room = new URL(req.url, 'http://local').searchParams.get('room') || 'demo';
  const peers = rooms.get(room) || new Set();
  if (peers.size >= 2) return ws.close(4000, 'room full');
  peers.add(ws);
  rooms.set(room, peers);
  console.log(`join   room=${room} peers=${peers.size}`);
  // the second person to arrive starts the call
  if (peers.size === 2) ws.send(JSON.stringify({ type: 'ready' }));

  ws.on('message', (data) => {
    const msg = JSON.parse(data);
    console.log(`relay  room=${room} type=${msg.type} bytes=${data.length}`);
    for (const p of peers) if (p !== ws) p.send(data.toString());
  });
  ws.on('close', () => {
    peers.delete(ws);
    console.log(`leave  room=${room} peers=${peers.size}`);
  });
});

http.listen(8080, () => console.log('signalling server on :8080'));
```

في الكود أعلاه لاحظ التالي:

- السيرفر لا يفهم شيئاً من WebRTC، فهو لا يقرأ ال SDP ولا يعرف ما هو ICE، وكل عمله أن يمرر أي رسالة تصله إلى الشخص الآخر في الغرفة نفسها كما هي، وهذا يكفي لمكالمة كاملة.
- عندما يصل الشخص الثاني يرسل له السيرفر رسالة `ready`، فيبدأ هو المكالمة، وبهذا نتجنب حالة يرسل فيها الطرفان عرضاً في الوقت نفسه.
- لا يوجد هنا أي تحقق من الهوية ولا TLS، وهذا مقبول على `127.0.0.1` فقط، أما في الإنتاج فهذا هو السيرفر الذي قلنا في [قسم البصمة](#fingerprint) إنه جزء من أمان المكالمة.

### صفحة المكالمة

بعد ذلك نكتب الصفحة `index.html` في المجلد نفسه، وكل منطق المكالمة فيها، مرقماً بنفس ترتيب الخطوات في [الصورة أعلاه](#call-lifecycle):

```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>WebRTC 1:1 call</title>
<style>
  body { font: 14px system-ui, sans-serif; margin: 16px; background: #15121a; color: #eee; }
  h3 { margin: 0 0 10px; } video { width: 320px; height: 240px; background: #000; border-radius: 8px; margin-right: 8px; }
  pre { font: 12px Consolas, monospace; white-space: pre-wrap; color: #9fe3b8; }
</style>
</head>
<body>
<h3>role: <span id="role">waiting</span> · connection: <span id="state">new</span></h3>
<video id="local" autoplay muted playsinline></video><video id="remote" autoplay playsinline></video>
<pre id="log"></pre>
<script type="module">
const params = new URLSearchParams(location.search);
const room = params.get('room') || 'demo';
const lab = window.lab = { role: 'waiting', candidates: [], messages: [], events: [] };
const log = (line) => { lab.events.push(line); document.getElementById('log').textContent += line + '\n'; };

// 1. Camera and microphone: the browser asks the user for permission here
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: { width: 640, height: 480 } });
document.getElementById('local').srcObject = stream;
log(`getUserMedia: ${stream.getTracks().map(t => `${t.kind} "${t.label}"`).join(', ')}`);

// 2. The connection. In production put your STUN and TURN servers in iceServers
const pc = window.pc = new RTCPeerConnection({ iceServers: [] });
for (const track of stream.getTracks()) pc.addTrack(track, stream);

// 3. A data channel next to the media; negotiated: both sides create it with the same id
const chat = pc.createDataChannel('chat', { negotiated: true, id: 0 });
chat.onopen = () => { chat.send(`hello from the ${lab.role}`); log('data channel open'); };
chat.onmessage = (e) => { lab.messages.push(e.data); log(`data channel <- ${e.data}`); };

// 4. Remote media arrives as tracks
pc.ontrack = (e) => { document.getElementById('remote').srcObject = e.streams[0]; };
pc.onconnectionstatechange = () => { document.getElementById('state').textContent = pc.connectionState; log(`connection: ${pc.connectionState}`); };
pc.onsignalingstatechange = () => log(`signaling: ${pc.signalingState}`);

// 5. Our signalling channel: a WebSocket to our own server
const ws = new WebSocket(`ws://${location.host}/ws?room=${room}`);
const send = (msg) => ws.send(JSON.stringify(msg));
ws.onclose = () => log('signalling: closed');

// Each address (candidate) is sent as soon as it is found (Trickle ICE).
// With ?loopback both browsers are on one machine and only the 127.0.0.1 candidates are sent.
const loopbackOnly = params.has('loopback');
pc.onicecandidate = (e) => {
  if (!e.candidate || (loopbackOnly && !e.candidate.candidate.includes(' 127.0.0.1 '))) return;
  lab.candidates.push(e.candidate.candidate);
  send({ type: 'candidate', candidate: e.candidate });
};

async function handle(msg) {
  if (msg.type === 'ready') {                       // we arrived second, so we call
    lab.role = 'caller';
    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);
    lab.offer = offer.sdp;
    send({ type: 'offer', sdp: offer.sdp });
  } else if (msg.type === 'offer') {
    lab.role = 'callee';
    lab.offer = msg.sdp;
    await pc.setRemoteDescription({ type: 'offer', sdp: msg.sdp });
    const answer = await pc.createAnswer();
    await pc.setLocalDescription(answer);
    lab.answer = answer.sdp;
    if (params.get('drop') === 'answer') return log('answer NOT sent (drop=answer)');
    send({ type: 'answer', sdp: answer.sdp });
  } else if (msg.type === 'answer') {
    lab.answer = msg.sdp;
    await pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp });
  } else if (msg.type === 'candidate') {
    await pc.addIceCandidate(msg.candidate);
  }
  document.getElementById('role').textContent = lab.role;
}
// messages are handled one after the other, so a candidate never arrives before its offer
let queue = Promise.resolve();
ws.onmessage = ({ data }) => { queue = queue.then(() => handle(JSON.parse(data))).catch(e => log(`error: ${e.message}`)); };

// 6. Numbers from getStats(): codec, bitrate, round trip time, DTLS and SRTP
lab.stats = async () => {
  const pick = async () => { const m = new Map(); (await pc.getStats()).forEach(s => m.set(s.id, s)); return m; };
  const a = await pick(); await new Promise(r => setTimeout(r, 2000)); const b = await pick();
  const all = [...b.values()];
  const rate = (s) => { const old = a.get(s.id); return old ? Math.round((s.bytesSent - old.bytesSent) * 8 / ((s.timestamp - old.timestamp) / 1000) / 1000) : null; };
  const out = all.filter(s => s.type === 'outbound-rtp').map(s => ({
    kind: s.kind, codec: b.get(s.codecId)?.mimeType, clockRate: b.get(s.codecId)?.clockRate, kbps: rate(s),
    ...(s.kind === 'video' ? { frameWidth: s.frameWidth, frameHeight: s.frameHeight, framesPerSecond: s.framesPerSecond, encoderImplementation: s.encoderImplementation } : {}) }));
  const inb = all.filter(s => s.type === 'inbound-rtp').map(s => ({ kind: s.kind, packetsReceived: s.packetsReceived, packetsLost: s.packetsLost,
    jitter: s.jitter, jitterBufferDelayMs: s.jitterBufferEmittedCount ? Math.round(s.jitterBufferDelay / s.jitterBufferEmittedCount * 1000) : null }));
  const t = all.find(s => s.type === 'transport');
  const pair = b.get(t?.selectedCandidatePairId);
  const cert = all.filter(s => s.type === 'certificate').map(s => `${s.fingerprintAlgorithm} ${s.fingerprint}`);
  return { outbound: out, inbound: inb,
    transport: { dtlsState: t?.dtlsState, tlsVersion: t?.tlsVersion, dtlsCipher: t?.dtlsCipher, srtpCipher: t?.srtpCipher, dtlsRole: t?.dtlsRole },
    pair: { local: b.get(pair?.localCandidateId)?.address + ':' + b.get(pair?.localCandidateId)?.port, remote: b.get(pair?.remoteCandidateId)?.address + ':' + b.get(pair?.remoteCandidateId)?.port,
      currentRoundTripTime: pair?.currentRoundTripTime, availableOutgoingBitrate: pair?.availableOutgoingBitrate },
    certificates: cert };
};
lab.ready = true;
</script>
</body>
</html>
```

في الكود أعلاه لاحظ التالي:

- الخيار `iceServers` فارغ، لأن المتصفحين على الجهاز نفسه، وفي الإنتاج تضع فيه عناوين STUN وTURN كما في [إعداد coturn لسيرفر حقيقي](https://arabroot.io/articles/%D9%85%D9%83%D8%A7%D9%84%D9%85%D8%A7%D8%AA-webrtc-%D8%AE%D9%84%D9%81-nat-%D8%B4%D8%B1%D8%AD-ice-%D9%88-stun-%D9%88-turn/#production).
- الرسائل القادمة من السيرفر تعالج واحدة بعد الأخرى عبر سلسلة `queue`، والسبب أن معالجة العرض تحتوي على `await`، فلو وصل عنوان مرشح أثناء ذلك واستدعينا له `addIceCandidate()` قبل أن يثبت العرض لرمى المتصفح خطأ، وهذا الخطأ نفسه سوف نراه بعد قليل في حالة أخرى.
- الخيار `?loopback` في الرابط يجعل الصفحة ترسل العناوين المحلية `127.0.0.1` فقط، وهو للتشغيل بمتصفحين على نفس الجهاز مع الخيار `--allow-loopback-in-peer-connection` كما سيأتي، أما دونه فترسل الصفحة كل العناوين.
- الخيار `?drop=answer` يجعل المستقبل يكتب الجواب ولا يرسله، وسوف نستخدمه لنرى ما يحدث عندما ينقطع التنسيق في منتصفه.
- الدالة `lab.stats()` تأخذ تقرير `getStats()` مرتين بينهما ثانيتان، فتحسب معدل الإرسال الحقيقي بالكيلوبت في الثانية من فرق `bytesSent`، لأن التقرير يعطي المجموع منذ بداية المكالمة وليس المعدل.

### تشغيل السيرفر وفتح الصفحة في متصفحين

نشغل السيرفر في Container من Docker Image الرسمية ل Node بإصدار محدد، ونربط مجلد التجربة للقراءة فقط، ثم ننسخ الملفين داخل ال Container ونثبت المكتبة هناك، حتى لا يظهر مجلد `node_modules` في مجلدك:

```bash
docker run -d --name lab-webrtc-signal -p 127.0.0.1:18480:8080 \
  -v "$PWD:/lab:ro" node:24.21.0-alpine3.24 \
  sh -c "mkdir -p /app && cp /lab/server.mjs /lab/index.html /app/ && cd /app && npm install --no-audit --no-fund ws@8.22.0 && node server.mjs"
docker logs lab-webrtc-signal
```

والمخرج سوف يكون كما يلي:

```text
added 1 package in 5s
signalling server on :8080
```

الآن افتح الرابط `http://127.0.0.1:18480/?room=demo` في نافذتين من المتصفح، أو في متصفحين مختلفين، ووافق على إذن الكاميرا في كل منهما، فتبدأ المكالمة عندما تفتح النافذة الثانية. وإذا لم تكن لديك كاميرا، أو أردت تشغيل المكالمة في خط CI دون شاشة، فشغل Chrome بالخيار `--use-fake-device-for-media-stream` الذي يضع كاميرا وميكروفوناً وهميين مكان الحقيقيين كما في [تعريفه في كود Chromium](https://github.com/chromium/chromium/blob/main/media/base/media%5Fswitches.cc?ref=arabroot.io)، ومعه `--use-fake-ui-for-media-stream` الذي يوافق على الإذن تلقائياً كما في [صفحة الاختبار في webrtc.org](https://webrtc.org/getting-started/testing?ref=arabroot.io)، و`--allow-loopback-in-peer-connection` الذي يسمح بالعنوان `127.0.0.1` في العناوين المرشحة، وكل نسخة بمجلد ملف شخصي مستقل:

```bash
google-chrome --headless=new --user-data-dir=/tmp/webrtc-a --remote-debugging-port=9431 \
  --use-fake-device-for-media-stream --use-fake-ui-for-media-stream --allow-loopback-in-peer-connection \
  "http://127.0.0.1:18480/?room=call1&loopback"
google-chrome --headless=new --user-data-dir=/tmp/webrtc-b --remote-debugging-port=9432 \
  --use-fake-device-for-media-stream --use-fake-ui-for-media-stream --allow-loopback-in-peer-connection \
  "http://127.0.0.1:18480/?room=call1&loopback"
```

وهذه الطريقة هي التي استخدمناها لكتابة المخرجات في هذا القسم، فكل الأرقام والأسطر التالية من مكالمة حقيقية بين نسختين من Chrome 154 بهذه الخيارات، قرأنا قيمها من الكائن `window.lab` في كل صفحة عبر DevTools، وتستطيع أن تقرأها أنت بنفس الأسماء من نافذة Console في أي من النافذتين. والصورة التالية للصفحة عند المستقبل، وفيها الكاميرا الوهمية التي يرسمها Chrome، دائرة تدور مع عداد للوقت والإطارات، صورته المحلية على اليسار وصورة المتصل على اليمين:

![صفحة المكالمة عند المستقبل بخلفية داكنة: في الأعلى role callee وconnection connected، ثم مربعان أخضران من الكاميرا الوهمية في Chrome فيهما دائرة تدور وعداد وقت، المحلي والبعيد، وتحتهما سجل الأحداث: getUserMedia مع Fake Default Audio Input وfake_device_0، ثم signaling have-remote-offer ثم stable، ثم connection connecting ثم connected، ثم data channel open، ثم وصول الرسالة hello from the caller](https://arabroot.io/content/images/2026/10/webrtc-basics-07-lab-call.webp)

المكالمة عند المستقبل: الصورتان من الكاميرا الوهمية في Chrome، والسجل بترتيب الأحداث

وسجل السيرفر في هذه اللحظة يبين كل ما مر عبره:

```bash
docker logs lab-webrtc-signal
```

```text
signalling server on :8080
join   room=call1 peers=1
join   room=call1 peers=2
relay  room=call1 type=offer bytes=6104
relay  room=call1 type=candidate bytes=221
relay  room=call1 type=candidate bytes=221
relay  room=call1 type=candidate bytes=221
relay  room=call1 type=answer bytes=5802
relay  room=call1 type=candidate bytes=221
```

في المخرج أعلاه لاحظ التالي:

- كل المكالمة من ناحية السيرفر ست رسائل، عرض حجمه 6104 بايت وجواب حجمه 5802 بايت وأربعة عناوين، وبعدها لا يمر عبر السيرفر بايت واحد من الصوت أو الصورة.
- أرسل المتصل ثلاثة عناوين والمستقبل عنواناً واحداً، والسبب أن المتصل لا يعرف عند كتابة العرض إن كان الطرف الآخر يقبل جمع الوسائط الثلاث على منفذ واحد، فيجهز منفذاً لكل قسم، أما المستقبل فيعرف من العرض أن الطرفين يدعمان ذلك كما سنرى في السطر `a=group:BUNDLE`.

### نقرأ العرض والجواب SDP

العرض الكامل 171 سطراً، وأغلبه تفاصيل الصيغ، فنأخذ منه الأسطر المهمة بهذا الأمر في Console عند المتصل:

```javascript
lab.offer.split('\r\n').filter(l => /^(m=|a=(group|ice-ufrag|ice-pwd|ice-options|fingerprint|setup|mid|rtcp-mux|sctp-port|max-message-size|rtpmap:(111|0|8|96|102|45|98) |rtcp-fb:96 ))/.test(l)).join('\n')
```

والمخرج سوف يكون كما يلي، بعد أن حذفنا تكرار أسطر ICE والبصمة في كل قسم لأنها متطابقة:

```text
a=group:BUNDLE 0 1 2
m=audio 9 UDP/TLS/RTP/SAVPF 111 63 9 0 8 13 110 126
a=ice-ufrag:XCO4
a=ice-pwd:6suMtr3tYXBS5sBFKVbFTzsM
a=ice-options:trickle
a=fingerprint:sha-256 B7:CF:16:FC:42:78:3E:FB:5B:59:52:09:83:39:0E:63:AC:7F:69:28:6E:45:0D:A4:10:1B:F0:34:54:A8:D4:39
a=setup:actpass
a=mid:0
a=rtcp-mux
a=rtpmap:111 opus/48000/2
a=rtpmap:0 PCMU/8000
a=rtpmap:8 PCMA/8000
m=video 9 UDP/TLS/RTP/SAVPF 96 97 102 103 104 107 108 109 114 115 116 117 39 40 45 46 98 99 100 101 118 119 120
a=mid:1
a=rtpmap:96 VP8/90000
a=rtcp-fb:96 goog-remb
a=rtcp-fb:96 transport-cc
a=rtcp-fb:96 ccm fir
a=rtcp-fb:96 nack
a=rtcp-fb:96 nack pli
a=rtpmap:102 H264/90000
a=rtpmap:45 AV1/90000
a=rtpmap:98 VP9/90000
m=application 9 UDP/DTLS/SCTP webrtc-datachannel
a=mid:2
a=sctp-port:5000
a=max-message-size:262144
```

في المخرج أعلاه لاحظ التالي:

- ثلاثة أسطر `m=`، أي ثلاثة أقسام: صوت وصورة وقناة البيانات، والسطر `a=group:BUNDLE 0 1 2` يطلب جمعها كلها على منفذ واحد، وهذا ما وافق عليه الجواب.
- البروتوكول في قسمي الصوت والصورة `UDP/TLS/RTP/SAVPF`، والحرف S فيه يعني Secure، أي SRTP، فلا يوجد في عرض WebRTC أي قسم بغير تشفير، وفي قسم البيانات `UDP/DTLS/SCTP` كما في RFC 8831.
- الأرقام بعد `m=audio` هي الصيغ بترتيب التفضيل، و111 أولها، وهي `opus/48000/2`، ثم تجد الصيغتين الإلزاميتين PCMU وPCMA برقميهما الثابتين 0 و8، وفي الفيديو VP8 أولاً ثم H.264 وAV1 وVP9.
- لا يوجد أي سطر `a=candidate` في العرض، والسبب السطر `a=ice-options:trickle`، فالعناوين ترسل بعده واحداً واحداً كما رأينا في سجل السيرفر.
- السطر `a=setup:actpass` يعني أن المتصل يقبل أن يكون أي طرف في مصافحة DTLS، والجواب هو الذي يحدد.

وفي الجواب عند المتصل نفس الأقسام، مع بصمة مختلفة واختيار واضح للدور:

```javascript
lab.answer.split('\r\n').filter(l => /^(a=group|m=|a=ice-ufrag|a=fingerprint|a=setup|a=rtpmap:(111|96) )/.test(l)).join('\n')
```

```text
a=group:BUNDLE 0 1 2
m=audio 9 UDP/TLS/RTP/SAVPF 111 63 9 0 8 13 110 126
a=ice-ufrag:piGC
a=fingerprint:sha-256 16:CF:1D:0E:A1:A6:1E:1F:7E:A3:7C:8F:E8:DE:A3:29:FE:2A:8A:61:B3:2A:EA:71:EF:BF:A2:89:9A:F8:E8:B3
a=setup:active
a=rtpmap:111 opus/48000/2
m=video 9 UDP/TLS/RTP/SAVPF 96 97 102 103 104 107 108 109 114 115 116 117 39 40 45 46 98 99 100 101 118 119 120
a=rtpmap:96 VP8/90000
m=application 9 UDP/DTLS/SCTP webrtc-datachannel
```

والسطر `a=setup:active` يعني أن المستقبل هو الذي يبدأ مصافحة DTLS، أي أنه عميل DTLS، والمتصل سيرفر DTLS، وسوف نرى ذلك مكتوباً في الإحصاءات بعد قليل. أما العناوين التي وصلت عبر السيرفر فهي في `lab.candidates`، وعند المتصل كانت كما يلي:

```text
candidate:1700221239 1 udp 2122194687 127.0.0.1 62336 typ host generation 0 ufrag XCO4 network-id 1 network-cost 50
candidate:1700221239 1 udp 2122194687 127.0.0.1 62342 typ host generation 0 ufrag XCO4 network-id 1 network-cost 50
candidate:1700221239 1 udp 2122194687 127.0.0.1 62348 typ host generation 0 ufrag XCO4 network-id 1 network-cost 50
```

وهي ثلاثة عناوين من نوع `host` على ثلاثة منافذ، واحد لكل قسم قبل أن يتفق الطرفان على BUNDLE، وقد شرحنا أنواع العناوين وأولوياتها في [مقال ICE](https://arabroot.io/articles/%D9%85%D9%83%D8%A7%D9%84%D9%85%D8%A7%D8%AA-webrtc-%D8%AE%D9%84%D9%81-nat-%D8%B4%D8%B1%D8%AD-ice-%D9%88-stun-%D9%88-turn/#candidate-types).

### البصمة والتشفير في الإحصاءات

الآن نطلب الإحصاءات من الصفحة عند المستقبل:

```javascript
await lab.stats()
```

والمخرج سوف يكون كما يلي:

```json
{
  "outbound": [
    { "kind": "audio", "codec": "audio/opus", "clockRate": 48000, "kbps": 23 },
    { "kind": "video", "codec": "video/VP8", "clockRate": 90000, "kbps": 404,
      "frameWidth": 640, "frameHeight": 480, "framesPerSecond": 18, "encoderImplementation": "libvpx" }
  ],
  "inbound": [
    { "kind": "audio", "packetsReceived": 541, "packetsLost": 0, "jitter": 0.003, "jitterBufferDelayMs": 97 },
    { "kind": "video", "packetsReceived": 600, "packetsLost": 0, "jitter": 0.006, "jitterBufferDelayMs": 12 }
  ],
  "transport": {
    "dtlsState": "connected", "tlsVersion": "FEFC", "dtlsCipher": "TLS_AES_128_GCM_SHA256",
    "srtpCipher": "SRTP_AES128_CM_HMAC_SHA1_80", "dtlsRole": "client"
  },
  "pair": {
    "local": "127.0.0.1:62354", "remote": "127.0.0.1:62336",
    "currentRoundTripTime": 0.001, "availableOutgoingBitrate": 3260800
  },
  "certificates": [
    "sha-256 16:CF:1D:0E:A1:A6:1E:1F:7E:A3:7C:8F:E8:DE:A3:29:FE:2A:8A:61:B3:2A:EA:71:EF:BF:A2:89:9A:F8:E8:B3",
    "sha-256 B7:CF:16:FC:42:78:3E:FB:5B:59:52:09:83:39:0E:63:AC:7F:69:28:6E:45:0D:A4:10:1B:F0:34:54:A8:D4:39"
  ]
}
```

في المخرج أعلاه لاحظ التالي:

- الشهادتان في `certificates` هما نفس البصمتين اللتين رأيناهما في الجواب والعرض، شهادة المستقبل التي تبدأ ب `16:CF` وشهادة المتصل التي تبدأ ب `B7:CF`، أي أن المصافحة تمت مع الشهادة التي أعلن عنها الطرف الآخر في التنسيق.
- `dtlsRole` هنا `client` لأن المستقبل كتب `a=setup:active`، وعند المتصل كانت القيمة `server`.
- `tlsVersion` قيمته `FEFC`، وهي الرقم الذي يعرفه [RFC 9147](https://www.rfc-editor.org/rfc/rfc9147?ref=arabroot.io) للإصدار DTLS 1.3، ومعه `TLS_AES_128_GCM_SHA256` من صيغ TLS 1.3 التي شرحنا عائلتها في [التشفير الموثق AEAD](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%B4%D9%81%D9%8A%D8%B1-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86-%D8%A7%D9%84%D9%87%D8%A7%D8%B4-%D9%88%D9%83%D9%84%D9%85%D8%A7%D8%AA-%D8%A7%D9%84%D9%85%D8%B1%D9%88%D8%B1-%D9%88%D8%A7%D9%84%D9%85%D9%81%D8%A7%D8%AA%D9%8A%D8%AD/#aead)، أما الصوت والصورة فمشفران ب `SRTP_AES128_CM_HMAC_SHA1_80`، وهي الصيغة التي يلزم RFC 8827 كل متصفح بدعمها.
- الصيغتان المستخدمتان فعلاً Opus وVP8، أي أول صيغة في قائمة كل قسم، والفيديو 640×480 بنحو 18 إطاراً في الثانية و404 kbps، والصوت 23 kbps.
- `currentRoundTripTime` واحد ملي ثانية، و`availableOutgoingBitrate` نحو 3.3 Mbps، وكلاهما رقمان لا معنى لهما خارج هذا الجهاز، لأن الطريق هنا داخل الجهاز نفسه، وعلى شبكة حقيقية سوف تجد RTT بعشرات الملي ثواني والتقدير أقل بكثير ويتغير كل ثانية.
- متوسط انتظار الصوت في مخزن التذبذب 97 ملي ثانية والفيديو 12 ملي ثانية، وهذا هو التأخير الذي يدفعه المستقبل مقابل التشغيل المنتظم، ولاحظ أنه ليس صفراً حتى على جهاز واحد لا يكاد يوجد فيه `jitter`.

### رسالة عبر قناة البيانات

في الحدث `open` ترسل كل صفحة رسالة إلى الأخرى، فنقرأ ما وصل:

```javascript
lab.messages
```

والمخرج عند المستقبل سوف يكون كما يلي، وعند المتصل وصلت الرسالة `hello from the callee`:

```text
[ "hello from the caller" ]
```

ولاحظ في سجل السيرفر أعلاه أنه لا يوجد أي سطر لهاتين الرسالتين، فقد ذهبتا مباشرة من متصفح إلى متصفح داخل DTLS على نفس المنفذ الذي يمر عليه الصوت والصورة، وهذا هو الفرق بين قناة البيانات واتصال WebSocket عبر سيرفرك.

### ما الذي يدعمه هذا المتصفح؟

قبل أن تختار صيغة في تطبيقك اسأل المتصفح نفسه، فالدالة `RTCRtpSender.getCapabilities()` تعيد كل ما يستطيع إرساله:

```javascript
({
  audio: [...new Set(RTCRtpSender.getCapabilities('audio').codecs.map(c => c.mimeType))],
  video: [...new Set(RTCRtpSender.getCapabilities('video').codecs.map(c => c.mimeType))],
  RTCRtpScriptTransform: typeof RTCRtpScriptTransform,
  createEncodedStreams: 'createEncodedStreams' in RTCRtpSender.prototype
})
```

والمخرج في Chrome 154 سوف يكون كما يلي:

```json
{
  "audio": ["audio/opus", "audio/red", "audio/G722", "audio/PCMU", "audio/PCMA", "audio/CN", "audio/telephone-event"],
  "video": ["video/VP8", "video/rtx", "video/H264", "video/AV1", "video/VP9", "video/red", "video/ulpfec"],
  "RTCRtpScriptTransform": "function",
  "createEncodedStreams": true
}
```

فالصيغ الإلزامية الأربع موجودة، Opus وPCMU وPCMA وVP8 وH.264، ومعها صيغ أخرى، وبعض العناصر ليست صيغاً وإنما آليات حماية من الضياع، فال `rtx` لإعادة الإرسال، وال `red` وال `ulpfec` لإرسال نسخ احتياطية تعوض الحزم الضائعة، وال `telephone-event` لأزرار لوحة الهاتف. وفي السطرين الأخيرين ترى أن واجهتي التشفير بين الطرفين اللتين ذكرناهما في [قسم SFU](#e2ee-sfu) موجودتان في هذا الإصدار.

### نوقف سيرفر التنسيق أثناء المكالمة

الآن نكسر التنسيق عمداً، فنوقف ال Container والمكالمة ما زالت تعمل:

```bash
docker stop -t 1 lab-webrtc-signal
```

وقرأنا في صفحة المستقبل حالة الاتصال ومعدل الإرسال من `lab.stats()` قبل الإيقاف، ثم بعده بست ثوان مع آخر حدث في السجل، فكانت القيم كما يلي:

```text
before stop  connection=connected  kbps: audio 32, video 436
after stop   connection=connected  kbps: audio 32, video 390  last event: "signalling: closed"
```

أي أن الاتصال بالسيرفر انقطع، ولكن المكالمة بقيت `connected` والصوت والصورة يمران بنفس المعدل تقريباً، لأن السيرفر لم يكن في طريقهما أصلاً. أما من يحاول أن يبدأ مكالمة جديدة الآن فلا يصل إلى أي شيء، فقد ظهر للصفحة الجديدة الخطأ `ERR_CONNECTION_REFUSED`. والدرس هنا أن سقوط سيرفر التنسيق لا يقطع المكالمات القائمة، ولكنه يمنع كل مكالمة جديدة، ويمنع أيضاً أي تغيير في المكالمة القائمة يحتاج عرضاً جديداً، مثل إضافة مشاركة الشاشة أو إعادة ICE بعد أن يتغير عنوان الجهاز، لذلك راقبه كما تراقب أي خدمة أساسية. ثم نعيد تشغيله:

```bash
docker start lab-webrtc-signal
```

### الجواب لا يصل: المكالمة عالقة في المنتصف

الحالة الأصعب في التشخيص هي أن يعمل التنسيق جزئياً، فنفتح الصفحة الأولى بالخيار `drop=answer`، وهي التي تصبح المستقبل، ثم الثانية عادية:

```text
http://127.0.0.1:18480/?room=drop&drop=answer&loopback
http://127.0.0.1:18480/?room=drop&loopback
```

وبعد 35 ثانية نقرأ هذا الكائن في كل صفحة، ووضعنا القيمتين معاً تحت الاسمين `first` للصفحة الأولى و`second` للثانية:

```javascript
({ role: lab.role, signalingState: pc.signalingState, iceConnectionState: pc.iceConnectionState, connectionState: pc.connectionState, events: lab.events })
```

```json
{
  "first": {
    "role": "callee", "signalingState": "stable",
    "iceConnectionState": "connected", "connectionState": "connecting",
    "events": ["getUserMedia: audio \"Fake Default Audio Input\", video \"fake_device_0\"",
      "signaling: have-remote-offer", "signaling: stable", "answer NOT sent (drop=answer)", "connection: connecting"]
  },
  "second": {
    "role": "caller", "signalingState": "have-local-offer",
    "iceConnectionState": "checking", "connectionState": "connecting",
    "events": ["getUserMedia: audio \"Fake Default Audio Input\", video \"fake_device_0\"",
      "signaling: have-local-offer", "connection: connecting",
      "error: Failed to execute 'addIceCandidate' on 'RTCPeerConnection': The remote description was null"]
  }
}
```

![صفحة المتصل بخلفية داكنة: role caller وconnection connecting، الصورة المحلية من الكاميرا الوهمية تعمل والصورة البعيدة سوداء، وفي السجل signaling have-local-offer ثم connection connecting ثم الخطأ Failed to execute addIceCandidate on RTCPeerConnection: The remote description was null](https://arabroot.io/content/images/2026/10/webrtc-basics-09-lab-no-answer.webp)

الجواب لم يصل: المتصل عالق في have-local-offer والصورة البعيدة سوداء

في المخرج أعلاه لاحظ التالي:

- المتصل بقي في `have-local-offer`، وهذه هي العلامة الأولى أن الجواب لم يصل، ولم يتغير شيء بعد 35 ثانية لأن WebRTC لا يعيد إرسال رسائل التنسيق، فهذه مسؤولية كودك.
- وصلت إلى المتصل عناوين المستقبل، فحاول الكود إضافتها، فرمى المتصفح الخطأ `The remote description was null`، أي لا يمكن إضافة عنوان قبل أن يعرف المتصفح وصف الطرف الآخر، وهذا الخطأ نفسه هو ما تراه في تطبيق حقيقي عندما يصل العنوان قبل الجواب بسبب ترتيب الرسائل، وهو سبب السلسلة `queue` في الصفحة.
- عند المستقبل حدث شيء مثير: `iceConnectionState` وصل إلى `connected`، لأن لديه العرض وعناوين المتصل، فنجحت فحوص ICE، ولكن `connectionState` بقي `connecting`، والسبب أن مصافحة DTLS لا تكتمل، فالمتصل لا يعرف بصمة شهادة المستقبل لأنها في الجواب الذي لم يصله، فلا يقبل الشهادة. فالحالتان معاً تخبرانك أين انقطع الطريق: طريق الشبكة يعمل، والتنسيق ناقص.

### إزالة التجربة

أغلق نوافذ المتصفح، ثم احذف ال Container بالاسم، وإذا أردت احذف ال Docker Image أيضاً:

```bash
docker rm -f lab-webrtc-signal
docker image rm node:24.21.0-alpine3.24
cd .. && rm -r lab-webrtc
```

## WHIP وWHEP: WebRTC في البث المباشر

رأينا أن WebRTC لا يحدد التنسيق، وهذا مناسب لتطبيق مكالمات تكتبه بنفسك، ولكنه مشكلة في البث، فبرنامج مثل OBS يريد أن يرسل البث إلى أي منصة دون أن يعرف كود JavaScript الخاص بكل منصة. والحل كان بروتوكولاً بسيطاً فوق HTTP اسمه WHIP، أي WebRTC-HTTP Ingestion Protocol، وقد صدر في [RFC 9725](https://www.rfc-editor.org/rfc/rfc9725?ref=arabroot.io) في مارس 2025: يرسل البرنامج العرض SDP في طلب HTTP POST إلى رابط المنصة، فترد ب `201 Created` وفيه الجواب، ثم يبدأ WebRTC كالمعتاد، وعندما ينتهي البث يرسل طلب HTTP DELETE. وقد أضاف OBS Studio مخرج WHIP منذ [الإصدار 30.0](https://github.com/obsproject/obs-studio/releases/tag/30.0.0?ref=arabroot.io). وفي الاتجاه المعاكس، أي المشاهد الذي يستقبل البث، هناك WHEP بنفس الفكرة، وهو ما زال [مسودة IETF](https://datatracker.ietf.org/doc/draft-ietf-wish-whep/?ref=arabroot.io) لم تصبح RFC. والفائدة الكبرى من WebRTC في البث هي التأخير، وقد قسناه في [شرح بث الفيديو](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D9%8A%D8%B9%D9%85%D9%84-%D8%A8%D8%AB-%D8%A7%D9%84%D9%81%D9%8A%D8%AF%D9%8A%D9%88/#latency) مقابل HLS، وهناك أيضاً تجد مكان WHIP بين [بروتوكولات الإدخال Ingest](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D9%8A%D8%B9%D9%85%D9%84-%D8%A8%D8%AB-%D8%A7%D9%84%D9%81%D9%8A%D8%AF%D9%8A%D9%88/#ingest) مثل RTMP وSRT، فلن نعيد المقارنة هنا.

## متى تبني بنفسك ومتى تستخدم SFU جاهزاً مثل LiveKit؟

التجربة أعلاه مكالمة كاملة في أقل من 150 سطراً، وقد يغريك ذلك بأن تبني تطبيق اجتماعات بنفس الطريقة، وهنا يجب أن تفرق بين حالتين:

- **مكالمة بين شخصين، أو ثلاثة على الأكثر:** الطريقة المباشرة Peer-to-Peer كما في التجربة تكفي، ويكفيك سيرفر تنسيق صغير مثل الذي كتبناه بعد أن تضيف إليه التحقق من الهوية وTLS، وسيرفر TURN مثل coturn لمن يقف خلف جدار حماية. والسبب أن كل ما هو صعب، من التشفير وتقدير السرعة والصيغ، يقوم به المتصفح.
- **اجتماعات جماعية، أو تسجيل، أو بث لعدد كبير:** هنا تحتاج سيرفر SFU، لأن الطريقة المباشرة بين الجميع تملأ اتصال كل مشارك كما شرحنا في [Mesh وSFU وMCU](https://arabroot.io/articles/%D9%85%D9%83%D8%A7%D9%84%D9%85%D8%A7%D8%AA-webrtc-%D8%AE%D9%84%D9%81-nat-%D8%B4%D8%B1%D8%AD-ice-%D9%88-stun-%D9%88-turn/#topologies)، ومعه تحتاج Simulcast واختيار الطبقات لكل مستقبل، وإعادة الاتصال عندما يتغير عنوان الهاتف، والتسجيل، والتشفير بين الطرفين بمفاتيح توزعها أنت. وكتابة كل هذا من الصفر عمل فريق لسنوات، فالحل الأنسب أن تشغل SFU مفتوح المصدر على سيرفرك، مثل [LiveKit](https://github.com/livekit/livekit?ref=arabroot.io) المكتوب بلغة Go والمرخص بترخيص Apache 2.0، والذي يعطيك مع السيرفر مكتبات للمتصفح والجوال تتولى التنسيق عنك. وسوف نشرح تشغيله على سيرفرك في مقال LiveKit المخطط في [مرحلة الفيديو والبث المباشر](https://arabroot.io/start/#stage-video).

وحتى مع SFU جاهز فإن ما شرحناه هنا لا يذهب سدى، لأنك عندما يشتكي مستخدم من أن صوته لا يصل سوف تفتح `webrtc-internals` وتقرأ `signalingState` و`connectionState` والزوج المختار وأرقام الضياع، وهي نفس الأرقام سواءً كان في الطرف الآخر متصفح أو LiveKit.

## أخطاء شائعة

- **الصفحة تعمل على جهازك ولا تعمل من جهاز آخر:** فتحتها عبر `http://` بعنوان غير `localhost`، فلا يوجد `navigator.mediaDevices` أصلاً، والحل HTTPS.
- **الخطأ `The remote description was null`:** عنوان مرشح وصل قبل العرض أو الجواب، فعالج الرسائل بالترتيب، أو احتفظ بالعناوين حتى يثبت الوصف.
- **الطرفان يرسلان عرضاً في الوقت نفسه:** هذه الحالة اسمها Glare، فاجعل السيرفر يحدد من يبدأ كما فعلنا برسالة `ready`، أو اتبع نمط Perfect Negotiation الذي تذكره [وثيقة WebRTC](https://www.w3.org/TR/webrtc/?ref=arabroot.io) في أمثلتها.
- **المكالمة تعمل في المكتب ولا تعمل من شبكة الجوال أو العكس:** لا يوجد TURN في `iceServers`، والتفصيل في [مشكلات ICE الشائعة](https://arabroot.io/articles/%D9%85%D9%83%D8%A7%D9%84%D9%85%D8%A7%D8%AA-webrtc-%D8%AE%D9%84%D9%81-nat-%D8%B4%D8%B1%D8%AD-ice-%D9%88-stun-%D9%88-turn/#troubleshooting).
- **سيرفر التنسيق دون تحقق من الهوية:** أي شخص يعرف اسم الغرفة يدخل المكالمة، ومن يتحكم في السيرفر يستطيع تبديل البصمات، فعامله كواجهة API محمية.

## الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

- WebRTC معياران: الواجهة في W3C، وهي توصية منذ 26 يناير 2021 وحدثت في 13 مارس 2025، والبروتوكولات في IETF ومدخلها RFC 8825.
- ثلاث واجهات تكفي: `getUserMedia` للكاميرا والميكروفون في صفحة آمنة، و`RTCPeerConnection` للاتصال والتشفير والصيغ، و`RTCDataChannel` للبيانات على نفس الاتصال.
- التنسيق عليك أنت: العرض والجواب والعناوين تمر عبر سيرفرك بأي بروتوكول تختاره كما في JSEP، وبعدها لا يمر عبره شيء، فسقوطه لا يقطع المكالمات القائمة ولكنه يمنع الجديدة.
- التشفير إلزامي: DTLS-SRTP للوسائط وDTLS للبيانات، والبصمة في ال SDP هي التي تربط التشفير بالطرف الآخر، لذلك احم سيرفر التنسيق كجزء من أمان المكالمة.
- مع SFU يرى السيرفر الوسائط ما لم تستخدم التشفير بين الطرفين عبر Encoded Transform وSFrame، وتوزيع المفاتيح مسؤوليتك.
- الصيغ الإلزامية Opus وG.711 للصوت وVP8 وH.264 للفيديو، وSimulcast وSVC لإعطاء كل مشارك ما يناسبه عبر SFU.
- لا تخمن عند مشكلة الجودة، فاقرأ `getStats()` أو `chrome://webrtc-internals`، وابدأ ب `signalingState` ثم `iceConnectionState` ثم `connectionState`.
- لمكالمة بين شخصين ابن بنفسك، وللاجتماعات والبث استخدم SFU جاهزاً مثل LiveKit.

[![](https://arabroot.io/content/images/2026/10/webrtc-turn-cover-cover.webp)دليلمكالمات WebRTC خلف NAT: شرح STUN وTURN وICEلماذا تتصل المكالمة ولا يصل الصوت، وكيف تحل STUN وTURN المشكلة دون أن تفتح باباً إلى شبكتك الداخلية.](https://arabroot.io/articles/%D9%85%D9%83%D8%A7%D9%84%D9%85%D8%A7%D8%AA-webrtc-%D8%AE%D9%84%D9%81-nat-%D8%B4%D8%B1%D8%AD-ice-%D9%88-stun-%D9%88-turn/)[![](https://arabroot.io/content/images/2026/10/video-basics-cover-cover.webp)دليلمفاهيم الفيديو: الحاوية والترميز Codec ومعدل البتما الذي يحدد حجم ملف الفيديو وجودته، ولماذا يعمل ملف في متصفح ولا يعمل في آخر، مع تجربة حقيقية تقيس الفرق بين الترميزات.](https://arabroot.io/articles/%D9%85%D9%81%D8%A7%D9%87%D9%8A%D9%85-%D8%A7%D9%84%D9%81%D9%8A%D8%AF%D9%8A%D9%88-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/streaming-concepts-cover-cover.webp)دليلكيف يعمل بث الفيديو المباشر وعند الطلب؟كيف يصل الفيديو إلى كل مشاهد بجودة تناسب سرعة اتصاله، ولماذا يتأخر البث المباشر عشرين ثانية أحياناً وأقل من ثانية أحياناً أخرى.](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D9%8A%D8%B9%D9%85%D9%84-%D8%A8%D8%AB-%D8%A7%D9%84%D9%81%D9%8A%D8%AF%D9%8A%D9%88/)[![](https://arabroot.io/content/images/2026/10/nat-explained-cover-cover.webp)دليلما هو NAT؟ شرح ترجمة العناوين وتوجيه المنافذلماذا تخرج كل أجهزة بيتك بعنوان عام واحد، وكيف يترجم ال Router العناوين، ولماذا يفشل توجيه المنافذ خلف CGNAT.](https://arabroot.io/articles/%D9%85%D8%A7-%D9%87%D9%88-nat-%D8%B4%D8%B1%D8%AD-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/certificates-cover-cover.webp)دليلالشهادات الرقمية TLS للمبتدئين وصيغ ملفاتهاكيف يعرف المتصفح أنه يتحدث مع الموقع الحقيقي، وماذا يوجد داخل الشهادة، ولماذا تأتي ملفاتها بأكثر من صيغة.](https://arabroot.io/articles/%D8%A7%D9%84%D8%B4%D9%87%D8%A7%D8%AF%D8%A7%D8%AA-%D8%A7%D9%84%D8%B1%D9%82%D9%85%D9%8A%D8%A9-tls-%D9%88-x509-%D9%88%D8%B5%D9%8A%D8%BA-pem-%D9%88-der/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال، وبناء التجربة العملية بمكالمة فيديو بين نسختين من Chrome 154 مع سيرفر تنسيق على Node.js 24.21.0 ومكتبة ws 8.22.0.