الاستضافة الذاتية والتطبيقات الأدلة التقنية

Excalidraw على سيرفرك مع الرسم المشترك

كيف تبني Excalidraw وخادم الغرف على سيرفرك لترسم مع فريقك في نفس اللوحة دون أن يمر رسمك بخدمة خارجية.

Excalidraw على سيرفرك مع الرسم المشترك

في مقارنة أدوات رسم المخططات وجدنا أن Excalidraw أنسب أداة لرسم النقاش مع الفريق، ووجدنا أيضاً ملاحظة مهمة: الـ Docker Image الرسمية تعمل على سيرفرك، ولكن الرسم المشترك فيها يمر بخادم الشركة، وحفظ الغرف يذهب إلى Firebase، والخطوط تأتي من CDN خارجي، وفيها سكربت تحليلات. وإذا كنت ترسم على هذه اللوحة شبكة شركتك الداخلية أو تصميم نظام لم يعلن بعد، فأنت تريد أن يبقى كل ذلك على سيرفرك.

المواضيع في السلسلة:

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

  • نفحص الـ Image الرسمية ونرى بالأوامر إلى أين تتصل.
  • نبني Excalidraw من الكود بعنوان خادم الغرف الخاص بنا وبدون Firebase.
  • نزيل الاتصال بالـ CDN وسكربت التحليلات، ثم نشغل الواجهة وخادم الغرف excalidraw-room خلف Caddy على نطاق واحد.
  • نرسم من متصفحين في نفس الغرفة، ونكتب نصاً عربياً، ونصدر الرسم إلى PNG وSVG، ونرى في سجلات الخادم أنه لا يقرأ ما نرسم.
  • نجرب ماذا يحدث عندما يخرج الجميع من الغرفة، وماذا يحدث إذا توقف خادم الغرف.
  • نشغل draw.io بجانبه، ونحول مخطط Mermaid إلى صورة بأداة mermaid-cli.

والصورة التالية تبين ما سوف نبنيه:

متصفحان A وB يتصلان بـ Caddy على draw.example.com، والمسار / يذهب إلى Container excalidraw فيه nginx وملفات الواجهة، والمسار /socket.io/ يذهب إلى excalidraw-room خادم الرسم المشترك الذي يعيد بث الرسائل المشفرة ولا يحفظ شيئاً، وفي الأسفل الخدمات التي تتصل بها الـ Image الرسمية وعليها علامة إلغاء: oss-collab.excalidraw.com وFirebase وfonts CDN وsimpleanalyticscdn.com
ثلاث Containers خلف نطاق واحد، والخدمات الخارجية في الأسفل هي ما نزيله في البناء الخاص بنا

المتطلبات

  • سيرفر Linux عليه Docker وDocker Compose، وقد اختبرنا على Docker Engine 29.8.0.
  • البناء يتم داخل Docker، فيثبت Node.js والحزم ويبني الواجهة، ويأخذ عدة دقائق ويحتاج ذاكرة أكبر من التشغيل، فإذا كان السيرفر صغيراً فابن الـ Image على جهازك ثم انقلها إليه. أما بعد التشغيل فالـ Containers صغيرة: الواجهة استهلكت حوالي 8 ميجابايت وخادم الغرف حوالي 39 ميجابايت من الذاكرة في تجربتنا.
  • Git لنسخ الكود، واتصال بالإنترنت وقت البناء لتنزيل الحزم.
  • نطاق فرعي Subdomain مثل draw.example.com يشير إلى السيرفر، والمنفذان 80 و443 مفتوحان، إذا أردت الوصول من خارج جهازك.

لماذا لا تكفي الـ Image الرسمية؟

الحل البسيط الذي يخطر على البال هو تشغيل excalidraw/excalidraw مباشرة، وهي تعمل فعلاً وتفتح اللوحة، ولكن لنر ما الذي بداخلها. أولاً لاحظ أنها لا تحمل إلا الوسم latest، فلا يوجد رقم إصدار تثبته، لذلك نثبت الـ Digest، ثم نبحث في ملفات الواجهة عن العناوين الخارجية:

