הצינור: מכשיר ראשון, ענן רק כשצריך
האפליקציה שלמה גם בלי הענן: הסנכרון הוא שכבה נוספת, לא תנאי להפעלה.
ההחלטה שקבעה את כל הארכיטקטורה היא שטיול קורה בדיוק במקומות שבהם אין קליטה. לכן המצב כולו חי במכשיר — חנות אחת בזיכרון שנשמרת ל-localStorage — והענן נכנס רק כשמישהו רוצה לסנכרן עם בן זוג. מכאן נגזרו שאר ההחלטות: ה-Service Worker מחזיק מטמון נפרד לאריחי המפה שלא נמחק בעליית גרסה, כי אריחים שנשמרו לפני הטיסה הם בדיוק מה שצריך לשרוד; תמונות והקלטות מוצפנות ב-AES-GCM לפני שהן יושבות ב-IndexedDB, ורק הבלוב המוצפן עולה לאחסון בענן; וכל רשומה נושאת חותמת זמן, כדי ששני מכשירים שערכו את אותה רשומה יתמזגו לפי העדכני מביניהם במקום למחוק זה את זה. גם מחיקה מסומנת ולא נעלמת, אחרת סנכרון היה מחזיר לחיים פריט שכבר נמחק במכשיר השני.
כל מה שמימין נשען על אותה חנות אחת במכשיר, והענן משמאל הוא היעד ולא המקור. זו הסיבה שהאפליקציה נשארת שלמה בטיסה, במנהרה ובכפר בלי קליטה — ומסתנכרנת כשחוזרת רשת.
מה זה
MA-Trip היא אפליקציית תכנון ותיעוד טיולים לזוגות ולמשפחות, בעברית ומימין לשמאל. מתקינים אותה מ-Safari דרך "הוסף למסך הבית" והיא נפתחת כאפליקציה עצמאית, בלי סרגל דפדפן.
יש בה 13 מסכי מוצר: בית עם ספירה לאחור, מסלול יומי, מפה, רשימת אריזה, תקציב עם המרת מטבע, מסמכים, תזכורות, מקומות מועמדים, זיכרונות עם הקלטה קולית ושיר רקע, ומסכי פירוט ועריכה שנפתחים מעליהם.
הבעיה
אפליקציות טיולים מניחות שיש אינטרנט. במציאות, דווקא ברגעים שבהם צריך את המסלול או את המפה — בטיסה, במנהרה, בכפר בלי קליטה — אין רשת בכלל.
הבעיה השנייה היא פרטיות: תיעוד של טיול הוא תמונות, הקלטות קול וצילומי דרכון. זה בדיוק סוג המידע שלא מרגיש נכון להעלות בגלוי לשרת של מישהו אחר.
מה בניתי
בניתי אפליקציה שהמצב שלה חי במכשיר. יש חנות מרכזית אחת שכל המסכים קוראים ממנה, והיא נשמרת ל-localStorage בכל שינוי — כך שסגירת האפליקציה, ניתוק רשת או המראה לא מוחקים כלום.
מעל זה בניתי שכבת מדיה מוצפנת: כל תמונה, הקלטה או סריקת מסמך מוצפנת ב-AES-GCM ונשמרת ב-IndexedDB. אם וכאשר מסנכרנים, מה שעולה לאחסון בענן הוא הבלוב המוצפן בלבד.
המפה בנויה על Leaflet מעל אריחי OpenStreetMap, עם חיפוש כתובות דרך Nominatim ובלי שום מפתח API. ה-Service Worker שומר את שלד האפליקציה, ובנוסף מחזיק מטמון אריחים נפרד המוגבל לאלף אריחים — שנשאר על כנו גם כשעולה גרסה חדשה, כדי שאריחים שנשמרו מראש לא ייעלמו בדיוק כשצריך אותם.
איך זה עובד
הפרונטאנד הוא React 18 שנטען כספרייה מוכנה מתוך התיקייה המקומית, עם קומפילציה של JSX בדפדפן עצמו. אין bundler, אין node_modules בפריסה ואין שלב build — מגישים קבצים סטטיים, וזהו. המחיר הוא קומפילציה בזמן טעינה; התמורה היא שאפשר לתקן משהו ולפרוס תוך שניות.
הצד השרתי הוא Cloudflare מקצה לקצה: Pages מגיש את האפליקציה, Worker אחד מספק את ה-API, D1 מחזיק שמונה טבלאות (חשבונות, sessions, קישורי כניסה, טיולים, חברי טיול, אינדקס בלובים, אסימוני שיתוף שבוטלו ויומן ביקורת), R2 מחזיק את הבלובים המוצפנים ו-KV אחראי על הגבלת קצב לפי כתובת.
הסנכרון עצמו נשען על שני כללים פשוטים: לכל רשומה יש חותמת זמן, ובמיזוג מנצחת המאוחרת; ומחיקה נשמרת כסימון ולא כהיעלמות, אחרת מכשיר שני היה מחזיר לחיים פריט שכבר נמחק.
הסטאק
React 18.3.1 ו-Leaflet 1.9.4 מתארחים מקומית, קומפילציית JSX בדפדפן, localStorage ו-IndexedDB לאחסון, Web Crypto להצפנה, MediaRecorder להקלטות ו-Service Worker למצב לא מקוון. בצד השרת Cloudflare Pages, Workers, D1, R2 ו-KV.
מכיוון שהאפליקציה מותקנת כ-PWA, כל פריסה מחייבת קידום גרסה בכל ההפניות ובמטמון של ה-Service Worker — אחרת מכשירים מותקנים ממשיכים להגיש קוד ישן. הגרסה החיה כרגע היא v169.
התוצאה
האפליקציה חיה ב-ma-trip.pages.dev ומותקנת כאפליקציה עצמאית ב-iPhone. מסלול, אריזה, תקציב ומפה עובדים גם במצב טיסה, והמדיה נשארת מוצפנת על המכשיר.
אפשר לפתוח אותה ישירות בדפדפן או להתקין אותה כאפליקציה בטלפון, לעבור על המסלול, האריזה, התקציב והמפה — ולראות איך הם ממשיכים לעבוד גם כשמנתקים את הרשת.