עם Service Worker, ויתרנו על הניסיון לפתור את הבעיה במצב אופליין, והענקנו למפתחים את החלקים הנעים כדי לפתור אותה בעצמם. היא מאפשרת לכם לשלוט במטמון ובאופן שבו הבקשות מטופלות. כלומר, אתם יכולים ליצור דפוסים משלכם. נבחן כמה דפוסים אפשריים בנפרד, אבל בפועל סביר להניח שתשתמשו בהרבה מהם יחד, בהתאם לכתובת ה-URL ולהקשר.
הדגמה פעילה של חלק מהדפוסים האלה מופיעה במאמר Trained-to-thrill ובסרטון הזה, שבו מוצג ההשפעה על הביצועים.
מכונת המטמון – מתי כדאי לאחסן משאבים
Service Worker מאפשר לטפל בבקשות בנפרד מהאחסון במטמון, ולכן אציג אותם בנפרד. קודם כול, אחסון במטמון – מתי כדאי לעשות זאת?
בהתקנה – כיחסי תלות
קובץ ה-Service Worker יוצר אירוע install
. אפשר להשתמש באירוע הזה כדי להכין דברים שצריך להיות מוכנים לפני שמטפלים באירועים אחרים. בזמן הזה, כל גרסה קודמת של ה-Service Worker עדיין פועלת ומציגה דפים, כך שהפעולות שאתם מבצעים כאן לא אמורות להפריע לכך.
מתאים במיוחד ל: CSS, תמונות, גופנים, JS, תבניות… בעצם כל מה שנתפס כסטטי ב'גרסה' הזו של האתר.
אלה פריטים שהאתר לא יפעל כלל בלעדיהם, ושההורדה הראשונית של אפליקציה מקבילה ספציפית לפלטפורמה תכלול אותם.
self.addEventListener('install', function (event) {
event.waitUntil(
caches.open('mysite-static-v3').then(function (cache) {
return cache.addAll([
'/css/whatever-v3.css',
'/css/imgs/sprites-v6.png',
'/css/fonts/whatever-v8.woff',
'/js/all-min-v4.js',
// etc.
]);
}),
);
});
event.waitUntil
מקבלת הבטחה שמגדירה את משך ההתקנה ואת הצלחת ההתקנה. אם ה-promise נדחה, ההתקנה נחשבת לכישלון וקובץ ה-Service Worker הזה יוגדר כלא פעיל (אם פועלת גרסה ישנה יותר, היא תישאר ללא שינוי). caches.open()
ו-cache.addAll()
החזרות הבטחות.
אם לא ניתן לאחזר אחד מהמשאבים, הקריאה ל-cache.addAll()
נדחית.
באתר trained-to-thrill, אני משתמש באפשרות הזו כדי לשמור נכסים סטטיים במטמון.
בהתקנה – לא כיחסי תלות
האפשרות הזו דומה לאפשרות שלמעלה, אבל היא לא תעכב את השלמת ההתקנה ולא תגרום להתקנה להיכשל אם האחסון במטמון נכשל.
מתאים במיוחד: משאבים גדולים יותר שלא נדרשים באופן מיידי, כמו נכסים לרמות מתקדמות יותר במשחק.
self.addEventListener('install', function (event) {
event.waitUntil(
caches.open('mygame-core-v1').then(function (cache) {
cache
.addAll
// levels 11–20
();
return cache
.addAll
// core assets and levels 1–10
();
}),
);
});
בדוגמה שלמעלה, ההתחייבות של cache.addAll
לרמות 11 עד 20 לא מועברת חזרה אל event.waitUntil
, כך שגם אם היא תיכשל, המשחק עדיין יהיה זמין במצב אופליין. כמובן, תצטרכו להיערך לאפשרות שהרמות האלה לא יהיו זמינות, ולנסות שוב לשמור אותן במטמון אם הן חסרות.
יכול להיות ש-Service Worker יופסק בזמן ההורדה של הרמות 11 עד 20 כי הוא סיים לטפל באירועים, כלומר הם לא יישמרו במטמון. בעתיד, Web Periodic Background Synchronization API יטפל במקרים כאלה ובהורדות גדולות יותר, כמו סרטים. כרגע אפשר להשתמש ב-API הזה רק ב-Chromium forks.
בזמן ההפעלה
מתאים במיוחד ל: ניקוי והעברה.
אחרי שמתקינים Service Worker חדש ולא משתמשים בגרסה הקודמת, הגרסה החדשה מופעלת ומופיע אירוע activate
. עכשיו, כשהגרסה הישנה לא בשימוש, זה הזמן המתאים לטפל בהעברות של סכימות ב-IndexedDB ולמחוק מטמון שלא בשימוש.
self.addEventListener('activate', function (event) {
event.waitUntil(
caches.keys().then(function (cacheNames) {
return Promise.all(
cacheNames
.filter(function (cacheName) {
// Return true if you want to remove this cache,
// but remember that caches are shared across
// the whole origin
})
.map(function (cacheName) {
return caches.delete(cacheName);
}),
);
}),
);
});
במהלך ההפעלה, אירועים אחרים כמו fetch
מועברים לתור, כך שהפעלה ארוכה עלולה לחסום את טעינת הדפים. מומלץ להפעיל את השירות בצורה מצומצמת ככל האפשר, ולהשתמש בו רק לדברים שלא יכולתם לעשות בזמן שהגרסה הישנה הייתה פעילה.
ב-trained-to-thrill אני משתמש בזה כדי להסיר מטמון ישן.
במהלך אינטראקציה של משתמש
האפשרות הזו מתאימה ל: מקרים שבהם אי אפשר להעביר את האתר כולו למצב אופליין, ובחרתם לאפשר למשתמש לבחור את התוכן שהוא רוצה שיהיה זמין במצב אופליין. לדוגמה: סרטון ב-YouTube, מאמר ב-Wikipedia או גלריה מסוימת ב-Flickr.
מציעים למשתמש לחצן 'לקריאה בהמשך' או 'שמירה לשימוש במצב אופליין'. כשמקישים עליו, אוספים מהרשת את מה שצריך ומכניסים אותו למטמון.
document.querySelector('.cache-article').addEventListener('click', function (event) {
event.preventDefault();
var id = this.dataset.articleId;
caches.open('mysite-article-' + id).then(function (cache) {
fetch('/get-article-urls?id=' + id)
.then(function (response) {
// /get-article-urls returns a JSON-encoded array of
// resource URLs that a given article depends on
return response.json();
})
.then(function (urls) {
cache.addAll(urls);
});
});
});
caches API זמין מדפים וגם מקובצי שירות (service workers), כלומר אפשר להוסיף למטמון ישירות מהדף.
בתגובה מהרשת
מתאים במיוחד ל: משאבים שמתעדכנים לעיתים קרובות, כמו תיבת הדואר הנכנס של משתמש או תוכן של מאמרים. אפשר גם להשתמש בהן לתוכן לא חיוני כמו דמויות וירטואליות, אבל צריך להיזהר.
אם בקשה לא תואמת לאף פריט במטמון, מקבלים אותה מהרשת, שולחים אותה לדף ומוסיפים אותה למטמון בו-זמנית.
אם תעשו זאת למגוון כתובות URL, כמו דמויות, עליכם להיזהר שלא להעמיס על נפח האחסון של המקור. אם המשתמש צריך לפנות מקום בכונן, לא כדאי שתהיה לכם אפשרות ראשית. חשוב להסיר מהמטמון פריטים שאין לכם יותר צורך בהם.
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.open('mysite-dynamic').then(function (cache) {
return cache.match(event.request).then(function (response) {
return (
response ||
fetch(event.request).then(function (response) {
cache.put(event.request, response.clone());
return response;
})
);
});
}),
);
});
כדי לנצל את הזיכרון בצורה יעילה, אפשר לקרוא את גוף התגובה/הבקשה רק פעם אחת. בקוד שלמעלה נעשה שימוש ב-.clone()
כדי ליצור עותקים נוספים שאפשר לקרוא בנפרד.
באתר trained-to-thrill אני משתמש בזה כדי לשמור בזיכרון את התמונות מ-Flickr.
Stale-while-revalidate
מתאים במיוחד: משאבים שמתעדכנים לעיתים קרובות, שבהם אין צורך בגרסה העדכנית ביותר. קטגוריה זו יכולה לכלול דמויות וירטואליות.
אם יש גרסה ששמורה במטמון, אפשר להשתמש בה, אבל כדאי לאחזר עדכון לפעם הבאה.
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.open('mysite-dynamic').then(function (cache) {
return cache.match(event.request).then(function (response) {
var fetchPromise = fetch(event.request).then(function (networkResponse) {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
return response || fetchPromise;
});
}),
);
});
האפשרות הזו דומה מאוד לאפשרות stale-while-revalidate ב-HTTP.
בהודעת Push
Push API הוא תכונה נוספת שמבוססת על Service Worker. כך אפשר להעיר את Service Worker בתגובה להודעה משירות ההודעות של מערכת ההפעלה. זה קורה גם אם למשתמש אין כרטיסייה פתוחה לאתר שלכם. רק קובץ ה-Service Worker מופעל. אתם מבקשים הרשאה לעשות זאת מדף, והמשתמש יקבל בקשה.
מתאים במיוחד ל: תוכן שקשור להתראה, כמו הודעת צ'אט, כתבה חדשותית דחופה או אימייל. כמו כן, תוכן שמשתנה לעיתים רחוקות ואפשר ליהנות מהסנכרון המיידי שלו, כמו עדכון של רשימת משימות או שינוי ביומן.
התוצאה הסופית הנפוצה היא התראה, שבהקשה עליה פותחת או ממקדת דף רלוונטי, אבל חשוב מאוד לעדכן את המטמון לפני שהיא מופיעה. ברור שהמשתמש מחובר לאינטרנט בזמן שהוא מקבל את הודעת ה-push, אבל יכול להיות שהוא לא יהיה מחובר כשיבצע לבסוף אינטראקציה עם ההתראה. לכן חשוב שהתוכן הזה יהיה זמין במצב אופליין.
הקוד הזה מעדכן את המטמון לפני הצגת התראה:
self.addEventListener('push', function (event) {
if (event.data.text() == 'new-email') {
event.waitUntil(
caches
.open('mysite-dynamic')
.then(function (cache) {
return fetch('/inbox.json').then(function (response) {
cache.put('/inbox.json', response.clone());
return response.json();
});
})
.then(function (emails) {
registration.showNotification('New email', {
body: 'From ' + emails[0].from.name,
tag: 'new-email',
});
}),
);
}
});
self.addEventListener('notificationclick', function (event) {
if (event.notification.tag == 'new-email') {
// Assume that all of the resources needed to render
// /inbox/ have previously been cached, e.g. as part
// of the install handler.
new WindowClient('/inbox/');
}
});
ברקע-סנכרון
סנכרון ברקע הוא תכונה נוספת שמבוססת על Service Worker. היא מאפשרת לבקש סנכרון של נתוני רקע באופן חד-פעמי או במרווח זמן (הוריסטי מאוד). זה קורה גם אם למשתמש אין כרטיסייה פתוחה לאתר שלכם. רק קובץ ה-Service Worker מופעל. מבקשים הרשאה לעשות זאת מדף, והמשתמש יתבקש לאשר.
מתאים במיוחד לעדכונים לא דחופים, במיוחד כאלה שמתרחשים באופן קבוע כל כך שהודעת ה-push לכל עדכון תהיה תדירה מדי עבור המשתמשים, כמו צירי זמן של רשתות חברתיות או מאמרי חדשות.
self.addEventListener('sync', function (event) {
if (event.id == 'update-leaderboard') {
event.waitUntil(
caches.open('mygame-dynamic').then(function (cache) {
return cache.add('/leaderboard.json');
}),
);
}
});
שמירה במטמון
למקור מוקצה נפח אחסון מסוים שאפשר לעשות בו מה שרוצים. המרחב הפנוי הזה משותף לכל מקורות האחסון: אחסון(מקומי), IndexedDB, גישה למערכת הקבצים וכמובן מטמון.
הסכום שאתם מקבלים לא מוגדר מראש. הוא משתנה בהתאם למכשיר ולתנאי האחסון. אפשר לבדוק כמה נקודות יש לכם:
if (navigator.storage && navigator.storage.estimate) {
const quota = await navigator.storage.estimate();
// quota.usage -> Number of bytes used.
// quota.quota -> Maximum number of bytes available.
const percentageUsed = (quota.usage / quota.quota) * 100;
console.log(`You've used ${percentageUsed}% of the available storage.`);
const remaining = quota.quota - quota.usage;
console.log(`You can write up to ${remaining} more bytes.`);
}
עם זאת, כמו כל האחסון בדפדפן, הדפדפן יכול למחוק את הנתונים אם המכשיר נמצא בלחץ אחסון. לצערנו, הדפדפן לא יכול להבדיל בין הסרטים שאתם רוצים לשמור בכל מחיר לבין המשחק שאתם לא ממש מתעניינים בו.
כדי לעקוף את הבעיה, משתמשים בממשק StorageManager:
// From a page:
navigator.storage.persist()
.then(function(persisted) {
if (persisted) {
// Hurrah, your data is here to stay!
} else {
// So sad, your data may get chucked. Sorry.
});
כמובן, המשתמש צריך להעניק הרשאה. לשם כך, משתמשים ב-Permissions API.
חשוב שהמשתמש יהיה חלק מהתהליך הזה, כי עכשיו הוא יכול לשלוט במחיקה. אם המכשיר שלהם נמצא בלחץ אחסון, והסרת נתונים לא חיוניים לא פותרת את הבעיה, המשתמש יכול להחליט אילו פריטים להשאיר ואילו להסיר.
כדי שהדבר יפעל, מערכות ההפעלה צריכות להתייחס למקורות 'עמידים' כאל אפליקציות ספציפיות לפלטפורמה בפירוטים שלהן לגבי שימוש באחסון, במקום לדווח על הדפדפן כפריט יחיד.
הצעות להצגת מודעות – מענה לבקשות
לא משנה כמה נתונים תשמרו במטמון, ה-service worker לא ישתמש במטמון אלא אם תציינו לו מתי ואיך. ריכזנו כאן כמה דפוסים לטיפול בבקשות:
מטמון בלבד
מתאים במיוחד ל: כל דבר שנתפס כסטטי ב'גרסה' מסוימת של האתר. כדאי לשמור אותם במטמון באירוע ההתקנה, כדי שתוכלו להסתמך על כך שהם יהיו שם.
self.addEventListener('fetch', function (event) {
// If a match isn't found in the cache, the response
// will look like a connection error
event.respondWith(caches.match(event.request));
});
…למרות שבדרך כלל לא צריך לטפל במקרה הזה באופן ספציפי, הוא מכוסה באפשרות אחסון במטמון, חזרה לרשת.
רשת בלבד
מתאים במיוחד ל: דברים שאין להם מקבילה אופליין, כמו פינגים של Analytics, בקשות שאינן GET.
self.addEventListener('fetch', function (event) {
event.respondWith(fetch(event.request));
// or simply don't call event.respondWith, which
// will result in default browser behavior
});
…למרות שבדרך כלל לא צריך לטפל במקרה הזה באופן ספציפי, הוא מכוסה באפשרות אחסון במטמון, חזרה לרשת.
מטמון, חזרה לרשת
מתאים במיוחד: לפיתוח אופליין-פיrst. במקרים כאלה, כך עליכם לטפל ברוב הבקשות. דפוסים אחרים יהיו חריגים על סמך הבקשה הנכנסת.
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.match(event.request).then(function (response) {
return response || fetch(event.request);
}),
);
});
כך תוכלו לקבל את ההתנהגות 'מטמון בלבד' לגבי פריטים במטמון, ואת ההתנהגות 'רשת בלבד' לכל פריט שלא מאוחסן במטמון (כולל כל הבקשות שאינן GET, כי אי אפשר לאחסן אותן במטמון).
מרוץ בין המטמון לרשת
התאמה מיטבית ל: נכסים קטנים שבהם אתם רוצים לשפר את הביצועים במכשירים עם גישה איטית לדיסק.
בשילובים מסוימים של דיסקים קשיחים ישנים, סורקי וירוסים וחיבורי אינטרנט מהירים יותר, אחזור משאבים מהרשת יכול להיות מהיר יותר מאשר אחזור מהדיסק. עם זאת, חשוב לזכור שהפנייה לערוץ כשהתוכן נמצא במכשיר של המשתמש עלולה להיות בזבוז נתונים.
// Promise.race is no good to us because it rejects if
// a promise rejects before fulfilling. Let's make a proper
// race function:
function promiseAny(promises) {
return new Promise((resolve, reject) => {
// make sure promises are all promises
promises = promises.map((p) => Promise.resolve(p));
// resolve this promise as soon as one resolves
promises.forEach((p) => p.then(resolve));
// reject if all promises reject
promises.reduce((a, b) => a.catch(() => b)).catch(() => reject(Error('All failed')));
});
}
self.addEventListener('fetch', function (event) {
event.respondWith(promiseAny([caches.match(event.request), fetch(event.request)]));
});
הרשת חוזרת למטמון
מתאים במיוחד: תיקון מהיר למשאבים שמתעדכנים לעיתים קרובות, מחוץ ל'גרסה' של האתר. לדוגמה: מאמרים, דמויות, צירי זמן ברשתות חברתיות ורשימות של שחקנים מובילים במשחקים.
כלומר, משתמשים אונליין מקבלים את התוכן העדכני ביותר, אבל משתמשים אופליין מקבלים גרסה ישנה יותר שנשמרה במטמון. אם הבקשה לרשת תתבצע בהצלחה, סביר להניח שתרצו לעדכן את הרשומה במטמון.
עם זאת, לשיטה הזו יש חסרונות. אם למשתמש יש חיבור לא יציב או איטי, הוא יצטרך להמתין עד שהחיבור לרשת יכשל כדי לקבל את התוכן הרצוי שכבר נמצא במכשיר שלו. התהליך הזה עשוי להימשך זמן רב מאוד וחוויית המשתמש עשויה להיות מתסכלת. פתרון טוב יותר מופיע בתבנית הבאה, אחסון במטמון ואז רשת.
self.addEventListener('fetch', function (event) {
event.respondWith(
fetch(event.request).catch(function () {
return caches.match(event.request);
}),
);
});
מטמון ואז רשת
מתאים במיוחד ל: תוכן שמתעדכן לעיתים קרובות. לדוגמה: מאמרים, צירי זמן ברשתות חברתיות ומשחקים. רשימות של מנהיגים.
לשם כך, הדף צריך לשלוח שתי בקשות, אחת למטמון ואחת לרשת. הרעיון הוא להציג קודם את הנתונים ששמורים במטמון, ואז לעדכן את הדף כשנתוני הרשת מגיעים (אם הם מגיעים).
לפעמים אפשר פשוט להחליף את הנתונים הנוכחיים כשמגיעים נתונים חדשים (למשל, לידרבורד של משחק), אבל זה עלול להפריע לחלקים גדולים יותר של תוכן. בעיקרון, אל תגרמו ל'היעלמו' של משהו שהמשתמש אולי קורא או מבצע איתו אינטראקציה.
מערכת Twitter מוסיפה את התוכן החדש מעל התוכן הישן ומתאימה את מיקום הגלילה כדי שהמשתמש לא ייפגע. הדבר אפשרי כי ב-Twitter התוכן נשאר בדרך כלל בסדר לינארי. העתקתי את התבנית הזו ל-trained-to-thrill כדי להציג תוכן במסך מהר ככל האפשר, תוך הצגת תוכן עדכני ברגע שהוא מגיע.
קוד בדף:
var networkDataReceived = false;
startSpinner();
// fetch fresh data
var networkUpdate = fetch('/data.json')
.then(function (response) {
return response.json();
})
.then(function (data) {
networkDataReceived = true;
updatePage(data);
});
// fetch cached data
caches
.match('/data.json')
.then(function (response) {
if (!response) throw Error('No data');
return response.json();
})
.then(function (data) {
// don't overwrite newer network data
if (!networkDataReceived) {
updatePage(data);
}
})
.catch(function () {
// we didn't get cached data, the network is our last hope:
return networkUpdate;
})
.catch(showErrorMessage)
.then(stopSpinner);
קוד בקובץ השירות (service worker):
תמיד צריך לעבור לרשת ולעדכן את המטמון תוך כדי תנועה.
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.open('mysite-dynamic').then(function (cache) {
return fetch(event.request).then(function (response) {
cache.put(event.request, response.clone());
return response;
});
}),
);
});
ב-trained-to-thrill תיארתי פתרון לבעיה הזו באמצעות שימוש ב-XHR במקום ב-fetch, ושימוש לרעה בכותרת Accept כדי להורות לקובץ השירות (Service Worker) מאיפה לקבל את התוצאה (קוד הדף, קוד קובץ השירות).
חלופה כללית
אם לא הצלחתם להציג משהו מהמטמון ו/או מהרשת, מומלץ לספק חלופה גנרית.
מתאים במיוחד ל: תמונות משניות כמו דמויות, בקשות POST שנכשלו ודף 'לא זמין במצב אופליין'.
self.addEventListener('fetch', function (event) {
event.respondWith(
// Try the cache
caches
.match(event.request)
.then(function (response) {
// Fall back to network
return response || fetch(event.request);
})
.catch(function () {
// If both fail, show a generic fallback:
return caches.match('/offline.html');
// However, in reality you'd have many different
// fallbacks, depending on URL and headers.
// Eg, a fallback silhouette image for avatars.
}),
);
});
סביר להניח שהפריט שאליו תבצעו חזרה הוא יחסי תלות בהתקנה.
אם הדף מפרסם אימייל, יכול להיות ש-service worker יעבור לשמירת האימייל ב'תיבת דואר יוצאת' ב-IndexedDB, ויודיע לדף שהשליחה נכשלה אבל הנתונים נשמרו.
יצירת תבניות בצד ה-Service Worker
מתאים במיוחד: דפים שלא ניתן לשמור את תגובת השרת שלהם במטמון.
עיבוד דפים בשרת מאפשר לכם לעבוד מהר יותר, אבל יכול להיות שזה יגרום לכם לכלול נתוני מצב שלא יתאימו למטמון, למשל 'התחבר כ…'. אם הדף שלכם נשלט על ידי קובץ שירות (service worker), תוכלו לבקש במקום זאת נתוני JSON יחד עם תבנית, ולבצע עיבוד (render) שלהם.
importScripts('templating-engine.js');
self.addEventListener('fetch', function (event) {
var requestURL = new URL(event.request.url);
event.respondWith(
Promise.all([
caches.match('/article-template.html').then(function (response) {
return response.text();
}),
caches.match(requestURL.path + '.json').then(function (response) {
return response.json();
}),
]).then(function (responses) {
var template = responses[0];
var data = responses[1];
return new Response(renderTemplate(template, data), {
headers: {
'Content-Type': 'text/html',
},
});
}),
);
});
סיכום של כל המידע
אתם לא מוגבלים לשימוש באחת מהשיטות האלה. למעשה, סביר להניח שתשתמשו בהרבה מהן, בהתאם לכתובת ה-URL של הבקשה. לדוגמה, במודל trained-to-thrill נעשה שימוש ב:
- cache on install (אחסון במטמון בהתקנה), לממשק המשתמש ולהתנהגות הסטטיים
- אחסון במטמון בתגובה מהרשת, לתמונות ולנתונים של Flickr
- אחזור מהמטמון, חזרה לרשת, לרוב הבקשות
- אחזור מהמטמון ואז מהרשת, עבור תוצאות החיפוש ב-Flickr
פשוט בודקים את הבקשה ומחליטים מה לעשות:
self.addEventListener('fetch', function (event) {
// Parse the URL:
var requestURL = new URL(event.request.url);
// Handle requests to a particular host specifically
if (requestURL.hostname == 'api.example.com') {
event.respondWith(/* some combination of patterns */);
return;
}
// Routing for local URLs
if (requestURL.origin == location.origin) {
// Handle article URLs
if (/^\/article\//.test(requestURL.pathname)) {
event.respondWith(/* some other combination of patterns */);
return;
}
if (/\.webp$/.test(requestURL.pathname)) {
event.respondWith(/* some other combination of patterns */);
return;
}
if (request.method == 'POST') {
event.respondWith(/* some other combination of patterns */);
return;
}
if (/cheese/.test(requestURL.pathname)) {
event.respondWith(
new Response('Flagrant cheese error', {
status: 512,
}),
);
return;
}
}
// A sensible default pattern
event.respondWith(
caches.match(event.request).then(function (response) {
return response || fetch(event.request);
}),
);
});
…הבנתם את הרעיון.
זיכויים
…על הסמלים היפים:
- Code מאת buzzyrobot
- Calendar מאת סקוט לואיס
- Network by Ben Rizzo
- SD מאת Thomas Le Bas
- CPU מ-iconsmind.com
- Trash מאת trasnik
- הודעה מאת @daosme
- פריסה מאת Mister Pixel
- Cloud מאת P.J. Onori
תודה גם ל-Jeff Posnick שזיהה הרבה שגיאות חמורות לפני שלחצתי על 'פרסום'.
קריאה נוספת
- מבוא ל-Service Workers
- האם Service Worker מוכן? – מעקב אחרי סטטוס ההטמעה בדפדפנים הראשיים
- JavaScript Promises – an Introduction – מדריך ל-Promises