docker image inspect excalidraw/excalidraw:latest --format '{{index .RepoDigests 0}} {{.Created}}'
docker run --rm --entrypoint sh excalidraw/excalidraw:latest -c \
  "cd /usr/share/nginx/html && grep -hoE 'https://(oss-collab\.excalidraw\.com|[a-z-]+\.firebaseio\.com|scripts\.simpleanalyticscdn\.com|excalidraw\.nyc3\.cdn\.digitaloceanspaces\.com)' index.html assets/*.js | sort | uniq -c"

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

excalidraw/excalidraw@sha256:f7ee194addd607bf831d2af0f0a34463dd4225e426cf35199ef0b12a803398e9 2026-05-06T21:19:58.716188897Z
      3 https://excalidraw-room-persistence.firebaseio.com
      5 https://excalidraw.nyc3.cdn.digitaloceanspaces.com
      2 https://oss-collab.excalidraw.com
      1 https://scripts.simpleanalyticscdn.com

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

  • oss-collab.excalidraw.com هو خادم الغرف الذي تتصل به الواجهة عند بدء الرسم المشترك، فحتى لو كانت الواجهة على سيرفرك فالجلسات المشتركة تمر بخادم الشركة، صحيح أنها مشفرة كما سيأتي، ولكن عنوان الغرفة ووقت الجلسة يصلان إليه.
  • firebaseio.com هو مكان حفظ الغرف والملفات المرفقة في نسخة excalidraw.com.
  • digitaloceanspaces.com هو الـ CDN الذي تحمل منه الخطوط، وsimpleanalyticscdn.com سكربت التحليلات.

والسبب أن هذه العناوين لا تتغير بمتغيرات بيئة Environment Variables عند تشغيل الـ Container، لأن Excalidraw تطبيق Vite، والقيم في ملف .env.production تكتب داخل ملفات JavaScript وقت البناء، والـ Dockerfile الرسمي يبني التطبيق ثم ينسخ الملفات الناتجة إلى nginx فقط. لذلك الحل الأنسب هو أن نبني الـ Image بأنفسنا بالقيم التي نريدها.

بناء Excalidraw من الكود

الآن سوف نقوم بنسخ الكود من فرع release، وهو الفرع الذي يبنى منه النشر الرسمي، ونثبت رقم الـ Commit الذي اختبرناه لكي يكون البناء قابلاً للتكرار:

mkdir -p /opt/draw && cd /opt/draw
git clone --branch release https://github.com/excalidraw/excalidraw.git
cd excalidraw
git checkout 17d8904f734464847bcef638b636647b85ac1a1d

بعد ذلك نعدل سطرين في .env.production: عنوان خادم الغرف ليصبح نطاقنا نفسه، وإعدادات Firebase لتصبح فارغة:

sed -i \
  -e 's#^VITE_APP_WS_SERVER_URL=.*#VITE_APP_WS_SERVER_URL=https://draw.example.com#' \
  -e "s#^VITE_APP_FIREBASE_CONFIG=.*#VITE_APP_FIREBASE_CONFIG='{}'#" \
  .env.production
grep -n "WS_SERVER\|FIREBASE" .env.production
15:VITE_APP_WS_SERVER_URL=https://draw.example.com
17:VITE_APP_FIREBASE_CONFIG='{}'

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

  • العنوان هو العنوان الذي يكتبه المستخدم في متصفحه، وليس اسم الـ Container، لأن الاتصال بخادم الغرف يتم من المتصفح وليس من السيرفر. وقد اختبرنا البناء بالعنوان http://localhost:18704 على جهاز التجربة، فضع أنت نطاقك.
  • جعلنا خادم الغرف على نفس النطاق، ومكتبة Socket.IO تتصل افتراضياً بالمسار /socket.io/، فيكفي أن يوجه الـ Reverse Proxy هذا المسار إلى excalidraw-room، ولا نحتاج نطاقاً فرعياً ثانياً ولا شهادة ثانية.
  • القيمة '{}' تعني أنه لا يوجد Firebase، وسوف نرى أثر ذلك في قسم ماذا يحدث عندما يخرج الجميع.

الآن نبني الـ Image:

docker build -t excalidraw-local:17d8904 .

وعند تشغيل الأمر سوف تجد أنه يأخذ عدة دقائق، فقد استغرق البناء الأول في تجربتنا 4 دقائق و39 ثانية، منها 160 ثانية لتثبيت الحزم بـ yarn و47 ثانية لبناء الواجهة بـ Vite، وآخر المخرج يبين نجاح البناء:

#13 49.57 ✓ built in 46.83s
#13 DONE 50.0s

