عندما تضغط زر الاتصال في Google Meet أو في Mattermost أو في أي تطبيق مكالمات يعمل من المتصفح، فإن المتصفح يطلب منك إذن الكاميرا، ثم بعد ثانية أو ثانيتين ترى صورة زميلك وتسمع صوته، ولا تحتاج إلى تثبيت أي برنامج أو إضافة، والذي يقوم بكل هذا هو WebRTC، أي مجموعة من الواجهات البرمجية في المتصفح ومن البروتوكولات تحتها صممت لنقل الصوت والصورة والبيانات بين جهازين مباشرة في الزمن الحقيقي. وقد شرحنا في مكالمات WebRTC خلف NAT كيف يجد الطرفان طريقاً بينهما عبر 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، والثاني هو البروتوكولات التي تتحرك على الشبكة تحت هذه الواجهة، أي كيف يتفق الطرفان وكيف يجدان الطريق وكيف يشفران الصوت، وتكتبها هيئة IETF في مجموعة من وثائق RFC. وقد أعلنت الجهتان معاً في 26 يناير 2021 أن WebRTC أصبح معياراً رسمياً، فصارت الواجهة توصية W3C Recommendation، وصدرت في الشهر نفسه حزمة وثائق IETF، وأولها RFC 8825 الذي يعطي نظرة عامة على البروتوكولات ويربط بقية الوثائق ببعضها، ثم حدثت W3C التوصية بنسخة جديدة في 13 مارس 2025، وهي النسخة الحالية على الرابط نفسه.
وهذا التقسيم مهم لك كمطور، لأنه يحدد أين تبحث عندما تواجه مشكلة: إذا كان السؤال عن دالة أو حدث في JavaScript، مثل متى يطلق icecandidate أو ما الحالات التي يمر بها connectionState، فالإجابة في وثيقة W3C، وإذا كان السؤال عن شيء على الشبكة، مثل لماذا تفشل مصافحة DTLS أو ما معنى سطر في ال SDP، فالإجابة في وثائق RFC، وسوف نربط كل فكرة في هذا المقال بالوثيقة التي تعرفها.
ثلاث واجهات في المتصفح تكفي لمكالمة كاملة
كل ما يحتاجه تطبيق مكالمات من المتصفح ثلاث واجهات، والصورة التالية تبين ما تفعله كل واحدة، وأين يقف كودك أنت بينها:

getUserMedia: الكاميرا والميكروفون
الدالة navigator.mediaDevices.getUserMedia() لا تعرفها وثيقة WebRTC نفسها، وإنما وثيقة مستقلة اسمها Media Capture and Streams، وهي تطلب من المستخدم إذن الكاميرا أو الميكروفون، فإذا وافق أعادت كائناً من نوع MediaStream فيه مسار Track لكل جهاز، مسار للصوت ومسار للصورة، وإذا رفض أو لم توجد كاميرا رمت خطأ يجب أن يعالجه كودك. ولاحظ أن الواجهة كلها معرفة في المعيار بالخاصية SecureContext، أي أنها لا تعمل إلا في صفحة آمنة، وهذا يعني HTTPS في الإنتاج، أما أثناء التطوير فيعامل المتصفح localhost والعناوين 127.0.0.0/8 كأصل موثوق كما في معيار Secure Contexts، لذلك تعمل التجربة في آخر المقال على 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 تقريباً، والفرق أن الرسائل تذهب مباشرة إلى الطرف الآخر ولا تمر عبر سيرفرك، وسوف نعود إليها في قسم قنوات البيانات.
المكالمة خطوة خطوة
لنفرض أن لديك متصفحين، المتصل A والمستقبل B، وسيرفر تنسيق صغيراً كتبته بنفسك، فالصورة التالية تبين ما يحدث من لحظة طلب الكاميرا إلى أن تصل الصورة، وهي نفس الخطوات التي سوف نراها في التجربة العملية:

