עובדי Push
- summary:
Web Push מקצה לקצה — דרישות ומפתחות VAPID, המסלול המקומי ב-pywebpush מול עובד ה-Node, החיבור ל-WebApp, מה מפעיל התראה (תזכורות פתקים וכתיבה דרך ה-MCP), צד הלקוח, ובדיקות.
ל-Code Keeper Bot יש שני מסלולים לשליחת Web Push:
מסלול מקומי (ברירת מחדל) – ה-WebApp שולח ישירות עם
pywebpush. זה המסלול הפעיל בפרודקשן.Node Push Worker (
/push_worker) – תהליך נפרד שרץ לצד הבוט (Render/Docker/VM), למי שרוצה להפריד את המפתח הפרטי מתהליך ה-Flask.
העמוד מסביר איך לפרוס ולחבר אותם אל ה-WebApp (webapp/push_api.py) בצורה מאובטחת.
אזהרה
Cloudflare Workers אינו נתמך לשליחת Web Push.
בעבר הייתה בריפו תיקיית worker/ עם Cloudflare Worker. היא הוסרה, כי
ספריית web-push נשענת על https.request מ-Node, ובסביבת Workers זהו
stub שזורק מיד:
[unenv] https.request is not implemented yet!
התוצאה: ה-Worker החזיר 502 על כל שליחה, בלי שאף בקשת רשת יצאה. הוספת
nodejs_compat אינה פותרת את זה. מי שרוצה בכל זאת שולח Edge חייב לממש
את פרוטוקול Web Push ישירות מול Web Crypto API ו-fetch — לא דרך
web-push.
דרישות מוקדמות
דפדפנים. Chrome, Edge, Firefox ואנדרואיד תומכים ישירות. ב-iOS פוש עובד רק מתוך PWA מותקנת (הוספה למסך הבית, iOS 16.4 ומעלה) — בלשונית רגילה בספארי אין פוש כלל, וזו הסיבה הנפוצה ביותר ל“לא מקבל התראות“ באייפון.
מפתחות VAPID. זוג מפתחות חובה בשני המסלולים. ייצור:
npx web-push generate-vapid-keys
דגל ראשי. PUSH_NOTIFICATIONS_ENABLED (ברירת מחדל true). כיבוי
עוצר את התזמון ואת השליחה, אבל משאיר את נקודות הקצה זמינות — כלומר
הדפדפן עדיין יכול להירשם, ופשוט לא יקבל דבר.
מסלול מקומי (pywebpush)
זהו ברירת המחדל, ואין צורך בשום רכיב נוסף. נדרש רק:
PUSH_REMOTE_DELIVERY_ENABLED=false
VAPID_PUBLIC_KEY=<base64url>
VAPID_PRIVATE_KEY=<base64url>
VAPID_SUB_EMAIL=support@example.com
לאחר שינוי משתני הסביבה יש לבצע restart לשירות כדי שייקלטו.
תקרת זמן למסירה מקומית
PUSH_LOCAL_TIMEOUT_SECONDS (ברירת מחדל 10) חוסם כל קריאת pywebpush
בזמן. הוא נדרש כי הספרייה אינה מגינה על עצמה: חתימת webpush() מגדירה
timeout=None, והערך מועבר הלאה תמיד — גם כשהוא None. לכן ברירת המחדל
הפנימית שלה אינה חלה לעולם, ומה שמגיע ל-requests הוא timeout=None,
כלומר המתנה בלי גבול.
סכנה
push-sender הוא חוט יחיד. מנוי אחד שנתקע עוצר את כל ההתראות של
כל המשתמשים — גם תזכורות הפתקים, לא רק אירועי ה-MCP. זו הסיבה שהחסם
קיים, ואין להסיר אותו.
מה קורה כשהחסם נסגר על מסירה איטית: אותה מסירה נכשלת במקום להמתין.
הכשל בטוח ואינו מאבד את ההתראה — needs_push נשאר דלוק, והסבב הבא מנסה
שוב. הקצב הוא PUSH_SEND_INTERVAL_SECONDS.
חשוב
אם תזכורות מפסיקות להגיע ובלוגים מופיעים כשלי מסירה חוזרים, זה המקום לבדוק ראשון. רשת איטית או שירות פוש שמגיב לאט יכולים לחרוג מעשר שניות, ואז כל ניסיון נחתך.
התקרה מכווננת ב-PUSH_LOCAL_TIMEOUT_SECONDS בלי שינוי קוד — הגדילו
אותה (למשל ל-20) ובצעו restart. אל תבטלו אותה: ערך גבוה מדי מחזיר את
סיכון ההיתקעות, ומעל PUSH_CLAIM_TTL_SECONDS הוא גם מאפשר לשולח אחר
לתפוס את אותה התראה ולשלוח אותה פעמיים.
לאבחון מהיר: POST /api/push/test מחזיר מערך errors מפורט, ודף
/settings/push-debug עוטף אותו בממשק.
Node Push Worker (/push_worker)
נכתב ב-Express (
index.js) ומאזין כברירת מחדל על127.0.0.1:8080.מיועד לרוץ כחלק מ-
scripts/start_with_worker.shאו כ-Container נפרד. הסקריפט טוען.env.workerכדי למנוע הדלפת מפתחות VAPID לתהליך הראשי.נתיבים:
GET /healthz– בדיקת חיים (משמשת את סקריפט ה-start להמתין ל-ready).POST /send– מקבל{subscription, payload, options}ב-JSON.
משתני סביבה:
PORT– ברירת מחדל 8080; הסקריפט מגדיר אוטומטיתPUSH_WORKER_PORT.PUSH_DELIVERY_TOKEN– תואם לחלק השרת.WORKER_VAPID_PUBLIC_KEY,WORKER_VAPID_PRIVATE_KEY,WORKER_VAPID_SUB_EMAIL– עדיפות ראשונה; נופל חזרה ל-VAPID_*אם חסר.
אבטחה:
השוואת Bearer מתבצעת ב-constant time בעזרת
crypto.timingSafeEqual.ה-Worker מאזין על
127.0.0.1בלבד.
פריסה כשירות נפרד ב-Render. render.push-worker.yaml מגדיר שירות
Docker ל-push_worker. אפשר להעלות אותו כ-Blueprint, או ליצור שירות Web
חדש שמצביע ל-push_worker/Dockerfile. משתני הסביבה שהשירות הזה צריך:
PUSH_DELIVERY_TOKEN, WORKER_VAPID_PUBLIC_KEY,
WORKER_VAPID_PRIVATE_KEY, WORKER_VAPID_SUB_EMAIL.
חשוב
הבינד ל-127.0.0.1 מתאים רק לפריסת sidecar, כלומר כשה-Worker
וה-WebApp חולקים את אותו network namespace (אותו קונטיינר, או אותו Pod
ב-Kubernetes). בפריסה שבה ה-Worker רץ בקונטיינר או ב-Pod נפרד, ה-WebApp
לא יוכל להגיע אליו כלל.
לפריסה נפרדת יש לבצע bind לממשק פנימי (למשל 0.0.0.0 בתוך רשת פרטית),
ולהגן עליו בשתי שכבות: NetworkPolicy או Security Group שמתירים תעבורה
מה-WebApp בלבד, ובנוסף PUSH_DELIVERY_TOKEN. אין לחשוף את הפורט
לאינטרנט בשום מקרה.
הערה
scripts/start_with_worker.sh מפעיל את ה-Worker רק כאשר
PUSH_REMOTE_DELIVERY_ENABLED דלוק. שירות שרץ עם scripts/start_webapp.sh
(למשל code-keeper-webapp ב-Render) אינו מריץ sidecar כלל, ולכן שם
המסלול המקומי הוא היחיד שזמין.
חיבור ל-WebApp (webapp/push_api.py)
כדי להפעיל משלוח דרך Worker חיצוני:
הגדירו ב-WebApp את המשתנים הבאים:
PUSH_REMOTE_DELIVERY_ENABLED=true PUSH_DELIVERY_URL=https://push-worker.example.com PUSH_DELIVERY_TOKEN=super-secret-token PUSH_DELIVERY_TIMEOUT_SECONDS=3
PUSH_DELIVERY_URLהוא בסיס בלי/send— השרת מוסיף אותו בעצמו.אם עובדים מול Worker מקומי (באמצעות
start_with_worker.sh):קבעו
PUSH_WORKER_PORT(ברירת מחדל 18080).מלאו את
.env.workerעם המפתחות הייעודיים.הסקריפט ימתין ל-
/healthzעד 6 שניות ויעדכןPUSH_DELIVERY_URLל-http://127.0.0.1:<port>אם לא סופק ערך.
סכנה
אין להשתמש בזוגות VAPID שונים בין השרת ל-Worker.
מנוי בדפדפן נקשר למפתח הציבורי שאיתו הוא נוצר. אם השליחה נחתמת במפתח פרטי מזוג אחר, שירות הפוש דוחה אותה ב-403 וההתראה לא מגיעה — בלי שום שגיאה גלויה בצד הלקוח.
שימו לב ש-_coerce_vapid_pair() נותן עדיפות ל-WORKER_VAPID_* על פני
VAPID_*, כולל במפתח שמוחזר ללקוח ב-/api/push/public-key. לכן הוספת
WORKER_VAPID_PUBLIC_KEY לסביבת ה-WebApp משנה את המפתח שהדפדפן נרשם
איתו, ומשביתה בשקט את כל המנויים הקיימים. אחרי כל החלפת מפתחות יש למחוק
את המנויים הישנים ולהירשם מחדש.
מי שולח את התזכורות, ומתי
לא ג’וב רשום ולא cron חיצוני, אלא thread בתוך תהליך ה-WebApp.
webapp/push_api.py מרים thread דמון בשם push-sender, שמריץ
_send_due_once() בלולאה ונרדם PUSH_SEND_INTERVAL_SECONDS בין סבב
לסבב (ברירת מחדל 60 שניות, ורצפה של 20 גם אם הוגדר פחות).
הסבב שולף מ-note_reminders תזכורות שהגיע זמנן, ושולח לכל מנוי של
המשתמש — במסלול המקומי ישירות עם pywebpush, ובמסלול המרוחק דרך
POST /send של ה-Worker.
חשוב
רק תהליך אחד שולח. לפני הרמת ה-thread נלקחת נעילת flock על
PUSH_SENDER_LOCK_FILE (ברירת מחדל /tmp/codebot-push-sender.lock);
תהליך שלא השיג אותה פשוט אינו מרים שולח. בלי זה כל worker של gunicorn
היה שולח את אותה תזכורת. הנעילה היא fail-open — אם fcntl אינו
זמין, כל תהליך מרים שולח משלו.
מניעת הכפילות ברמת התזכורת עצמה היא נפרדת, ונשענת על _claim_reminder
ועל דגל needs_push.
המקור השני: כתיבה שהגיעה מ-MCP
מלבד התזכורות, פוש נשלח גם כשסוכן שומר קובץ דרך שירות ה-MCP —
codekeeper_save_file, codekeeper_edit_file ו-codekeeper_append_file.
ההבדל העקרוני מהתזכורות: הן נשענות על ”הגיע הזמן“, וזה נשען על ”קרה משהו“.
המקור אינו נקבע בשדה אלא במיקום. ה-hook יושב ב-
ProductionBackend.save_file, ומחלקה זו מיובאת רק ב-mcp_server/app.py.
כתיבה מהוובאפ או מהבוט אינה עוברת שם, ולכן אינה יכולה לייצר התראה כזו — בלי
שדה origin ובלי דגל מקור שנודד בפרמטרים. שלושת הכלים מתנקזים לאותה מתודה,
ולכן ה-hook אחד.
וכלי כתיבה שאינו מייצר התראה, בכוונה.
codekeeper_update_file_description משנה את ה-description של קובץ קיים
ואינו עובר ב-save_file כלל: הוא אינו יוצר גרסה ואינו נוגע בתוכן. היעדר
הפוש שם אינו באג — התראה שאומרת ”סוכן שמר קובץ“ על שינוי שהמשתמש לא יזהה
כשמירה היא רעש. מי שמחפש שם את ה-hook ולא מוצא אותו מצא את ההתנהגות הנכונה.
חשוב
ה-MCP אינו שולח פוש. הוא רץ בתהליך נפרד, ושליחה משם הייתה עוקפת את
נעילת ה-flock שמבטיחה שולח יחיד, ומחייבת עותק שני של מפתחות ה-VAPID.
במקום זה הוא רושם שורה באוסף push_events, ואותו thread push-sender
שמטפל בתזכורות אוסף אותה בסבב הבא — דרך אותו מסלול מסירה בדיוק.
המחיר הוא עיכוב של עד PUSH_SEND_INTERVAL_SECONDS. אין לקצר אותו בשביל
המקור הזה — הוא מכתיב את הקצב של כל התזכורות.
הרישום מתבצע רק אחרי ששמירה הצליחה. כשל ברישום אינו מפיל את השמירה — הקובץ כבר נשמר — אבל הוא נרשם ללוג, כדי שתור שהפסיק להתמלא לא ייראה כמו סוכן שלא כתב כלום.
כיבוי. MCP_PUSH_NOTIFICATIONS_ENABLED (ברירת מחדל true) נקרא בצד
הכותב, בנקודת הרישום. אירוע שכובה אינו נרשם כלל, ולכן כיבוי אינו מותיר תור
שמתמלא בלי שאף אחד קורא ממנו.
האוסף ואינדקסיו. push_events מקבל שני אינדקסים ב-
database/manager.py ולא ב-webapp/push_api.py, ובכוונה: את התור כותב
שירות ה-MCP וקורא ה-WebApp, ושניהם מגיעים ל-_create_indexes. אינדקס שנבנה
רק בצד הוובאפ היה חסר בפריסה שבה עלה ה-MCP לבדו.
push_events_pending_idx—(needs_push, created_at), לשאילתת הסבב.push_events_ttl— TTL עלcreated_at, שבעה ימים. התור חולף מטבעו, ואירוע שלא נשלח תוך שבוע מתאר כתיבה שאבד בה העניין.
הערה
בפריסה שבה רק ה-MCP רץ, אירועים נרשמים ואינם נשלחים לעולם — השולח חי בתהליך הוובאפ. ה-TTL מנקה אותם, כך שהאוסף אינו גדל בלי גבול.
מה שההתראה נושאת. כותרת לפי יצירה מול עדכון, והגוף הוא שם הקובץ, חתוך ל-
120 תווים. אין היום תקרת אורך על שם קובץ בכלי הכתיבה של ה-MCP, והגוף נשלח
בבקשת Web Push שבה התקרה היא של הפרוטוקול: שירות פוש אינו רשאי לדחות גוף של
4096 בתים או פחות, ומעבר לכך הוא רשאי לדחות ב-413
(RFC 8030 §7.2).
החיתוך נעשה בתווים ולא בבתים — בעברית אות היא שני בתים, וחיתוך לפי בתים נוחת
באמצע תו.
התראה חדשה על אותו קובץ מחליפה את הקודמת. ה-tag של ההתראה נגזר משם
הקובץ ולא מהגרסה — כל שמירה ב-CodeKeeper היא מסמך חדש עם מזהה חדש, ולכן
tag שנשען על מזהה הגרסה היה שונה בכל עדכון. סוכן שמעדכן את אותו קובץ כמה
פעמים ברצף היה מייצר ערימת התראות זהות במקום אחת מעודכנת, וזו בדיוק התקלה
שה-tag נועד למנוע.
אזהרה
ההתראה נשלחת בלי כפתורי פעולה, וזה לא פשרה אלא דרישה. ה-Service Worker
מתעלם מכל action שאינו open_note או snooze_10, ולכן התראה
שנושאת כפתור בשם חדש לא תיפתח כלל אצל מי שה-SW שלו עדיין ישן — וה-SW
מתעדכן באיחור. בלי כפתורים, לחיצה על גוף ההתראה מגיעה בלי action,
נופלת למסלול הפתיחה, ומנותבת ל-/md/<file_id>.
אירוע שאין לו יעד מסירה נפסל, ולא נשאר ממתין. מי שעובד מול ה-MCP בלי
להירשם לפוש בדפדפן צובר אירועים שלא יישלחו לעולם; כל עוד needs_push שלהם
דלוק הם נשלפים בכל סבב ותופסים מקום במכסה, עד כדי חניקה של משתמשים שכן נרשמו.
לכן, כשמתברר שלמשתמש אין מנוי, כל הממתינים שלו מכובים בבת אחת עם
skipped_reason: no_subscriptions — ולא רק המנה שנשלפה באותו סבב, כי פסילה
אינה שליחה ואין סיבה להגביל אותה למכסת השליחה.
חשוב
הפסילה חלה רק על ”אין מנוי“, שהוא מצב יציב. חסר מפתח VAPID פרטי או
pywebpush שאינו מותקן הם תקלות קונפיג חולפות, והאירועים ממתינים להן —
פסילה בגללן הייתה הופכת תקלה זמנית לאובדן התראות קבוע.
אירוע שסוגו אינו מוכר לגרסת הוובאפ שרצה — למשל שירות MCP חדש יותר שכותב
kind חדש — אינו נשלח, ו-needs_push שלו מכובה מיד. בלי זה הוא היה נבחר
בכל סבב מכאן והלאה בלי להישלח אף פעם.
כיבוי מותנה בבעלות. מסירה שנמשכה מעבר ל-PUSH_CLAIM_TTL_SECONDS
משחררת את האירוע, ושולח אחר יכול לתפוס אותו. לכן _claim_push_event מחזיר
מזהה בעלות, והכיבוי מתנה את עצמו בו — אותה זהירות שבה מסלול התזכורות מתנה את
הכיבוי שלו ב-remind_at שלא השתנה.
מסירה שנכשלת אינה חוזרת לנצח. ``404``/410 מוחקים מנוי מת ופותרים את
עצמם, אבל כשל אחר — מנוי עם מפתחות פגומים, או 5xx משירות הפוש — אינו
מוחק דבר, ולכן היה חוזר בכל סבב ותופס מקום במכסה. לכל אירוע יש מונה
attempts, ובהגיעו ל-חמישה ניסיונות הוא עובר למצב סופי עם
skipped_reason: delivery_failed. בקצב ברירת המחדל אלה כחמש דקות.
הערה
התקרה מכבה את needs_push ואינה מתבטאת כסינון attempts < MAX
בשאילתה, ובכוונה: אירוע שמיצה את ניסיונותיו היה יוצא מהשאילתה אבל נשאר
דלוק במסד לנצח — אותה תקלה, רק מוסווית. כשל מסירה גם משחרר את ה-claim,
כי אחרת הניסיון החוזר היה תלוי בכך שהסבב הבא יאחר: PUSH_CLAIM_TTL_SECONDS
ו-PUSH_SEND_INTERVAL_SECONDS שווים בברירת המחדל.
צד הלקוח
נקודות קצה בשרת:
נתיב |
תפקיד |
|---|---|
|
מחזיר |
|
שומר מנוי למשתמש המחובר |
|
מסיר מנוי |
Service Worker. sw.js נטען משורש הסקופ (/sw.js) ומאזין
ל-push ול-notificationclick, כולל פעולות מהירות בהתראה עצמה:
open_note (פתיחת הפתק) ו-snooze_10/snooze_60/snooze_1440
(דחייה ב-10 דקות, שעה, או יממה — מטופלת ב-SW עצמו).
הרשמה. בעמוד /settings יש CTA שמבצע את הרצף המלא: רישום ה-Service
Worker, בקשת הרשאה מהדפדפן, רישום Push מול המפתח הציבורי, ושליחת המנוי
לשרת.
בדיקות ועצות
בדיקת חיים – curl -fsS $PUSH_DELIVERY_URL/healthz אמור להחזיר {"ok":true}.
בדיקת אינטגרציה – POST /send דורש Bearer token וגוף JSON תקין,
אחרת יוחזר 401 או 400 עוד לפני ניסיון השליחה:
curl -X POST "$PUSH_DELIVERY_URL/send" \
-H "Authorization: Bearer $PUSH_DELIVERY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"subscription": {
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
"keys": {"p256dh": "<base64url>", "auth": "<base64url>"}
},
"payload": {"notification": {"title": "בדיקה", "body": "שלום"}},
"options": {"ttl": 3600, "urgency": "high", "contentEncoding": "aes128gcm"}
}'
את ערכי ה-subscription אפשר להעתיק מ-/settings/push-debug (כפתור
”הצג מנויים“) או מ-PushSubscription.toJSON() בקונסולת הדפדפן.
בדיקת לקוח – POST /api/push/test (נדרש session) שולח פוש לדפדפן
ומחזיר JSON עם sent ומערך errors מפורט. דף /settings/push-debug
עוטף את זה בממשק.
פענוח שגיאות
קוד |
משמעות |
|---|---|
|
שגיאת הרשאה בין ה-WebApp ל-Worker: |
|
נדחה על ידי שירות הפוש. ברוב המקרים אי-התאמת מפתחות VAPID — המנוי נוצר עם מפתח ציבורי אחר מזה שחותם. בדקו את ה-Worker ואת זוג המפתחות. |
|
המנוי מת (המשתמש ביטל, ניקה נתונים, או שהדפדפן חידש). נמחק אוטומטית מהמסד. |
|
שגיאת צד לקוח מ-Google Play Services במכשיר (שעון לא מסונכרן, חשבון Google חסר, GMS ישן). אינה קשורה לשרת. |
ניסיונות חוזרים – אין ניסיון חוזר מיידי בכלל. _post_to_worker()
שולח בקשה אחת ומחזיר, ואין סביבה לולאת retry.
מה שכן קורה: needs_push מתנקה רק בשליחה שהצליחה. כל שליחה שלא
הצליחה — מכל סוג, כולל 4xx שאינו 404/410 — משאירה את הדגל
דלוק, ולכן הסבב הבא של push-sender (ראו למעלה) יאסוף את אותה תזכורת
וינסה שוב. הקצב הוא PUSH_SEND_INTERVAL_SECONDS, לא backoff.
מה כן מפסיק לחזור: 404/410 גוררים מחיקת ה-endpoint מ-
push_subscriptions, ולכן המנוי המת אינו נשלף בסבב הבא. התזכורת עצמה
עדיין תישלף כל עוד needs_push דלוק — כלומר כל עוד אף מנוי אחר של
המשתמש לא הצליח.
הערה
בפועל המשמעות היא ש-401/403 — טוקן שגוי בין השרת ל-Worker, או
אי-התאמת VAPID — נשלחים שוב ושוב עד שמתקנים את הקונפיג. זו התנהגות
הקוד היום, לא המלצה: תיקון של הסיבה עדיף על המתנה לניסיון הבא.
X-Idempotency-Key – ה-Worker מעביר את הכותרת הלאה בלבד, ושירותי הפוש
מתעלמים ממנה. זוהי כותרת מתאם ואבחון לשיוך לוגים בין השרת ל-Worker,
ואינה מונעת שליחה כפולה. מניעת כפילויות אמיתית מתבצעת בצד השרת
דרך _claim_reminder ודגל needs_push.
בדיקות יחידה – tests/test_push_api.py מכסה את public-key ואת
subscribe/unsubscribe.
לוגים – נרשם hash של ה-endpoint בלבד, ו-URLs מנוקים מהודעות שגיאה.