والـ Image الناتجة حجمها 92 ميجابايت، لأنها nginx وملفات ثابتة فقط، أما Node.js والحزم فتبقى في مرحلة البناء Build Stage ولا تدخل الـ Image النهائية.

إزالة الـ CDN وسكربت التحليلات Analytics

بقي عنوانان لا يأتيان من .env.production، وإنما من ملف index.html نفسه: سكربت التحليلات الذي يضاف في كل بناء Production، ومسار الخطوط EXCALIDRAW_ASSET_PATH الذي يضع الـ CDN أولاً ثم السيرفر نفسه. ولكي لا نعدل الكود نفسه، نضيف طبقة صغيرة فوق الـ Image التي بنيناها، في ملف /opt/draw/Dockerfile.local:

FROM excalidraw-local:17d8904
RUN sed -i -e 's#"https://excalidraw.nyc3.cdn.digitaloceanspaces.com/oss/",##' \
           -e 's#https://scripts.simpleanalyticscdn.com/latest.js#/no-analytics.js#' \
           /usr/share/nginx/html/index.html

والسطر الأول يحذف الـ CDN من قائمة مسارات الخطوط فتحمل من السيرفر نفسه، والثاني يغير عنوان سكربت التحليلات إلى ملف غير موجود على سيرفرك، فيفشل تحميله بصمت ولا يخرج أي طلب. وبعد البناء فحصنا الـ Image الجديدة بنفس أمر grep فكان المخرج:

      4 https://excalidraw.nyc3.cdn.digitaloceanspaces.com

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

⚠️
أمر sed في Dockerfile.local يعتمد على نص محدد في index.html، فإذا تغير هذا النص في إصدار قادم فلن يفشل البناء، وإنما سوف يمر الأمر دون أن يغير شيئاً. لذلك بعد كل تحديث أعد تشغيل أمر grep أعلاه على الـ Image الجديدة، ولا تعتمد على أن البناء نجح.

التشغيل خلف Caddy

نحتاج ثلاث خدمات: الواجهة التي بنيناها، وخادم الغرف excalidraw-room، وCaddy كـ Reverse Proxy يوجه المسار /socket.io/ إلى خادم الغرف وكل ما عداه إلى الواجهة. وخادم الغرف ليس له إلا وسوم بأرقام Commit، وآخرها sha-03ff435 المبنية في 15 ديسمبر 2023، فنثبت الـ Digest الخاص بها. أنشئ الملف /opt/draw/compose.yaml:

services:
  excalidraw:
    build:
      context: .
      dockerfile: Dockerfile.local
    image: excalidraw-local:17d8904-local
    restart: unless-stopped
  room:
    image: excalidraw/excalidraw-room@sha256:2fe999f9be4379e3ee282fc45d75d84a691a6383dde33544514cc395287c7a70
    restart: unless-stopped
  proxy:
    image: caddy:2.11.7-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
    depends_on: [excalidraw, room]
volumes:
  caddy-data:

ثم ملف /opt/draw/Caddyfile، ويستخدم التوجيه reverse_proxy بحسب المسار:

draw.example.com {
	handle /socket.io/* {
		reverse_proxy room:80
	}
	handle {
		reverse_proxy excalidraw:80
	}
}

ثم نشغل الخدمات:

cd /opt/draw
docker compose up -d --build

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

  • Caddy يصدر شهادة TLS للنطاق تلقائياً عندما يكون النطاق يشير إلى السيرفر والمنفذان 80 و443 مفتوحين، وقد شرحنا ذلك بالتفصيل في دليل Caddy. وفي تجربتنا على جهاز محلي استخدمنا :80 بدل اسم النطاق، ونشرنا المنفذ على 127.0.0.1 فقط.
  • الواجهة وخادم الغرف لا ينشران أي منفذ على السيرفر، فلا يصل إليهما أحد إلا عبر Caddy.
  • Caddy يمرر اتصال WebSocket دون إعداد إضافي، وهذا ما يحتاجه Socket.IO.
  • الـ Volume الوحيد هو بيانات Caddy التي فيها الشهادات، أما Excalidraw وخادم الغرف فلا يحفظان شيئاً على القرص.

وللتحقق من أن المسارين يعملان، نطلب الصفحة الرئيسية، ثم نطلب Socket.IO بطريقة Polling:

curl -s -o /dev/null -w "%{http_code}\n" https://draw.example.com/
curl -s "https://draw.example.com/socket.io/?EIO=4&transport=polling"

والمخرج في تجربتنا على localhost:

200
0{"sid":"gKJq4dV1MAUurHmgAAAA","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000}

والسطر الثاني رد من خادم الغرف نفسه، يعطي رقم جلسة ويعرض الترقية إلى WebSocket، فإذا ظهر بدلاً منه صفحة HTML فالمسار /socket.io/ يذهب إلى الواجهة وليس إلى خادم الغرف.

الرسم المشترك Real-time Collaboration من متصفحين

افتح https://draw.example.com، ثم اضغط زر المشاركة الأخضر في الأعلى، ثم Start session، فيعطيك Excalidraw رابطاً بالشكل التالي:

https://draw.example.com/#room=26dcc716436cbd8d8277,<KEY>

والجزء بعد #room= فيه قيمتان: رقم الغرفة Room ID، ومفتاح التشفير Key. وأي شيء بعد علامة # في الرابط لا يرسله المتصفح إلى السيرفر أصلاً، لذلك يبقى المفتاح في المتصفح، ويرسل المتصفح لخادم الغرف رقم الغرفة فقط ومعه بيانات مشفرة بخوارزمية AES-GCM بمفتاح 128 بت، كما يشرح مقال الفريق عن التشفير، وكما وجدنا في كود المشروع.

رابط الغرفة https://draw.example.com/#room= ثم رقم الغرفة باللون البنفسجي ثم المفتاح باللون الأخضر، والمتصفح A يشفر كل تغيير بـ AES-GCM 128 ويرسله مع رقم الغرفة إلى excalidraw-room الذي يرى رقم الغرفة وبيانات مشفرة فقط، ويعيد بثها إلى المتصفح B الذي يفك التشفير بنفس المفتاح من رابطه
رقم الغرفة يصل إلى الخادم، والمفتاح لا يغادر المتصفح

وعندما ضغطنا Start session ظهرت لنا رسالة خطأ فوق نافذة المشاركة:

نافذة Live collaboration فيها الاسم Harmonious Cod ورابط http://localhost:18704/#room= وزر Copy link، وفوقها رسالة Error تقول Couldn't save to the backend database. If problems persist, you should save your file locally to ensure you don't lose your work، وفي الأعلى علامة تحذير حمراء
رسالة «Couldn't save to the backend database» متوقعة في بنائنا، لأنه لا يوجد Firebase

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

لقطتان متجاورتان لنفس اللوحة في متصفحين، في كل منهما مستطيل وسهم وشكل بيضاوي ونصان سيرفر الإنتاج Production Server وقاعدة البيانات Database، وفي كل لقطة مؤشر المستخدم الآخر باسمه: Superb Dolphin في اليسار وGenerous Sandpiper في اليمين
المتصفح B على اليسار والمتصفح A على اليمين، والعناصر الخمسة ظهرت في الاثنين، وكل متصفح يرى مؤشر الآخر واسمه

ولكي نتأكد من أن خادم الغرف لا يرى الرسم، شغلناه مرة بمتغير البيئة DEBUG='*' ليطبع كل ما يصله، وهذا جزء من السجل أثناء الرسم:

socket.io:socket got packet {"type":2,"nsp":"/","data":["server-volatile-broadcast","a33384c03fca8fc3861e",{"type":"Buffer","data":[93,177,128,180,246,191,86,71,6,240,114,156,1,174,66,22,234,28,195,61,...]},{"type":"Buffer","data":[161,111,233,172,237,80,72,56,245,21,63,22]}]}
socket A5KueQzGHCYx_7J5AAAD sends volatile update to a33384c03fca8fc3861e

في السجل أعلاه لاحظ التالي:

  • القيمة a33384c03fca8fc3861e هي رقم الغرفة، وهي الشيء الوحيد المقروء في الرسالة.
  • الـ Buffer الأول هو البيانات المشفرة، والثاني طوله 12 بايت، وهو الـ IV العشوائي الذي يولده المتصفح لكل رسالة.
  • لا يظهر في السجل أي نص كتبناه ولا أي إحداثيات، لذلك فمن يصل إلى سجلات السيرفر لا يعرف ماذا رسمنا، ولكنه يعرف متى كانت الغرفة نشطة وكم شخصاً فيها.
🛑
من يملك الرابط كاملاً بما فيه المفتاح يدخل الغرفة ويرى كل شيء، ولا توجد في Excalidraw أي صلاحيات أو تسجيل دخول، لذلك عامل رابط الغرفة معاملة كلمة المرور، ولا تضعه في قناة عامة. وإذا أردت أن تقصر الوصول إلى الأداة على موظفيك فضع أمامها تسجيل دخول في الـ Reverse Proxy أو اجعلها متاحة عبر الـ VPN فقط.

النص العربي واتجاهه RTL

كتبنا في المتصفح الأول «سيرفر الإنتاج Production Server»، وفي الثاني «قاعدة البيانات Database»، وكما ترى في الصورة السابقة اكتشفت الأداة أن النص يبدأ بالعربية فجعلت اتجاهه من اليمين إلى اليسار، فظهرت الكلمة العربية أولاً للقارئ، وظهرت الحروف متصلة بشكل صحيح. والملاحظة الوحيدة أن العربية تظهر بخط النظام وليس بالخط الذي يشبه اليد Excalifont، لأن هذا الخط لا يحتوي الحروف العربية، فيبدو النص العربي أكثر رسمية من بقية الرسم، وهذا لا يؤثر على القراءة.

التصدير Export إلى PNG وSVG

من القائمة، أو بالاختصار Ctrl+Shift+E، تفتح نافذة تصدير الصورة Export image:

نافذة Export image فيها معاينة للنص قاعدة البيانات Database، وخيارات Only selected وBackground وDark mode وEmbed scene وScale بقيم 1x و2x و3x، وأزرار PNG وSVG وCopy to clipboard
نافذة التصدير، والخيار Embed scene يضع بيانات الرسم داخل الصورة لتعود وتعدلها

في النافذة أعلاه لاحظ التالي:

  • Only selected يكون مفعلاً إذا كان هناك عنصر محدد، فإذا صدرت والنص محدد فسوف تحصل على صورة فيها النص وحده، وهذا ما حدث معنا في المحاولة الأولى، لذلك اضغط على مكان فارغ قبل فتح النافذة.
  • Embed scene يضع بيانات الرسم داخل ملف PNG أو SVG، فإذا فتحت الملف لاحقاً في Excalidraw عاد قابلاً للتعديل، وهو الخيار الذي ننصح به للصور التي تضعها في المستودع.
  • Scale يحدد دقة الصورة، و2x مناسبة لصفحات التوثيق.

وبعد إلغاء التحديد صدرنا اللوحة كاملة، فكان ملف PNG بحجم 27 كيلوبايت وأبعاد 699 في 243، وملف SVG بحجم 8 كيلوبايت، وهذه الصورة كما خرجت:

الصورة المصدرة: مستطيل وسهم وشكل بيضاوي بخط يشبه اليد، وتحتها النصان سيرفر الإنتاج Production Server وقاعدة البيانات Database
ملف PNG كما صدرته Excalidraw من نسختنا

وفي ملف SVG وجدنا أن النص العربي محفوظ كنص وليس كأشكال، ومعه اتجاهه:

<text x="0" y="17.619999999999997" font-family="Excalifont, Xiaolai, sans-serif, Segoe UI Emoji" font-size="20px" fill="#1e1e1e" text-anchor="end" style="white-space: pre;" direction="rtl" dominant-baseline="alphabetic">سيرفر الإنتاج Production Server</text>

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

ماذا يحدث عندما يخرج الجميع؟

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

لوحة Excalidraw فارغة فيها شاشة الترحيب وعبارة Your drawings are saved in your browser's storage وخيارات Open وHelp وLive collaboration، وعلى زر المشاركة الرقم 1
نفس رابط الغرفة بعد خروج الجميع: اللوحة فارغة، ولا يوجد في الغرفة إلا المتصفح الجديد

وهذا هو أثر إزالة Firebase، ففي excalidraw.com تحفظ الغرفة مشفرة في Firebase فتعود عندما يفتح أحدهم الرابط لاحقاً، أما في بنائنا فالغرفة موجودة ما دام فيها شخص واحد على الأقل. والحل البسيط أن يحفظ أحد المشاركين الرسم في نهاية الجلسة من القائمة Save to، أو بالاختصار Ctrl+S، كملف .excalidraw ويضعه في المستودع بجانب الكود، أو يصدره كـ PNG مع Embed scene. وإذا كنت تحتاج حفظاً دائماً للغرف ومساحة مشتركة للفريق فهذا ما تبيعه Excalidraw+، أو تستطيع أن تربط بناءك بمشروع Firebase تملكه أنت، وهذا خارج نطاق هذا الدليل.

⚠️
الصور التي تسحبها إلى اللوحة أثناء الجلسة المشتركة ترفع في النسخة الرسمية إلى Firebase Storage لكي يراها الآخرون، وفي بنائنا لا يوجد مكان ترفع إليه، لذلك استخدم في الجلسات المشتركة الأشكال والنصوص، وأضف الصور بعد الجلسة في ملفك المحفوظ.

draw.io بجانبه

إذا كان فريقك يحتاج أيضاً مخطط شبكة مفصل بأيقونات السحابة والأجهزة، فـ draw.io مناسب أكثر، وتشغيله أبسط من Excalidraw لأنه لا يحتاج بناء:

docker run -d --name drawio --restart unless-stopped -p 127.0.0.1:8081:8080 jgraph/drawio:32.5.5
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8081/
200

وظهرت الواجهة بالعربية عندما فتحنا http://localhost:8081/?lang=ar، ورسمنا فيها نفس المخطط مع ضبط اتجاه النص textDirection=rtl لكل شكل:

واجهة draw.io بقوائم عربية: ملف وتعديل وعرض ورتب وإضافات ومساعدة، وعلى اللوحة المستخدم User وسهم طلب HTTPS إلى API Server الخادم، ومنه SQL إلى Database قاعدة البيانات ورسالة إلى Queue الطابور
draw.io 32.5.5 على سيرفرنا بالواجهة العربية

ولاحظ أمرين قبل أن تعتمد عليه: الأول أن هذه الـ Image تعطل المزامنة في الوقت نفسه، ووجدنا ذلك في ملف الإعداد داخل الـ Container:

docker exec drawio grep "sync" /usr/local/tomcat/webapps/draw/js/PreConfig.js
urlParams['sync'] = 'manual'; //Disable Real-Time

والثاني أن ملف README في المستودع يقول إن هذه النسخة للتجربة والتقييم، وإن هناك نسخة إنتاج مدفوعة، لذلك استخدم draw.io المستضاف للرسم الفردي، واحفظ الملفات بصيغة .drawio.svg في المستودع، واجعل Excalidraw للجلسات المشتركة. ولكي تضعه خلف نفس الـ Reverse Proxy أضف له نطاقاً فرعياً مثل diagrams.example.com بنفس طريقة Caddyfile أعلاه.

مخطط Mermaid من سطر الأوامر CLI

وأخيراً، المخططات التي تعيش داخل المستودع ككود لا تحتاج سيرفراً أصلاً، فـ GitHub وGitLab يعرضان Mermaid مباشرة، ولكن إذا أردت صورة لتضعها في عرض تقديمي أو في Wiki لا يعرض Mermaid، فأداة mermaid-cli تحولها. وهذا ملف flow-rtl.mmd بتسميات عربية مع dir='rtl' الذي وجدناه ضرورياً في اختبار العربية:

flowchart LR
    user["<span dir='rtl'>المستخدم User</span>"] -->|<span dir='rtl'>طلب HTTPS</span>| api["<span dir='rtl'>الخادم API Server</span>"]
    api -->|SQL| db[("<span dir='rtl'>قاعدة البيانات Database</span>")]
docker run --rm -v "$PWD":/data minlag/mermaid-cli:12.0.1 -i flow-rtl.mmd -o flow-rtl.png -s 2 -b white
Generating single mermaid chart
مخطط تدفق من اليسار إلى اليمين: User المستخدم ثم سهم HTTPS طلب إلى API Server الخادم ثم سهم SQL إلى أسطوانة قاعدة البيانات Database
المخرج من mermaid-cli، والكلمة العربية في كل تسمية هي الأولى من اليمين

ولاحظ أن الـ Image حجمها 3.53 جيجابايت لأنها تحمل متصفح Chromium، وأن كل تحويل يأخذ حوالي 15 ثانية، فإذا كنت تولد عشرات المخططات في خط CI فحولها كلها في تشغيل واحد بدل تشغيل الـ Container لكل ملف.

التحقق من النجاح

  • curl على الصفحة الرئيسية يرجع 200، وعلى /socket.io/?EIO=4&transport=polling يرجع سطراً يبدأ بـ 0{"sid":.
  • أمر grep على الـ Image التي بنيتها لا يظهر فيه oss-collab.excalidraw.com ولا firebaseio.com ولا simpleanalyticscdn.com.
  • بعد Start session يظهر على زر المشاركة رقم المشاركين، وعندما يدخل شخص ثان يصبح الرقم 2 ويظهر مؤشره باسمه.
  • جربنا أيضاً حجب كل العناوين الخارجية عن المتصفحين، ففتحت اللوحة وعمل الرسم المشترك والتصدير، وهذا يعني أن الأداة تعمل في شبكة داخلية لا تصل إلى الإنترنت.

النسخ الاحتياطي Backup والاستعادة Restore

لا توجد بيانات على السيرفر تحتاج نسخاً احتياطياً Backup، لأن خادم الغرف لا يحفظ شيئاً والواجهة ملفات ثابتة، لذلك فالذي تحفظه هو ما تحتاجه لإعادة البناء:

  • المجلد /opt/draw بدون مجلد الكود: compose.yaml وCaddyfile وDockerfile.local، ورقم الـ Commit الذي بنيت منه، والأفضل أن تضعها في مستودع Git خاص.
  • الـ Volume الخاص بـ Caddy إذا أردت ألا يطلب شهادات جديدة بعد الاستعادة.
  • والأهم من كل ذلك ملفات .excalidraw التي يحفظها الفريق، ومكانها المستودعات نفسها، فإذا ضاع السيرفر بقيت المخططات.
cd /opt/draw
tar czf /backup/draw-config-$(date +%F).tgz compose.yaml Caddyfile Dockerfile.local
git -C excalidraw rev-parse HEAD > /backup/draw-commit.txt

التحديث Upgrade إلى إصدار أحدث

التحديث يعني بناء الـ Image من Commit أحدث في فرع release، ولأن ملف .env.production قد يتغير بين الإصدارات فلا تنسخ ملفك القديم فوقه، وإنما أعد تطبيق أمر sed على الملف الجديد:

cd /opt/draw/excalidraw
git checkout -- .env.production
git fetch origin release
git checkout origin/release
git log -1 --format='%H %cI %s'
sed -i \
  -e 's#^VITE_APP_WS_SERVER_URL=.*#VITE_APP_WS_SERVER_URL=https://draw.example.com#' \
  -e "s#^VITE_APP_FIREBASE_CONFIG=.*#VITE_APP_FIREBASE_CONFIG='{}'#" \
  .env.production
docker build -t excalidraw-local:new .

بعد ذلك غير الوسم في Dockerfile.local وcompose.yaml إلى الوسم الجديد، وشغل docker compose up -d --build، ثم أعد أمر grep وتجربة المتصفحين قبل أن تعلن التحديث للفريق. وخادم الغرف لم يتغير منذ ديسمبر 2023، ويعمل على Node.js 12.22.12 الذي انتهى دعمه، لذلك أبقه داخل الشبكة الداخلية خلف Caddy كما فعلنا، ولا تنشر منفذه مباشرة.

مشكلات شائعة وحلولها

ماذا يحدث إذا توقف خادم الغرف Room Server؟

نجرب العطل: أوقفنا Container خادم الغرف، ثم فتحنا رابط غرفة جديدة، فلم تظهر أي رسالة خطأ، وفتحت اللوحة كأنها لوحة عادية، وكل ما تغير أن زر المشاركة ليس عليه رقم:

لوحة Excalidraw بشاشة الترحيب، وزر المشاركة الأخضر في الأعلى بدون أي رقم عليه، ولا توجد رسالة خطأ
عندما يتوقف خادم الغرف تعمل اللوحة محلياً بصمت، وزر المشاركة بدون رقم

وهذه أخطر حالة، لأن كل شخص يظن أنه في جلسة مشتركة وهو يرسم وحده، لذلك إذا قال أحد الزملاء إنه لا يرى ما ترسمه فانظر أولاً إلى الرقم على زر المشاركة، ثم شغل أمر curl على /socket.io/، ثم docker compose logs room.

الرسم المشترك لا يعمل بعد نقل السيرفر إلى نطاق آخر

عنوان خادم الغرف مكتوب داخل ملفات الواجهة وقت البناء، فإذا غيرت النطاق فأعد البناء بالعنوان الجديد، ولا تكتف بتغيير Caddyfile، والسبب أن المتصفح سوف يستمر في الاتصال بالعنوان القديم المكتوب في ملفات الواجهة. وللتأكد من العنوان الموجود في الـ Image شغل grep على assets/*.js بحثاً عن نطاقك.

رسالة «Couldn't save to the backend database»

تظهر عند بدء الجلسة لأن Firebase فارغ، وهي متوقعة في هذا الإعداد ولا تعني أن الجلسة فشلت، وتذكير بأن الرسم لا يحفظ على السيرفر.

خط النوافذ Font يظهر مختلفاً في شبكة مغلقة

إذا كان المتصفح لا يصل إلى الإنترنت فسوف تظهر بعض نصوص النوافذ بخط Serif بدل خط الواجهة، والسبب أن ملف CSS يطلب خط الواجهة Assistant من الـ CDN، أما خطوط الرسم نفسها فتحمل من السيرفر، والرسم والتصدير لا يتأثران.

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

إذا كنت جربت على جهازك وتريد إزالة كل شيء:

cd /opt/draw
docker compose down -v
docker rm -f drawio
docker rmi excalidraw-local:17d8904 excalidraw-local:17d8904-local caddy:2.11.7-alpine \
  excalidraw/excalidraw-room@sha256:2fe999f9be4379e3ee282fc45d75d84a691a6383dde33544514cc395287c7a70 \
  jgraph/drawio:32.5.5 minlag/mermaid-cli:12.0.1

الخلاصة

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

  • الـ Image الرسمية لـ Excalidraw تعمل على سيرفرك، ولكن الرسم المشترك فيها يتصل بخادم الشركة، والحفظ يذهب إلى Firebase، والخطوط من CDN، وفيها سكربت تحليلات.
  • هذه العناوين تكتب وقت البناء، لذلك نبني الـ Image بأنفسنا بعد تعديل سطرين في .env.production، ثم نزيل الـ CDN والتحليلات بطبقة صغيرة فوقها.
  • خادم الغرف وراء نفس النطاق على المسار /socket.io/، فيكفي نطاق واحد وشهادة واحدة.
  • الرسم المشترك مشفر، والمفتاح في الرابط بعد #، وسجلات الخادم لا تظهر فيها إلا أرقام الغرف وبيانات مشفرة، والرابط نفسه هو الصلاحية الوحيدة.
  • بدون Firebase تختفي الغرفة عندما يخرج آخر شخص، فاحفظ الرسم كملف .excalidraw أو PNG مع Embed scene في المستودع.
  • العربية تعمل وتأخذ اتجاهها تلقائياً بخط النظام، وdraw.io مناسب بجانبه للمخطط المفصل بدون تعديل مشترك، وmermaid-cli يحول مخططات الكود إلى صور.

وأقرب المقالات إلى هذا الموضوع على الموقع:

دليلأدوات رسم المخططات Diagrams: مقارنة عمليةمقارنة أدوات الرسم مفتوحة المصدر والتجارية بالترخيص والاستضافة والرسم المشترك وGit، مع اختبار فعلي للعربية.دليلالمخططات التقنية Diagrams: ماذا ترسم ومتى؟كيف تختار نوع المخطط من سؤال القارئ، ومتى ترسم باليد أو بالكود، وكيف تبقيه صحيحاً ومقروءاً بالعربية.دليلتثبيت Caddy على خادمك: Reverse Proxy بشهادات HTTPS تلقائيةكيف تثبت Caddy على سيرفرك ليمنح كل موقع شهادة HTTPS تلقائياً، وتربط به تطبيقاتك بملف إعداد من بضعة أسطر.دليلOutline: قاعدة معرفة فريقك على سيرفرككيف تشغل Outline على سيرفرك ليكتب فيه فريقك إجراءاته وقراراته، ويدخل إليه الموظفون بحساباتهم في authentik.

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

  • أكتوبر 2026: كتابة الدليل واختباره على Excalidraw من فرع release (17d8904) مع excalidraw/excalidraw-room:sha-03ff435 وcaddy:2.11.7-alpine وjgraph/drawio:32.5.5 وminlag/mermaid-cli:12.0.1 على Docker Engine 29.8.0.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.