- يطلب A الكاميرا والميكروفون عبر
getUserMedia()، وينشئRTCPeerConnectionويضيف إليه المسارات. - يستدعي A الدالة
createOffer()فيحصل على عرض Offer مكتوب بصيغة SDP، ثم يثبته عنده بsetLocalDescription()، وفي هذه اللحظة يبدأ المتصفح جمع العناوين المرشحة. - يرسل كود A العرض إلى سيرفر التنسيق، والسيرفر يمرره إلى B كما هو.
- يثبت B العرض ب
setRemoteDescription()، ويطلب الكاميرا، ويكتب جواباً Answer بcreateAnswer()ويثبته عنده. - يعود الجواب عبر السيرفر إلى A الذي يثبته ب
setRemoteDescription()، وبهذا يعرف كل طرف صيغ الآخر وبصمة شهادته. - كل عنوان مرشح يكتشفه أي طرف يصل في الحدث
icecandidate، فيرسله الكود عبر السيرفر، ويضيفه الطرف الآخر بaddIceCandidate(). - يختبر ICE أزواج العناوين مباشرة بين المتصفحين، ويختار زوجاً يعمل.
- على الزوج المختار يجري الطرفان مصافحة DTLS، ويتحقق كل منهما أن شهادة الطرف الآخر تطابق البصمة التي وصلت في ال SDP.
- يبدأ الصوت والصورة عبر SRTP، وتفتح قناة البيانات عبر SCTP، وكل ذلك على نفس الطريق المباشر.
التنسيق Signaling: لماذا لا يحدده WebRTC؟
أول ما يفاجئ من يبدأ مع WebRTC أنه لا يوجد في المتصفح أي شيء يرسل العرض إلى الطرف الآخر، فالدالة createOffer() تعطيك نصاً، ثم تتركك. وهذا مقصود، فقد ذكر RFC 9429 الذي يعرف JSEP، أي JavaScript Session Establishment Protocol، أن تصميم المكالمة في WebRTC يركز على التحكم في طريق الوسائط، ويترك سلوك التنسيق للتطبيق قدر الإمكان، والسبب أن التطبيقات المختلفة تفضل بروتوكولات مختلفة، فتطبيق يريد أن يتصل ببدالة هاتف تعمل ب SIP، وآخر يكفيه WebSocket بسيط، وثالث يستخدم طلبات HTTP عادية. فالذي يحدده المعيار هو المحتوى، أي وصف الجلسة SDP الذي يعرفه RFC 8866، ونموذج العرض والجواب الذي شرحنا فكرته في عرض وجواب SDP في فقرة واحدة، أما كيف يصل هذا المحتوى ومن يسمح له بالاتصال بمن، فهو قرارك أنت.
وقد يتساءل البعض: إذا كان سيرفر التنسيق مجرد ساعي بريد، فهل يكفي أن أنشره بأي شكل؟ والإجابة لا، لأن هذا الساعي يحمل بصمة شهادة DTLS لكل طرف، ومن يستطيع تغيير البصمة في الطريق يستطيع أن يقف في وسط المكالمة كما سوف نرى في قسم البصمة، لذلك يجب أن يعمل سيرفر التنسيق عبر HTTPS أو WSS، وأن يتحقق من هوية المستخدم قبل أن يمرر رسائله، تماماً مثل أي واجهة API في تطبيقك. وإذا وضعته خلف Reverse Proxy فتذكر أن اتصال WebSocket يحتاج ترويستي Upgrade وConnection كما شرحنا في دليل تطبيقك خلف Reverse Proxy.
حالات العرض والجواب signalingState
يتتبع RTCPeerConnection مكانه في نموذج العرض والجواب في الخاصية signalingState، فيبدأ كل طرف في الحالة stable، وعندما يثبت المتصل عرضه ينتقل إلى have-local-offer، وعندما يثبت المستقبل العرض القادم ينتقل إلى have-remote-offer، ثم يعود كل منهما إلى stable عندما يكتمل الجواب. وهذه الحالات هي أول ما تنظر إليه عندما لا تبدأ المكالمة، فإذا بقي المتصل في have-local-offer فالجواب لم يصل إليه، وهذا بالضبط ما سوف نصنعه عمداً في التجربة.
ICE والطريق بين المتصفحين
الخطوتان 6 و7 هما موضوع مقال كامل، فقد شرحنا في مكالمات WebRTC خلف NAT كيف يجمع ICE العناوين المرشحة بأنواعها ويختبرها بفحوص الاتصال، ودور STUN وTURN، والذي يهمك هنا كمطور سطر واحد: الخيار iceServers الذي تمرره عند إنشاء RTCPeerConnection هو المكان الذي تضع فيه عناوين STUN وTURN، وفي التجربة تركناه فارغاً لأن المتصفحين على نفس الجهاز، أما في الإنتاج فلا تقم بذلك، والسبب أن من يجلس خلف جدار حماية يمنع UDP لن تصله المكالمة أبداً دون TURN.
التشفير إلزامي: DTLS-SRTP
في أنظمة الصوت عبر الإنترنت التقليدية كان التشفير SRTP خياراً يفعله المسؤول أو ينساه، فيمر الصوت عبر RTP نصاً واضحاً، أما في WebRTC فالتشفير ليس إعداداً يمكن نسيانه، فقد نص RFC 8827 الذي يعرف معمارية الأمان في WebRTC على أن كل قناة وسائط يجب أن تحمى ب SRTP وSRTCP، وأن الوسائط لا يجوز أن ترسل عبر RTP غير مشفر، وأن المتصفح لا يقبل صيغ تشفير فارغة NULL، وأن مفاتيح SRTP تأتي من مصافحة DTLS، أي DTLS-SRTP كما في RFC 5764، وأن الطريقة القديمة التي تكتب المفاتيح داخل ال SDP نفسه، واسمها SDES، ممنوعة، وأن كل قناة بيانات يجب أن تحمى ب DTLS. والصورة التالية تبين هذه الطبقات، وماذا يرى كل من في الطريق:

والتوزيع هنا يشبه ما شرحناه في تبادل المفاتيح والتشفير الهجين: مصافحة DTLS، وهي TLS نفسه معدلاً ليعمل فوق UDP، تتفق على مفاتيح جلسة عبر تبادل مفاتيح بالمنحنيات البيضاوية، ثم يستخرج المتصفح منها مفاتيح SRTP كما يحدد RFC 5764، فيشفر بها كل حزمة صوت وصورة تشفيراً متماثلاً سريعاً كما في RFC 3711. ولاحظ أن SRTP يشفر محتوى الحزمة فقط، أما ترويسة RTP، وفيها رقم المصدر SSRC ورقم التسلسل والتوقيت، فتبقى مقروءة وموقعة لا يمكن تغييرها، لأن الشبكة والسيرفرات الوسيطة تحتاجها لترتيب الحزم، وهذا يعني أن من يراقب الشبكة يعرف أن هناك مكالمة، ومن أي عنوان إلى أي عنوان، ومتى يتكلم الشخص ومتى يصمت من أحجام الحزم، ولكنه لا يسمع الكلام ولا يرى الصورة.
بصمة الشهادة a=fingerprint: لماذا لا تحتاج شهادة من جهة إصدار؟
في HTTPS يتحقق المتصفح من شهادة الموقع عبر سلسلة ثقة تنتهي بجهة إصدار معروفة كما شرحنا في سلسلة الثقة، أما في WebRTC فكل متصفح يصنع لنفسه شهادة موقعة ذاتياً Self-Signed لكل اتصال، ولا يوجد أي جهة إصدار، فكيف يعرف الطرف الآخر أن الشهادة التي وصلته في مصافحة DTLS هي شهادة صاحبه وليست شهادة مخترق في الطريق؟ والإجابة أن كل طرف يكتب هاش شهادته في ال SDP في سطر a=fingerprint، فإذا وصلت الشهادة في المصافحة حسب المتصفح الهاش ورفض الاتصال إذا لم يطابق البصمة، وسوف نرى في التجربة البصمة نفسها في العرض وفي getStats().
وهذا يعني أن الثقة كلها تنتقل إلى قناة التنسيق، وقد ذكر القسم 9.1 من RFC 8827 ذلك صراحة: إذا لم يكن الاتصال بسيرفر التنسيق محمياً ب HTTPS فأي مخترق في الطريق يستطيع تبديل البصمات والوقوف بين الطرفين، وحتى مع HTTPS يستطيع سيرفر التنسيق نفسه أن يفعل ذلك إذا لم توجد طريقة مستقلة للتحقق من المفاتيح. لذلك فالقاعدة العملية: سيرفر التنسيق جزء من أمان المكالمة وليس مجرد ناقل، فاحمه كما تحمي سيرفر الدخول.
التشفير بين الطرفين End-to-End عندما يكون في الوسط سيرفر SFU
كل ما سبق صحيح في المكالمة المباشرة بين متصفحين، أما في المكالمة الجماعية فالشائع أن يكون في الوسط سيرفر SFU كما شرحنا في Mesh وSFU وMCU، وهنا تتغير الصورة، لأن كل متصفح يفتح اتصال WebRTC مع السيرفر وليس مع الأشخاص الآخرين، فتنتهي مصافحة DTLS عند السيرفر، ويفك السيرفر SRTP عند كل قفزة Hop ثم يعيد تشفيره للمستقبل، أي أن الصوت والصورة يكونان واضحين داخل ذاكرة السيرفر. وهذا ما يصفه RFC 9605 في مقدمته: نحتاج طبقتين من التشفير، تشفيراً بين القفزات Hop-by-Hop تراه السيرفرات لأنها تحتاج الترويسات ورسائل RTCP، وتشفيراً بين الطرفين End-to-End للمحتوى نفسه لا يستطيع السيرفر فكه، والصورة التالية تبين الفرق:

ولتطبيق ذلك في المتصفح يحتاج كودك أن يصل إلى الإطار المرمز قبل أن يغلفه المتصفح في SRTP، فيشفره بمفتاحه، ثم يفكه عند المستقبل بعد SRTP وقبل فك الترميز، وهذا ما تعطيه واجهة WebRTC Encoded Transform عبر الكائن RTCRtpScriptTransform الذي يمرر الإطارات إلى Worker، والوثيقة ما زالت مسودة عمل Working Draft في W3C، وفيها أيضاً تعريف لتحويل جاهز مبني على SFrame. وقبلها أضاف Chrome واجهة أقدم اسمها Insertable Streams عبر الدالة createEncodedStreams()، وفي تجربتنا على Chrome 154 كانت الواجهتان موجودتين كما سوف ترى في مخرج الصيغ. أما صيغة التشفير نفسها فقد وحدها RFC 9605 الصادر في أغسطس 2024 باسم SFrame.
والجزء الصعب هنا ليس التشفير وإنما المفاتيح، فمن يولد المفتاح K؟ وكيف يصل إلى كل مشارك دون أن يمر عبر السيرفر؟ وماذا يحدث عندما يغادر شخص الاجتماع؟ ولهذا تقول وثيقة التشفير في LiveKit بوضوح إن توليد المفاتيح وتخزينها وتوزيعها مسؤوليتك، وإن LiveKit لا يخزن المفاتيح ولا ينقلها ولا يستطيع ذلك. فإذا قال لك مزود خدمة إن مكالماته مشفرة، فاسأله: مشفرة إلى أين؟ إلى سيرفره، أم إلى الطرف الآخر؟
الصيغ Codecs الإلزامية
الصيغة Codec هي الخوارزمية التي تضغط الصوت أو الصورة قبل الإرسال وتفكها عند الاستقبال، ولكي يتصل أي متصفح بأي متصفح آخر دون تحويل بينهما حدد المعيار حداً أدنى يجب أن يدعمه الجميع: في الصوت يلزم RFC 7874 كل طرف WebRTC بصيغة Opus وبصيغتي G.711 المسماتين PCMA وPCMU، الأولى لأنها الأفضل للصوت عبر الإنترنت، والثانيتان لأنهما لغة الهواتف التقليدية فيسهل ربط المكالمة ببدالة هاتف، وفي الفيديو يلزم RFC 7742 كل متصفح بصيغتي VP8 وH.264 بالملف Constrained Baseline. وكل ما زاد على ذلك، مثل VP9 وAV1، اختياري يتفق عليه الطرفان في العرض والجواب، فالمتصل يكتب في العرض كل الصيغ التي يدعمها مرتبة بتفضيله، والمستقبل يختار منها. وإذا أردت أن تفهم ما تعنيه الصيغة ومعدل البت والإطار المفتاحي في الفيديو عموماً فقد شرحناها في مفاهيم الفيديو للمبتدئين، ومنه أيضاً أي ترميز يعمل في أي متصفح.
Simulcast وSVC باختصار
في المكالمة الجماعية مع SFU قد يكون أحد المشاركين على ألياف سريعة وآخر على شبكة جوال ضعيفة، ولا يستطيع السيرفر أن يغير جودة الصورة بنفسه لأنه لا يفك الترميز، فكيف يعطي كل واحد ما يناسبه؟ هناك طريقتان: الأولى Simulcast كما في RFC 8853، وفيها يرمز المرسل صورة الكاميرا نفسها عدة مرات بأحجام مختلفة ويرسلها كلها، فيختار السيرفر لكل مستقبل النسخة المناسبة، والثانية SVC، أي Scalable Video Coding، وفيها ترميز واحد مكون من طبقات، طبقة أساسية وطبقات تضيف التفاصيل أو عدد الإطارات، فيحذف السيرفر الطبقات العليا لمن لا يتحملها، وتضبطها في المتصفح عبر الخاصية scalabilityMode التي تعرفها وثيقة SVC في W3C، وهي ما زالت مسودة عمل:

وفي المكالمة المباشرة بين شخصين لا تحتاج أياً منهما، لأن المرسل يعرف حال المستقبل الوحيد ويضبط الجودة له مباشرة، ولذلك ظهر في تجربتنا scalabilityMode=L1T1، أي طبقة مكانية واحدة وطبقة زمنية واحدة، وهي القيمة التي تعني في الوثيقة أن SVC معطل.
قنوات البيانات Data Channels
قناة البيانات تبدو من JavaScript مثل WebSocket، ولكنها تحتها شيء مختلف تماماً، فقد عرفها RFC 8831 بأنها بروتوكول SCTP يعمل داخل DTLS كما في RFC 8261، وDTLS يعمل فوق ICE وUDP، أي على نفس الطريق ونفس التشفير الذي تمر عليه الوسائط. وقد اختير SCTP لأنه يعطيك في الاتصال الواحد عدة قنوات مستقلة، فلا تتأخر رسالة في قناة بسبب رسالة ضائعة في قناة أخرى، ولأنه يسمح لكل قناة أن تختار طريقة التسليم:
- مرتبة وموثوقة Ordered + Reliable: وهي الافتراضية، فكل رسالة تصل وبالترتيب، وتناسب المحادثة النصية ونقل الملفات.
- غير مرتبة أو غير موثوقة: تمرر للدالة
createDataChannel()الخيارordered: falseمعmaxRetransmitsأوmaxPacketLifeTime، فإذا ضاعت رسالة لا يعاد إرسالها، أو يعاد مرة محدودة، وتناسب موقع اللاعب في لعبة، لأن الموقع القديم بعد وصول موقع أحدث لا قيمة له.
وهذه بالضبط الأمثلة التي يذكرها المعيار في قسم حالات الاستخدام: لعبة في الزمن الحقيقي ترسل المواقع عبر قناة غير موثوقة وحالة اللعبة المهمة عبر قناة موثوقة، ونقل الملفات بين شخصين أثناء المحادثة، والمحادثة النصية أثناء مكالمة صوت أو صورة. ولفتح القناة هناك طريقتان، إما أن ينشئها طرف فيرسل المتصفح رسالة فتح للطرف الآخر عبر بروتوكول DCEP الذي يعرفه RFC 8832 فيصل إليه الحدث datachannel، وإما أن يتفق الطرفان مسبقاً ويستخدم كل منهما negotiated: true مع رقم id نفسه، وهذه الطريقة الثانية هي التي استخدمناها في التجربة لأنها أبسط عندما يكون الكود نفسه عند الطرفين.
جودة المكالمة: تقدير السرعة ومخزن التذبذب
الشبكة بين طرفي المكالمة لا تبقى على حال، فالسرعة المتاحة تنقص عندما يبدأ أحد في البيت مشاهدة فيديو، والحزم تتأخر بمقادير مختلفة، وبعضها يضيع، ولو أرسل المتصفح الصورة بمعدل ثابت لتجمدت المكالمة عند أول ازدحام. لذلك يعمل داخل RTCPeerConnection نظامان لا تراهما، والصورة التالية تبين كيف يعملان:

تقدير السرعة Bandwidth Estimation
يرسل المستقبل تقارير RTCP يخبر فيها المرسل بوقت وصول كل حزمة، فإذا بدأ التأخير يزيد بين حزمة وأخرى فهذا يعني أن طابوراً يمتلئ في Router ما في الطريق قبل أن تبدأ الحزم بالضياع، فيخفض المرسل معدل البت مبكراً، وإذا استقر التأخير رفعه تدريجياً. وأشهر خوارزمية لذلك هي Google Congestion Control الموصوفة في مسودة IETF لم تصبح RFC، وستجد في عرض Chrome في التجربة السطر a=rtcp-fb:96 transport-cc والامتداد transport-wide-cc، وهما الإعلان عن هذا النوع من التقارير. وتجد في العرض نفسه سطري nack وnack pli، والأول يطلب إعادة إرسال حزمة ضائعة، والثاني يطلب إطاراً مفتاحياً جديداً عندما يفسد ما يكفي من الصورة، وكلاهما من رسائل RFC 4585، أما إعادة الإرسال نفسها فتمر في تدفق منفصل اسمه rtx كما في RFC 4588. والنتيجة التي تراها أنت هي الرقم availableOutgoingBitrate في getStats()، أي تقدير المتصفح للسرعة المتاحة الآن.
مخزن التذبذب Jitter Buffer
التذبذب Jitter هو اختلاف التأخير بين الحزم، فلو خرجت حزم الصوت من المرسل كل 20 ملي ثانية بانتظام قد تصل واحدة بعد 30 وأخرى بعد 45 وثالثة بعد 31، ولو شغلها المستقبل فور وصولها لسمعت صوتاً متقطعاً، لذلك يحتفظ المستقبل بالحزم في مخزن صغير ينتظر فيه قليلاً، ثم يشغلها بانتظام، ويكبر هذا المخزن عندما يزيد التذبذب ويصغر عندما يهدأ، والثمن تأخير إضافي بسيط. وتعرف وثيقة الإحصاءات الرقمين jitter وjitterBufferDelay، ومن الثاني مقسوماً على jitterBufferEmittedCount تعرف متوسط ما تنتظره كل عينة في المخزن، وهو ما حسبناه في التجربة.
getStats وchrome://webrtc-internals
عندما يشتكي مستخدم من أن الصورة متقطعة، فلا تخمن، وإنما اقرأ الأرقام: الدالة pc.getStats() تعيد تقريراً فيه عشرات الكائنات، وأهمها outbound-rtp لما ترسله، وinbound-rtp لما تستقبله ومعه packetsLost وjitter، وcandidate-pair للطريق المختار ومعه currentRoundTripTime، وtransport لحالة DTLS، وcodec للصيغة المستخدمة فعلاً. وقد شرحنا في كيف تعرف أن المكالمة مرت عبر TURN كيف تصل من هذا التقرير إلى نوع العنوان المختار. وإذا كنت تشخص على جهازك فافتح في Chrome صفحة chrome://webrtc-internals في تبويب جديد أثناء المكالمة، فتجد كل اتصال مفتوح وأحداثه بالترتيب والجداول نفسها مع رسوم بيانية لكل رقم، والصورة التالية من التجربة:

تجربة عملية: مكالمة فيديو بين متصفحين وسيرفر تنسيق صغير
الآن سوف نبني كل ما سبق بأقل كود ممكن: صفحة HTML واحدة فيها مكالمة فيديو بين شخصين وقناة بيانات، وسيرفر تنسيق بلغة Node يعمل في Container على المنفذ 127.0.0.1:18480، ثم نفتح الصفحة في متصفحين ونقرأ ما يحدث. والسيرفر نفسه يقدم الصفحة أيضاً، حتى يكون المصدر واحداً.
سيرفر التنسيق: غرف لشخصين
نبدأ بمجلد التجربة، ونكتب فيه الملف server.mjs، وهو كل سيرفر التنسيق، ويستخدم مكتبة ws 8.22.0 لاتصالات WebSocket:
mkdir lab-webrtc && cd lab-webrtc// 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فقط، أما في الإنتاج فهذا هو السيرفر الذي قلنا في قسم البصمة إنه جزء من أمان المكالمة.
صفحة المكالمة
بعد ذلك نكتب الصفحة index.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 لسيرفر حقيقي. - الرسائل القادمة من السيرفر تعالج واحدة بعد الأخرى عبر سلسلة
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 في مجلدك:
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 [email protected] && node server.mjs"
docker logs lab-webrtc-signalوالمخرج سوف يكون كما يلي:
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، ومعه --use-fake-ui-for-media-stream الذي يوافق على الإذن تلقائياً كما في صفحة الاختبار في webrtc.org، و--allow-loopback-in-peer-connection الذي يسمح بالعنوان 127.0.0.1 في العناوين المرشحة، وكل نسخة بمجلد ملف شخصي مستقل:
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، دائرة تدور مع عداد للوقت والإطارات، صورته المحلية على اليسار وصورة المتصل على اليمين:

وسجل السيرفر في هذه اللحظة يبين كل ما مر عبره:
docker logs lab-webrtc-signalsignalling 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 عند المتصل:
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 والبصمة في كل قسم لأنها متطابقة:
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، والجواب هو الذي يحدد.
وفي الجواب عند المتصل نفس الأقسام، مع بصمة مختلفة واختيار واضح للدور:
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')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، وعند المتصل كانت كما يلي:
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.
البصمة والتشفير في الإحصاءات
الآن نطلب الإحصاءات من الصفحة عند المستقبل:
await lab.stats()والمخرج سوف يكون كما يلي:
{
"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 للإصدار DTLS 1.3، ومعهTLS_AES_128_GCM_SHA256من صيغ TLS 1.3 التي شرحنا عائلتها في التشفير الموثق 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 ترسل كل صفحة رسالة إلى الأخرى، فنقرأ ما وصل:
lab.messagesوالمخرج عند المستقبل سوف يكون كما يلي، وعند المتصل وصلت الرسالة hello from the callee:
[ "hello from the caller" ]ولاحظ في سجل السيرفر أعلاه أنه لا يوجد أي سطر لهاتين الرسالتين، فقد ذهبتا مباشرة من متصفح إلى متصفح داخل DTLS على نفس المنفذ الذي يمر عليه الصوت والصورة، وهذا هو الفرق بين قناة البيانات واتصال WebSocket عبر سيرفرك.
ما الذي يدعمه هذا المتصفح؟
قبل أن تختار صيغة في تطبيقك اسأل المتصفح نفسه، فالدالة RTCRtpSender.getCapabilities() تعيد كل ما يستطيع إرساله:
({
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 سوف يكون كما يلي:
{
"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 موجودتان في هذا الإصدار.
نوقف سيرفر التنسيق أثناء المكالمة
الآن نكسر التنسيق عمداً، فنوقف ال Container والمكالمة ما زالت تعمل:
docker stop -t 1 lab-webrtc-signalوقرأنا في صفحة المستقبل حالة الاتصال ومعدل الإرسال من lab.stats() قبل الإيقاف، ثم بعده بست ثوان مع آخر حدث في السجل، فكانت القيم كما يلي:
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 بعد أن يتغير عنوان الجهاز، لذلك راقبه كما تراقب أي خدمة أساسية. ثم نعيد تشغيله:
docker start lab-webrtc-signalالجواب لا يصل: المكالمة عالقة في المنتصف
الحالة الأصعب في التشخيص هي أن يعمل التنسيق جزئياً، فنفتح الصفحة الأولى بالخيار drop=answer، وهي التي تصبح المستقبل، ثم الثانية عادية:
http://127.0.0.1:18480/?room=drop&drop=answer&loopback
http://127.0.0.1:18480/?room=drop&loopbackوبعد 35 ثانية نقرأ هذا الكائن في كل صفحة، ووضعنا القيمتين معاً تحت الاسمين first للصفحة الأولى وsecond للثانية:
({ role: lab.role, signalingState: pc.signalingState, iceConnectionState: pc.iceConnectionState, connectionState: pc.connectionState, events: lab.events }){
"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"]
}
}
في المخرج أعلاه لاحظ التالي:
- المتصل بقي في
have-local-offer، وهذه هي العلامة الأولى أن الجواب لم يصل، ولم يتغير شيء بعد 35 ثانية لأن WebRTC لا يعيد إرسال رسائل التنسيق، فهذه مسؤولية كودك. - وصلت إلى المتصل عناوين المستقبل، فحاول الكود إضافتها، فرمى المتصفح الخطأ
The remote description was null، أي لا يمكن إضافة عنوان قبل أن يعرف المتصفح وصف الطرف الآخر، وهذا الخطأ نفسه هو ما تراه في تطبيق حقيقي عندما يصل العنوان قبل الجواب بسبب ترتيب الرسائل، وهو سبب السلسلةqueueفي الصفحة. - عند المستقبل حدث شيء مثير:
iceConnectionStateوصل إلىconnected، لأن لديه العرض وعناوين المتصل، فنجحت فحوص ICE، ولكنconnectionStateبقيconnecting، والسبب أن مصافحة DTLS لا تكتمل، فالمتصل لا يعرف بصمة شهادة المستقبل لأنها في الجواب الذي لم يصله، فلا يقبل الشهادة. فالحالتان معاً تخبرانك أين انقطع الطريق: طريق الشبكة يعمل، والتنسيق ناقص.
إزالة التجربة
أغلق نوافذ المتصفح، ثم احذف ال Container بالاسم، وإذا أردت احذف ال Docker Image أيضاً:
docker rm -f lab-webrtc-signal
docker image rm node:24.21.0-alpine3.24
cd .. && rm -r lab-webrtcWHIP وWHEP: WebRTC في البث المباشر
رأينا أن WebRTC لا يحدد التنسيق، وهذا مناسب لتطبيق مكالمات تكتبه بنفسك، ولكنه مشكلة في البث، فبرنامج مثل OBS يريد أن يرسل البث إلى أي منصة دون أن يعرف كود JavaScript الخاص بكل منصة. والحل كان بروتوكولاً بسيطاً فوق HTTP اسمه WHIP، أي WebRTC-HTTP Ingestion Protocol، وقد صدر في RFC 9725 في مارس 2025: يرسل البرنامج العرض SDP في طلب HTTP POST إلى رابط المنصة، فترد ب 201 Created وفيه الجواب، ثم يبدأ WebRTC كالمعتاد، وعندما ينتهي البث يرسل طلب HTTP DELETE. وقد أضاف OBS Studio مخرج WHIP منذ الإصدار 30.0. وفي الاتجاه المعاكس، أي المشاهد الذي يستقبل البث، هناك WHEP بنفس الفكرة، وهو ما زال مسودة IETF لم تصبح RFC. والفائدة الكبرى من WebRTC في البث هي التأخير، وقد قسناه في شرح بث الفيديو مقابل HLS، وهناك أيضاً تجد مكان WHIP بين بروتوكولات الإدخال Ingest مثل RTMP وSRT، فلن نعيد المقارنة هنا.
متى تبني بنفسك ومتى تستخدم SFU جاهزاً مثل LiveKit؟
التجربة أعلاه مكالمة كاملة في أقل من 150 سطراً، وقد يغريك ذلك بأن تبني تطبيق اجتماعات بنفس الطريقة، وهنا يجب أن تفرق بين حالتين:
- مكالمة بين شخصين، أو ثلاثة على الأكثر: الطريقة المباشرة Peer-to-Peer كما في التجربة تكفي، ويكفيك سيرفر تنسيق صغير مثل الذي كتبناه بعد أن تضيف إليه التحقق من الهوية وTLS، وسيرفر TURN مثل coturn لمن يقف خلف جدار حماية. والسبب أن كل ما هو صعب، من التشفير وتقدير السرعة والصيغ، يقوم به المتصفح.
- اجتماعات جماعية، أو تسجيل، أو بث لعدد كبير: هنا تحتاج سيرفر SFU، لأن الطريقة المباشرة بين الجميع تملأ اتصال كل مشارك كما شرحنا في Mesh وSFU وMCU، ومعه تحتاج Simulcast واختيار الطبقات لكل مستقبل، وإعادة الاتصال عندما يتغير عنوان الهاتف، والتسجيل، والتشفير بين الطرفين بمفاتيح توزعها أنت. وكتابة كل هذا من الصفر عمل فريق لسنوات، فالحل الأنسب أن تشغل SFU مفتوح المصدر على سيرفرك، مثل LiveKit المكتوب بلغة Go والمرخص بترخيص Apache 2.0، والذي يعطيك مع السيرفر مكتبات للمتصفح والجوال تتولى التنسيق عنك. وسوف نشرح تشغيله على سيرفرك في مقال LiveKit المخطط في مرحلة الفيديو والبث المباشر.
وحتى مع SFU جاهز فإن ما شرحناه هنا لا يذهب سدى، لأنك عندما يشتكي مستخدم من أن صوته لا يصل سوف تفتح webrtc-internals وتقرأ signalingState وconnectionState والزوج المختار وأرقام الضياع، وهي نفس الأرقام سواءً كان في الطرف الآخر متصفح أو LiveKit.
أخطاء شائعة
- الصفحة تعمل على جهازك ولا تعمل من جهاز آخر: فتحتها عبر
http://بعنوان غيرlocalhost، فلا يوجدnavigator.mediaDevicesأصلاً، والحل HTTPS. - الخطأ
The remote description was null: عنوان مرشح وصل قبل العرض أو الجواب، فعالج الرسائل بالترتيب، أو احتفظ بالعناوين حتى يثبت الوصف. - الطرفان يرسلان عرضاً في الوقت نفسه: هذه الحالة اسمها Glare، فاجعل السيرفر يحدد من يبدأ كما فعلنا برسالة
ready، أو اتبع نمط Perfect Negotiation الذي تذكره وثيقة WebRTC في أمثلتها. - المكالمة تعمل في المكتب ولا تعمل من شبكة الجوال أو العكس: لا يوجد TURN في
iceServers، والتفصيل في مشكلات ICE الشائعة. - سيرفر التنسيق دون تحقق من الهوية: أي شخص يعرف اسم الغرفة يدخل المكالمة، ومن يتحكم في السيرفر يستطيع تبديل البصمات، فعامله كواجهة 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.
دليلمكالمات WebRTC خلف NAT: شرح STUN وTURN وICEلماذا تتصل المكالمة ولا يصل الصوت، وكيف تحل STUN وTURN المشكلة دون أن تفتح باباً إلى شبكتك الداخلية.
دليلمفاهيم الفيديو: الحاوية والترميز Codec ومعدل البتما الذي يحدد حجم ملف الفيديو وجودته، ولماذا يعمل ملف في متصفح ولا يعمل في آخر، مع تجربة حقيقية تقيس الفرق بين الترميزات.
دليلكيف يعمل بث الفيديو المباشر وعند الطلب؟كيف يصل الفيديو إلى كل مشاهد بجودة تناسب سرعة اتصاله، ولماذا يتأخر البث المباشر عشرين ثانية أحياناً وأقل من ثانية أحياناً أخرى.
دليلما هو NAT؟ شرح ترجمة العناوين وتوجيه المنافذلماذا تخرج كل أجهزة بيتك بعنوان عام واحد، وكيف يترجم ال Router العناوين، ولماذا يفشل توجيه المنافذ خلف CGNAT.
دليلالشهادات الرقمية TLS للمبتدئين وصيغ ملفاتهاكيف يعرف المتصفح أنه يتحدث مع الموقع الحقيقي، وماذا يوجد داخل الشهادة، ولماذا تأتي ملفاتها بأكثر من صيغة.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وبناء التجربة العملية بمكالمة فيديو بين نسختين من Chrome 154 مع سيرفر تنسيق على Node.js 24.21.0 ومكتبة ws 8.22.0.