├── .gitignore ├── README.md ├── package.json ├── server.js └── web ├── 404.html ├── about.html ├── add-post.html ├── contact.html ├── css └── style.css ├── images ├── logo.gif └── offline.png ├── index.html ├── js ├── add-post.js ├── blog.js ├── external │ └── idb-keyval-iife.min.js ├── home.js ├── login.js └── sw.js ├── login.html ├── offline.html └── posts └── post.html /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | web/posts/2* 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Service Workers & Offline Course](https://frontendmasters.com/courses/service-workers/) by Kyle Simpson 2 | 3 | Code for the Service Workers / PWA section of the Service Workers and Offline course by Kyle Simpson 4 | 5 | ## Starter Exercise Files 6 | 7 | To get started, download the [Starter Files (ZIP)](https://static.frontendmasters.com/resources/2019-05-10-service-worker-pwa/service-workers-starter.zip) 8 | 9 | ### Web Workers Solution 10 | 11 | The solution for the Web Workers section of the course: https://github.com/FrontendMasters/web-workers 12 | 13 | ### Service Workers & PWA Solution 14 | 15 | This repository has the final code for the course. You may refer to the [code commits on May 10th](https://github.com/FrontendMasters/service-workers-offline/commits/master), to walk through the course code as Kyle is completing it throughout the course. 16 | 17 | Don't forget to `npm install` the necessary modules. 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-ramblings", 3 | "main": "server.js", 4 | "dependencies": { 5 | "cookie": "~0.3.1", 6 | "get-stream": "~5.1.0", 7 | "node-static-alias": "~1.1.2", 8 | "random-number-csprng": "~1.0.2" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var util = require("util"); 4 | var fs = require("fs"); 5 | var path = require("path"); 6 | var http = require("http"); 7 | var nodeStaticAlias = require("node-static-alias"); 8 | var getStream = require("get-stream"); 9 | var cookie = require("cookie"); 10 | var rand = require("random-number-csprng"); 11 | 12 | var fsReadDir = util.promisify(fs.readdir); 13 | var fsReadFile = util.promisify(fs.readFile); 14 | var fsWriteFile = util.promisify(fs.writeFile); 15 | 16 | const PORT = 8049; 17 | const WEB_DIR = path.join(__dirname,"web"); 18 | 19 | var httpServer = http.createServer(handleRequest); 20 | 21 | var staticServer = new nodeStaticAlias.Server(WEB_DIR,{ 22 | serverInfo: "My Ramblings", 23 | cache: 1, 24 | alias: [ 25 | { 26 | // basic static page friendly URL rewrites 27 | match: /^\/(?:index)?(?:[#?]|$)/, 28 | serve: "index.html", 29 | force: true, 30 | }, 31 | { 32 | // basic static page friendly URL rewrites 33 | match: /^\/(?:about|contact|login|404|offline)(?:[#?]|$)/, 34 | serve: "<% basename %>.html", 35 | force: true, 36 | }, 37 | { 38 | // URL rewrites for individual posts 39 | match: /^\/post\/[\w\d-]+(?:[#?]|$)/, 40 | serve: "posts/<% basename %>.html", 41 | force: true, 42 | }, 43 | { 44 | // match (with force) static files 45 | match: /^\/(?:(?:(?:js|css|images)\/.+))$/, 46 | serve: ".<% reqPath %>", 47 | force: true, 48 | }, 49 | ], 50 | }); 51 | 52 | 53 | httpServer.listen(PORT); 54 | console.log(`Server started on http://localhost:${PORT}...`); 55 | 56 | 57 | // ******************************* 58 | 59 | var sessions = []; 60 | 61 | async function handleRequest(req,res) { 62 | // parse cookie values? 63 | if (req.headers.cookie) { 64 | req.headers.cookie = cookie.parse(req.headers.cookie); 65 | } 66 | 67 | // handle API calls 68 | if ( 69 | ["GET","POST"].includes(req.method) && 70 | /^\/api\/.+$/.test(req.url) 71 | ) { 72 | if (req.url == "/api/get-posts") { 73 | await getPosts(req,res); 74 | return; 75 | } 76 | else if (req.url == "/api/login") { 77 | let loginData = JSON.parse(await getStream(req)); 78 | await doLogin(loginData,req,res); 79 | return; 80 | } 81 | else if ( 82 | req.url == "/api/add-post" && 83 | validateSessionID(req,res) 84 | ) { 85 | let newPostData = JSON.parse(await getStream(req)); 86 | await addPost(newPostData,req,res); 87 | return; 88 | } 89 | 90 | // didn't recognize the API request 91 | res.writeHead(404); 92 | res.end(); 93 | } 94 | // handle all other file requests 95 | else if (["GET","HEAD"].includes(req.method)) { 96 | // special handling for empty favicon 97 | if (req.url == "/favicon.ico") { 98 | res.writeHead(204,{ 99 | "Content-Type": "image/x-icon", 100 | "Cache-Control": "public, max-age: 604800" 101 | }); 102 | res.end(); 103 | return; 104 | } 105 | 106 | // special handling for service-worker (virtual path) 107 | if (/^\/sw\.js(?:[?#].*)?$/.test(req.url)) { 108 | serveFile("/js/sw.js",200,{ "cache-control": "max-age=0", },req,res) 109 | .catch(console.error); 110 | return; 111 | } 112 | 113 | // handle admin pages 114 | if (/^\/(?:add-post)(?:[#?]|$)/.test(req.url)) { 115 | // page not allowed without active session 116 | if (validateSessionID(req,res)) { 117 | await serveFile("/add-post.html",200,{},req,res); 118 | } 119 | // show the login page instead 120 | else { 121 | await serveFile("/login.html",200,{},req,res); 122 | } 123 | return; 124 | } 125 | 126 | // login page when already logged in? 127 | if ( 128 | /^\/(?:login)(?:[#?]|$)/.test(req.url) && 129 | validateSessionID(req,res) 130 | ) { 131 | res.writeHead(307,{ Location: "/add-post", }); 132 | res.end(); 133 | return; 134 | } 135 | 136 | // handle logout 137 | if (/^\/(?:logout)(?:[#?]|$)/.test(req.url)) { 138 | clearSession(req,res); 139 | res.writeHead(307,{ Location: "/", }); 140 | res.end(); 141 | return; 142 | } 143 | 144 | // handle other static files 145 | staticServer.serve(req,res,function onStaticComplete(err){ 146 | if (err) { 147 | if (req.headers["accept"].includes("text/html")) { 148 | serveFile("/404.html",200,{ "X-Not-Found": "1" },req,res) 149 | .catch(console.error); 150 | } 151 | else { 152 | res.writeHead(404); 153 | res.end(); 154 | } 155 | } 156 | }); 157 | } 158 | // Oops, invalid/unrecognized request 159 | else { 160 | res.writeHead(404); 161 | res.end(); 162 | } 163 | } 164 | 165 | function serveFile(url,statusCode,headers,req,res) { 166 | var listener = staticServer.serveFile(url,statusCode,headers,req,res); 167 | return new Promise(function c(resolve,reject){ 168 | listener.on("success",resolve); 169 | listener.on("error",reject); 170 | }); 171 | } 172 | 173 | async function getPostIDs() { 174 | var files = await fsReadDir(path.join(WEB_DIR,"posts")); 175 | return ( 176 | files 177 | .filter(function onlyPosts(filename){ 178 | return /^\d+\.html$/.test(filename); 179 | }) 180 | .map(function postID(filename){ 181 | let [,postID] = filename.match(/^(\d+)\.html$/); 182 | return Number(postID); 183 | }) 184 | .sort(function desc(x,y){ 185 | return y - x; 186 | }) 187 | ); 188 | } 189 | 190 | async function getPosts(req,res) { 191 | var postIDs = await getPostIDs(); 192 | sendJSONResponse(postIDs,res); 193 | } 194 | 195 | async function addPost(newPostData,req,res) { 196 | if ( 197 | newPostData.title.length > 0 && 198 | newPostData.post.length > 0 199 | ) { 200 | let postTemplate = await fsReadFile(path.join(WEB_DIR,"posts","post.html"),"utf-8"); 201 | let newPost = 202 | postTemplate 203 | .replace(/\{\{TITLE\}\}/g,newPostData.title) 204 | .replace(/\{\{POST\}\}/,newPostData.post); 205 | let postIDs = await getPostIDs(); 206 | let newPostCount = 1; 207 | let [,year,month,day] = (new Date()).toISOString().match(/^(\d{4})-(\d{2})-(\d{2})/); 208 | if (postIDs.length > 0) { 209 | let [,latestYear,latestMonth,latestDay,latestCount] = String(postIDs[0]).match(/^(\d{4})(\d{2})(\d{2})(\d+)/); 210 | if ( 211 | latestYear == year && 212 | latestMonth == month && 213 | latestDay == day 214 | ) { 215 | newPostCount = Number(latestCount) + 1; 216 | } 217 | } 218 | let newPostID = `${year}${month}${day}${newPostCount}`; 219 | try { 220 | await fsWriteFile(path.join(WEB_DIR,"posts",`${newPostID}.html`),newPost,"utf8"); 221 | sendJSONResponse({ OK: true, postID: newPostID },res); 222 | return; 223 | } 224 | catch (err) {} 225 | } 226 | 227 | sendJSONResponse({ failed: true },res); 228 | } 229 | 230 | function validateSessionID(req,res) { 231 | if (req.headers.cookie && req.headers.cookie["sessionId"]) { 232 | let isLoggedIn = Number(req.headers.cookie["isLoggedIn"]); 233 | let sessionID = req.headers.cookie["sessionId"]; 234 | let session; 235 | 236 | if ( 237 | isLoggedIn == 1 && 238 | sessions.includes(sessionID) 239 | ) { 240 | req.sessionID = sessionID; 241 | 242 | // update cookie headers 243 | res.setHeader( 244 | "Set-Cookie", 245 | getCookieHeaders(sessionID,new Date(Date.now() + /*1 hour in ms*/3.6E5).toUTCString()) 246 | ); 247 | return true; 248 | } 249 | else { 250 | clearSession(req,res); 251 | } 252 | } 253 | 254 | return false; 255 | } 256 | 257 | async function randomString() { 258 | var chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/"; 259 | var str = ""; 260 | for (let i = 0; i < 20; i++) { 261 | str += chars[ await rand(0,63) ]; 262 | } 263 | return str; 264 | } 265 | 266 | async function createSession() { 267 | var sessionID; 268 | do { 269 | sessionID = await randomString(); 270 | } while (sessions.includes(sessionID)); 271 | sessions.push(sessionID); 272 | return sessionID; 273 | } 274 | 275 | function clearSession(req,res) { 276 | var sessionID = 277 | req.sessionID || 278 | (req.headers.cookie && req.headers.cookie.sessionId); 279 | 280 | if (sessionID) { 281 | sessions = sessions.filter(function removeSession(sID){ 282 | return sID !== sessionID; 283 | }); 284 | } 285 | 286 | res.setHeader("Set-Cookie",getCookieHeaders(null,new Date(0).toUTCString())); 287 | } 288 | 289 | function getCookieHeaders(sessionID,expires = null) { 290 | var cookieHeaders = [ 291 | `sessionId=${sessionID || ""}; HttpOnly; Path=/`, 292 | `isLoggedIn=${sessionID ? "1" : ""}; Path=/`, 293 | ]; 294 | 295 | if (expires != null) { 296 | cookieHeaders = cookieHeaders.map(function addExpires(headerVal){ 297 | return `${headerVal}; Expires=${expires}`; 298 | }); 299 | } 300 | 301 | return cookieHeaders; 302 | } 303 | 304 | async function doLogin(loginData,req,res) { 305 | // WARNING: This is absolutely NOT how you should handle logins, 306 | // having credentials hard-coded. Hash all credentials and store 307 | // them in a secure database. 308 | if (loginData.username == "admin" && loginData.password == "changeme") { 309 | let sessionID = await createSession(); 310 | sendJSONResponse({ OK: true },res,{ 311 | "Set-Cookie": getCookieHeaders( 312 | sessionID, 313 | new Date(Date.now() + /*1 hour in ms*/3.6E5).toUTCString() 314 | ) 315 | }); 316 | } 317 | else { 318 | sendJSONResponse({ failed: true },res); 319 | } 320 | } 321 | 322 | function sendJSONResponse(msg,res,otherHeaders = {}) { 323 | res.writeHead(200,{ 324 | "Content-Type": "application/json", 325 | "Cache-Control": "private, no-cache, no-store, must-revalidate, max-age=0", 326 | ...otherHeaders 327 | }); 328 | res.end(JSON.stringify(msg)); 329 | } 330 | -------------------------------------------------------------------------------- /web/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | My Ramblings :: Not Found 6 | 7 | 8 | 9 |
10 |

My Ramblings

11 | 18 | 19 |
20 | 21 |
22 |

Not Found

23 |

24 | Sorry, that couldn't be found. Please try again. 25 |

26 |
27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /web/about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | My Ramblings :: About 6 | 7 | 8 | 9 |
10 |

My Ramblings

11 | 18 | 19 |
20 | 21 |
22 |

About

23 |

24 | These are just some of my rambling thoughts. I hope they are interesting to some of you. 25 |

26 |

27 | Please feel free to reach out if you have any thoughts to share with me! 28 |

29 |
30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /web/add-post.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | My Ramblings :: Add Post 6 | 7 | 8 | 9 |
10 |

My Ramblings

11 | 19 | 20 |
21 | 22 |
23 |

Add Post

24 |

25 | Title: 26 |

27 |

28 | 29 |

30 |

31 | 32 |

33 |
34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /web/contact.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | My Ramblings :: Contact 6 | 7 | 8 | 9 |
10 |

My Ramblings

11 | 18 | 19 |
20 | 21 |
22 |

Contact

23 |

24 | If you'd like to reach out to me: 25 |

26 | 30 |
31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /web/css/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | font-family: sans-serif; 4 | font-size: 1.3em; 5 | } 6 | 7 | *, 8 | *::before, 9 | *::after { 10 | box-sizing: inherit; 11 | } 12 | 13 | html input, 14 | html button, 15 | html textarea { 16 | font-size: 1em; 17 | } 18 | 19 | html, 20 | body { 21 | background-color: #e5efff; 22 | } 23 | 24 | #connectivity-status { 25 | position: absolute; 26 | top: 5px; 27 | right: 5px; 28 | width: 55px; 29 | height: 49px; 30 | background: url(/images/offline.png) 0px 0px/55px 49px no-repeat; 31 | } 32 | 33 | #connectivity-status.hidden { 34 | display: none; 35 | } 36 | 37 | header { 38 | position: relative; 39 | max-width: 800px; 40 | margin: 0px auto; 41 | color: #222; 42 | } 43 | 44 | header h1 { 45 | position: relative; 46 | padding-left: 90px; 47 | } 48 | 49 | header h1::before { 50 | content: ""; 51 | display: block; 52 | position: absolute; 53 | left: 0px; 54 | top: 0px; 55 | width: 63px; 56 | height: 75px; 57 | background: url(/images/logo.gif) 0px 0px/63px 75px no-repeat; 58 | } 59 | 60 | nav { 61 | background-color: #5078ba; 62 | color: #fff; 63 | } 64 | 65 | nav ul { 66 | padding: 0px; 67 | padding-left: 20px; 68 | margin: 0px; 69 | list-style: none; 70 | } 71 | 72 | nav ul li { 73 | display: inline-block; 74 | padding: 10px; 75 | } 76 | 77 | nav ul li a { 78 | color: #fff; 79 | text-decoration: none; 80 | } 81 | 82 | main { 83 | margin: 0px auto; 84 | max-width: 800px; 85 | background-color: #fff; 86 | color: #000; 87 | padding: 30px; 88 | } 89 | 90 | main a { 91 | color: #000; 92 | } 93 | 94 | main > h1:first-child { 95 | margin-top: 0px; 96 | } 97 | 98 | #my-posts { 99 | list-style: none; 100 | } 101 | 102 | #my-posts > li { 103 | margin-bottom: 12px; 104 | } 105 | 106 | #my-posts > li:last-child { 107 | margin-bottom: 0px; 108 | } 109 | -------------------------------------------------------------------------------- /web/images/logo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FrontendMasters/service-workers-offline/f08e2dd684946d70e67cf2fffad62823c12490d0/web/images/logo.gif -------------------------------------------------------------------------------- /web/images/offline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FrontendMasters/service-workers-offline/f08e2dd684946d70e67cf2fffad62823c12490d0/web/images/offline.png -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | My Ramblings 6 | 7 | 8 | 9 |
10 |

My Ramblings

11 | 18 | 19 |
20 | 21 |
22 |

My Posts:

23 | 26 |
27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /web/js/add-post.js: -------------------------------------------------------------------------------- 1 | (function AddPost(){ 2 | "use strict"; 3 | 4 | var titleInput; 5 | var postInput; 6 | var addPostBtn; 7 | 8 | document.addEventListener("DOMContentLoaded",ready,false); 9 | 10 | 11 | // ********************************** 12 | 13 | async function ready() { 14 | titleInput = document.getElementById("new-title"); 15 | postInput = document.getElementById("new-post"); 16 | addPostBtn = document.getElementById("btn-add-post"); 17 | 18 | addPostBtn.addEventListener("click",addPost,false); 19 | titleInput.addEventListener("change",backupPost,false); 20 | postInput.addEventListener("change",backupPost,false); 21 | 22 | // restore a backup? 23 | var addPostBackup = await idbKeyval.get("add-post-backup"); 24 | if (addPostBackup) { 25 | titleInput.value = addPostBackup.title || ""; 26 | postInput.value = addPostBackup.post || ""; 27 | } 28 | } 29 | 30 | // save backup of post (in case posting fails or offline) 31 | async function backupPost() { 32 | await idbKeyval.set("add-post-backup",{ 33 | title: titleInput.value, 34 | post: postInput.value 35 | }); 36 | } 37 | 38 | async function addPost() { 39 | if ( 40 | titleInput.value.length > 0 && 41 | postInput.value.length > 0 42 | ) { 43 | // don't try posting while offline 44 | if (!isBlogOnline()) { 45 | alert("You seem to be offline currently. Please try posting once you come back online."); 46 | return; 47 | } 48 | 49 | try { 50 | let res = await fetch("/api/add-post",{ 51 | method: "POST", 52 | credentials: "same-origin", 53 | body: JSON.stringify({ 54 | title: titleInput.value, 55 | post: postInput.value 56 | }) 57 | }); 58 | 59 | if (res && res.ok) { 60 | let result = await res.json(); 61 | if (result.OK) { 62 | titleInput.value = ""; 63 | postInput.value = ""; 64 | document.location.href = `/post/${result.postID}`; 65 | return; 66 | } 67 | } 68 | } 69 | catch (err) { 70 | console.error(err); 71 | } 72 | 73 | alert("Posting failed. Try again."); 74 | } 75 | else { 76 | alert("Please enter a title and some blog post content."); 77 | } 78 | } 79 | 80 | })(); 81 | -------------------------------------------------------------------------------- /web/js/blog.js: -------------------------------------------------------------------------------- 1 | (function Blog(global){ 2 | "use strict"; 3 | 4 | var offlineIcon; 5 | var isOnline = ("onLine" in navigator) && navigator.onLine; 6 | var isLoggedIn = /isLoggedIn=1/.test(document.cookie.toString() || ""); 7 | var usingSW = ("serviceWorker" in navigator); 8 | var swRegistration; 9 | var svcworker; 10 | 11 | if (usingSW) { 12 | initServiceWorker().catch(console.error); 13 | } 14 | 15 | global.isBlogOnline = isBlogOnline; 16 | 17 | document.addEventListener("DOMContentLoaded",ready,false); 18 | 19 | 20 | // ********************************** 21 | 22 | function ready() { 23 | offlineIcon = document.getElementById("connectivity-status"); 24 | 25 | if (!isOnline) { 26 | offlineIcon.classList.remove("hidden"); 27 | } 28 | 29 | window.addEventListener("online",function online(){ 30 | offlineIcon.classList.add("hidden"); 31 | isOnline = true; 32 | sendStatusUpdate(); 33 | },false); 34 | window.addEventListener("offline",function offline(){ 35 | offlineIcon.classList.remove("hidden"); 36 | isOnline = false; 37 | sendStatusUpdate(); 38 | },false); 39 | } 40 | 41 | function isBlogOnline() { 42 | return isOnline; 43 | } 44 | 45 | async function initServiceWorker() { 46 | swRegistration = await navigator.serviceWorker.register("/sw.js",{ 47 | updateViaCache: "none", 48 | }); 49 | 50 | svcworker = swRegistration.installing || swRegistration.waiting || swRegistration.active; 51 | sendStatusUpdate(svcworker); 52 | 53 | // listen for new service worker to take over 54 | navigator.serviceWorker.addEventListener("controllerchange",async function onController(){ 55 | svcworker = navigator.serviceWorker.controller; 56 | sendStatusUpdate(svcworker); 57 | }); 58 | 59 | navigator.serviceWorker.addEventListener("message",onSWMessage,false); 60 | } 61 | 62 | function onSWMessage(evt) { 63 | var { data } = evt; 64 | if (data.statusUpdateRequest) { 65 | console.log("Status update requested from service worker, responding..."); 66 | sendStatusUpdate(evt.ports && evt.ports[0]); 67 | } 68 | else if (data == "force-logout") { 69 | document.cookie = "isLoggedIn="; 70 | isLoggedIn = false; 71 | sendStatusUpdate(); 72 | } 73 | } 74 | 75 | function sendStatusUpdate(target) { 76 | sendSWMessage({ statusUpdate: { isOnline, isLoggedIn } },target); 77 | } 78 | 79 | function sendSWMessage(msg,target) { 80 | if (target) { 81 | target.postMessage(msg); 82 | } 83 | else if (svcworker) { 84 | svcworker.postMessage(msg); 85 | } 86 | else if (navigator.serviceWorker.controller) { 87 | navigator.serviceWorker.controller.postMessage(msg); 88 | } 89 | } 90 | 91 | })(window); 92 | -------------------------------------------------------------------------------- /web/js/external/idb-keyval-iife.min.js: -------------------------------------------------------------------------------- 1 | var idbKeyval=function(e){"use strict";class t{constructor(e="keyval-store",t="keyval"){this.storeName=t,this._dbp=new Promise((r,n)=>{const o=indexedDB.open(e,1);o.onerror=(()=>n(o.error)),o.onsuccess=(()=>r(o.result)),o.onupgradeneeded=(()=>{o.result.createObjectStore(t)})})}_withIDBStore(e,t){return this._dbp.then(r=>new Promise((n,o)=>{const s=r.transaction(this.storeName,e);s.oncomplete=(()=>n()),s.onabort=s.onerror=(()=>o(s.error)),t(s.objectStore(this.storeName))}))}}let r;function n(){return r||(r=new t),r}return e.Store=t,e.get=function(e,t=n()){let r;return t._withIDBStore("readonly",t=>{r=t.get(e)}).then(()=>r.result)},e.set=function(e,t,r=n()){return r._withIDBStore("readwrite",r=>{r.put(t,e)})},e.del=function(e,t=n()){return t._withIDBStore("readwrite",t=>{t.delete(e)})},e.clear=function(e=n()){return e._withIDBStore("readwrite",e=>{e.clear()})},e.keys=function(e=n()){const t=[];return e._withIDBStore("readonly",e=>{(e.openKeyCursor||e.openCursor).call(e).onsuccess=function(){this.result&&(t.push(this.result.key),this.result.continue())}}).then(()=>t)},e}({}); -------------------------------------------------------------------------------- /web/js/home.js: -------------------------------------------------------------------------------- 1 | (function Home(){ 2 | "use strict"; 3 | 4 | var postsList; 5 | 6 | document.addEventListener("DOMContentLoaded",ready,false); 7 | 8 | 9 | // ********************************** 10 | 11 | function ready() { 12 | postsList = document.getElementById("my-posts"); 13 | main().catch(console.error); 14 | } 15 | 16 | async function main() { 17 | var postIDs; 18 | 19 | try { 20 | var res = await fetch("/api/get-posts"); 21 | if (res && res.ok) { 22 | postIDs = await res.json(); 23 | } 24 | } 25 | catch (err) {} 26 | 27 | renderPostIDs(postIDs || []); 28 | } 29 | 30 | function renderPostIDs(postIDs) { 31 | if (postIDs.length > 0) { 32 | postsList.innerHTML = ""; 33 | for (let postID of postIDs) { 34 | let [,year,month,day,postNum] = String(postID).match(/^(\d{4})(\d{2})(\d{2})(\d+)$/); 35 | let postEntry = document.createElement("li"); 36 | postEntry.innerHTML = `Post-${+month}/${+day}/${year}-${postNum}`; 37 | postsList.appendChild(postEntry); 38 | } 39 | } 40 | else { 41 | postsList.innerHTML = "
  • -- nothing yet, check back soon! --
  • "; 42 | } 43 | } 44 | 45 | })(); 46 | -------------------------------------------------------------------------------- /web/js/login.js: -------------------------------------------------------------------------------- 1 | (function Login(){ 2 | "use strict"; 3 | 4 | var usernameInput; 5 | var passwordInput; 6 | var loginBtn; 7 | 8 | document.addEventListener("DOMContentLoaded",ready,false); 9 | 10 | 11 | // ********************************** 12 | 13 | function ready() { 14 | usernameInput = document.getElementById("login-username"); 15 | passwordInput = document.getElementById("login-password"); 16 | loginBtn = document.getElementById("btn-login"); 17 | 18 | loginBtn.addEventListener("click",tryLogin,false); 19 | } 20 | 21 | async function tryLogin() { 22 | if ( 23 | usernameInput.value.length > 3 && 24 | passwordInput.value.length > 7 25 | ) { 26 | try { 27 | let res = await fetch("/api/login",{ 28 | method: "POST", 29 | credentials: "same-origin", 30 | body: JSON.stringify({ 31 | username: usernameInput.value, 32 | password: passwordInput.value 33 | }) 34 | }); 35 | 36 | if (res && res.ok) { 37 | let result = await res.json(); 38 | usernameInput.value = ""; 39 | passwordInput.value = ""; 40 | if (result.OK) { 41 | if (document.location.href == "/add-post") { 42 | document.location.reload(); 43 | } 44 | else { 45 | document.location.href = "/add-post"; 46 | } 47 | return; 48 | } 49 | } 50 | } 51 | catch (err) { 52 | console.error(err); 53 | } 54 | 55 | alert("Login failed. Try again."); 56 | } 57 | else { 58 | alert("Please enter a sufficient username and password."); 59 | } 60 | } 61 | 62 | })(); 63 | -------------------------------------------------------------------------------- /web/js/sw.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | importScripts("/js/external/idb-keyval-iife.min.js"); 4 | 5 | var version = 8; 6 | var isOnline = true; 7 | var isLoggedIn = false; 8 | var cacheName = `ramblings-${version}`; 9 | var allPostsCaching = false; 10 | 11 | var urlsToCache = { 12 | loggedOut: [ 13 | "/", 14 | "/about", 15 | "/contact", 16 | "/404", 17 | "/login", 18 | "/offline", 19 | "/css/style.css", 20 | "/js/blog.js", 21 | "/js/home.js", 22 | "/js/login.js", 23 | "/js/add-post.js", 24 | "/js/external/idb-keyval-iife.min.js", 25 | "/images/logo.gif", 26 | "/images/offline.png" 27 | ] 28 | }; 29 | 30 | self.addEventListener("install",onInstall); 31 | self.addEventListener("activate",onActivate); 32 | self.addEventListener("message",onMessage); 33 | self.addEventListener("fetch",onFetch); 34 | 35 | main().catch(console.error); 36 | 37 | 38 | // **************************** 39 | 40 | async function main() { 41 | await sendMessage({ statusUpdateRequest: true }); 42 | await cacheLoggedOutFiles(); 43 | return cacheAllPosts(); 44 | } 45 | 46 | function onInstall(evt) { 47 | console.log(`Service Worker (v${version}) installed`); 48 | self.skipWaiting(); 49 | } 50 | 51 | function onActivate(evt) { 52 | evt.waitUntil(handleActivation()); 53 | } 54 | 55 | async function handleActivation() { 56 | await clearCaches(); 57 | await cacheLoggedOutFiles(/*forceReload=*/true); 58 | await clients.claim(); 59 | console.log(`Service Worker (v${version}) activated`); 60 | 61 | // spin off background caching of all past posts (over time) 62 | cacheAllPosts(/*forceReload=*/true).catch(console.error); 63 | } 64 | 65 | async function clearCaches() { 66 | var cacheNames = await caches.keys(); 67 | var oldCacheNames = cacheNames.filter(function matchOldCache(cacheName){ 68 | var [,cacheNameVersion] = cacheName.match(/^ramblings-(\d+)$/) || []; 69 | cacheNameVersion = cacheNameVersion != null ? Number(cacheNameVersion) : cacheNameVersion; 70 | return ( 71 | cacheNameVersion > 0 && 72 | version !== cacheNameVersion 73 | ); 74 | }); 75 | await Promise.all( 76 | oldCacheNames.map(function deleteCache(cacheName){ 77 | return caches.delete(cacheName); 78 | }) 79 | ); 80 | } 81 | 82 | async function cacheLoggedOutFiles(forceReload = false) { 83 | var cache = await caches.open(cacheName); 84 | 85 | return Promise.all( 86 | urlsToCache.loggedOut.map(async function requestFile(url){ 87 | try { 88 | let res; 89 | 90 | if (!forceReload) { 91 | res = await cache.match(url); 92 | if (res) { 93 | return; 94 | } 95 | } 96 | 97 | let fetchOptions = { 98 | method: "GET", 99 | cache: "no-store", 100 | credentials: "omit" 101 | }; 102 | res = await fetch(url,fetchOptions); 103 | if (res.ok) { 104 | return cache.put(url,res); 105 | } 106 | } 107 | catch (err) {} 108 | }) 109 | ); 110 | } 111 | 112 | async function cacheAllPosts(forceReload = false) { 113 | // already caching the posts? 114 | if (allPostsCaching) { 115 | return; 116 | } 117 | allPostsCaching = true; 118 | await delay(5000); 119 | 120 | var cache = await caches.open(cacheName); 121 | var postIDs; 122 | 123 | try { 124 | if (isOnline) { 125 | let fetchOptions = { 126 | method: "GET", 127 | cache: "no-store", 128 | credentials: "omit" 129 | }; 130 | let res = await fetch("/api/get-posts",fetchOptions); 131 | if (res && res.ok) { 132 | await cache.put("/api/get-posts",res.clone()); 133 | postIDs = await res.json(); 134 | } 135 | } 136 | else { 137 | let res = await cache.match("/api/get-posts"); 138 | if (res) { 139 | let resCopy = res.clone(); 140 | postIDs = await res.json(); 141 | } 142 | // caching not started, try to start again (later) 143 | else { 144 | allPostsCaching = false; 145 | return cacheAllPosts(forceReload); 146 | } 147 | } 148 | } 149 | catch (err) { 150 | console.error(err); 151 | } 152 | 153 | if (postIDs && postIDs.length > 0) { 154 | return cachePost(postIDs.shift()); 155 | } 156 | else { 157 | allPostsCaching = false; 158 | } 159 | 160 | 161 | // ************************* 162 | 163 | async function cachePost(postID) { 164 | var postURL = `/post/${postID}`; 165 | var needCaching = true; 166 | 167 | if (!forceReload) { 168 | let res = await cache.match(postURL); 169 | if (res) { 170 | needCaching = false; 171 | } 172 | } 173 | 174 | if (needCaching) { 175 | await delay(10000); 176 | if (isOnline) { 177 | try { 178 | let fetchOptions = { 179 | method: "GET", 180 | cache: "no-store", 181 | credentials: "omit" 182 | }; 183 | let res = await fetch(postURL,fetchOptions); 184 | if (res && res.ok) { 185 | await cache.put(postURL,res.clone()); 186 | needCaching = false; 187 | } 188 | } 189 | catch (err) {} 190 | } 191 | 192 | // failed, try caching this post again? 193 | if (needCaching) { 194 | return cachePost(postID); 195 | } 196 | } 197 | 198 | // any more posts to cache? 199 | if (postIDs.length > 0) { 200 | return cachePost(postIDs.shift()); 201 | } 202 | else { 203 | allPostsCaching = false; 204 | } 205 | } 206 | } 207 | 208 | async function sendMessage(msg) { 209 | var allClients = await clients.matchAll({ includeUncontrolled: true, }); 210 | return Promise.all( 211 | allClients.map(function sendTo(client){ 212 | var chan = new MessageChannel(); 213 | chan.port1.onmessage = onMessage; 214 | return client.postMessage(msg,[chan.port2]); 215 | }) 216 | ); 217 | } 218 | 219 | function onMessage({ data }) { 220 | if ("statusUpdate" in data) { 221 | ({ isOnline, isLoggedIn } = data.statusUpdate); 222 | console.log(`Service Worker (v${version}) status update... isOnline:${isOnline}, isLoggedIn:${isLoggedIn}`); 223 | } 224 | } 225 | 226 | function onFetch(evt) { 227 | evt.respondWith(router(evt.request)); 228 | } 229 | 230 | async function router(req) { 231 | var url = new URL(req.url); 232 | var reqURL = url.pathname; 233 | var cache = await caches.open(cacheName); 234 | 235 | // request for site's own URL? 236 | if (url.origin == location.origin) { 237 | // are we making an API request? 238 | if (/^\/api\/.+$/.test(reqURL)) { 239 | let fetchOptions = { 240 | credentials: "same-origin", 241 | cache: "no-store" 242 | }; 243 | let res = await safeRequest(reqURL,req,fetchOptions,/*cacheResponse=*/false,/*checkCacheFirst=*/false,/*checkCacheLast=*/true,/*useRequestDirectly=*/true); 244 | if (res) { 245 | if (req.method == "GET") { 246 | await cache.put(reqURL,res.clone()); 247 | } 248 | // clear offline-backup of successful post? 249 | else if (reqURL == "/api/add-post") { 250 | await idbKeyval.del("add-post-backup"); 251 | } 252 | return res; 253 | } 254 | 255 | return notFoundResponse(); 256 | } 257 | // are we requesting a page? 258 | else if (req.headers.get("Accept").includes("text/html")) { 259 | // login-aware requests? 260 | if (/^\/(?:login|logout|add-post)$/.test(reqURL)) { 261 | let res; 262 | 263 | if (reqURL == "/login") { 264 | if (isOnline) { 265 | let fetchOptions = { 266 | method: req.method, 267 | headers: req.headers, 268 | credentials: "same-origin", 269 | cache: "no-store", 270 | redirect: "manual" 271 | }; 272 | res = await safeRequest(reqURL,req,fetchOptions); 273 | if (res) { 274 | if (res.type == "opaqueredirect") { 275 | return Response.redirect("/add-post",307); 276 | } 277 | return res; 278 | } 279 | if (isLoggedIn) { 280 | return Response.redirect("/add-post",307); 281 | } 282 | res = await cache.match("/login"); 283 | if (res) { 284 | return res; 285 | } 286 | return Response.redirect("/",307); 287 | } 288 | else if (isLoggedIn) { 289 | return Response.redirect("/add-post",307); 290 | } 291 | else { 292 | res = await cache.match("/login"); 293 | if (res) { 294 | return res; 295 | } 296 | return cache.match("/offline"); 297 | } 298 | } 299 | else if (reqURL == "/logout") { 300 | if (isOnline) { 301 | let fetchOptions = { 302 | method: req.method, 303 | headers: req.headers, 304 | credentials: "same-origin", 305 | cache: "no-store", 306 | redirect: "manual" 307 | }; 308 | res = await safeRequest(reqURL,req,fetchOptions); 309 | if (res) { 310 | if (res.type == "opaqueredirect") { 311 | return Response.redirect("/",307); 312 | } 313 | return res; 314 | } 315 | if (isLoggedIn) { 316 | isLoggedIn = false; 317 | await sendMessage("force-logout"); 318 | await delay(100); 319 | } 320 | return Response.redirect("/",307); 321 | } 322 | else if (isLoggedIn) { 323 | isLoggedIn = false; 324 | await sendMessage("force-logout"); 325 | await delay(100); 326 | return Response.redirect("/",307); 327 | } 328 | else { 329 | return Response.redirect("/",307); 330 | } 331 | } 332 | else if (reqURL == "/add-post") { 333 | if (isOnline) { 334 | let fetchOptions = { 335 | method: req.method, 336 | headers: req.headers, 337 | credentials: "same-origin", 338 | cache: "no-store" 339 | }; 340 | res = await safeRequest(reqURL,req,fetchOptions,/*cacheResponse=*/true); 341 | if (res) { 342 | return res; 343 | } 344 | res = await cache.match( 345 | isLoggedIn ? "/add-post" : "/login" 346 | ); 347 | if (res) { 348 | return res; 349 | } 350 | return Response.redirect("/",307); 351 | } 352 | else if (isLoggedIn) { 353 | res = await cache.match("/add-post"); 354 | if (res) { 355 | return res; 356 | } 357 | return cache.match("/offline"); 358 | } 359 | else { 360 | res = await cache.match("/login"); 361 | if (res) { 362 | return res; 363 | } 364 | return cache.match("/offline"); 365 | } 366 | } 367 | } 368 | // otherwise, just use "network-and-cache" 369 | else { 370 | let fetchOptions = { 371 | method: req.method, 372 | headers: req.headers, 373 | cache: "no-store" 374 | }; 375 | let res = await safeRequest(reqURL,req,fetchOptions,/*cacheResponse=*/false,/*checkCacheFirst=*/false,/*checkCacheLast=*/true); 376 | if (res) { 377 | if (!res.headers.get("X-Not-Found")) { 378 | await cache.put(reqURL,res.clone()); 379 | } 380 | else { 381 | await cache.delete(reqURL); 382 | } 383 | return res; 384 | } 385 | 386 | // otherwise, return an offline-friendly page 387 | return cache.match("/offline"); 388 | } 389 | } 390 | // all other files use "cache-first" 391 | else { 392 | let fetchOptions = { 393 | method: req.method, 394 | headers: req.headers, 395 | cache: "no-store" 396 | }; 397 | let res = await safeRequest(reqURL,req,fetchOptions,/*cacheResponse=*/true,/*checkCacheFirst=*/true); 398 | if (res) { 399 | return res; 400 | } 401 | 402 | // otherwise, force a network-level 404 response 403 | return notFoundResponse(); 404 | } 405 | } 406 | } 407 | 408 | async function safeRequest(reqURL,req,options,cacheResponse = false,checkCacheFirst = false,checkCacheLast = false,useRequestDirectly = false) { 409 | var cache = await caches.open(cacheName); 410 | var res; 411 | 412 | if (checkCacheFirst) { 413 | res = await cache.match(reqURL); 414 | if (res) { 415 | return res; 416 | } 417 | } 418 | 419 | if (isOnline) { 420 | try { 421 | if (useRequestDirectly) { 422 | res = await fetch(req,options); 423 | } 424 | else { 425 | res = await fetch(req.url,options); 426 | } 427 | 428 | if (res && (res.ok || res.type == "opaqueredirect")) { 429 | if (cacheResponse) { 430 | await cache.put(reqURL,res.clone()); 431 | } 432 | return res; 433 | } 434 | } 435 | catch (err) {} 436 | } 437 | 438 | if (checkCacheLast) { 439 | res = await cache.match(reqURL); 440 | if (res) { 441 | return res; 442 | } 443 | } 444 | } 445 | 446 | function notFoundResponse() { 447 | return new Response("",{ 448 | status: 404, 449 | statusText: "Not Found" 450 | }); 451 | } 452 | 453 | function delay(ms) { 454 | return new Promise(function c(res){ 455 | setTimeout(res,ms); 456 | }); 457 | } 458 | -------------------------------------------------------------------------------- /web/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | My Ramblings :: Add Post 6 | 7 | 8 | 9 |
    10 |

    My Ramblings

    11 | 18 | 19 |
    20 | 21 |
    22 |

    Login

    23 |

    24 | Username: 25 |

    26 |

    27 | Password: 28 |

    29 |

    30 | 31 |

    32 |
    33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /web/offline.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | My Ramblings :: Offline! 6 | 7 | 8 | 9 |
    10 |

    My Ramblings

    11 | 18 | 19 |
    20 | 21 |
    22 |

    Offline

    23 |

    24 | It looks like you're offline and the page you requested couldn't be loaded. Please try again once you're back online. 25 |

    26 |
    27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /web/posts/post.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | My Ramblings :: {{TITLE}} 6 | 7 | 8 | 9 |
    10 |

    My Ramblings

    11 | 18 | 19 |
    20 | 21 |
    22 |

    {{TITLE}}

    23 | 24 | {{POST}} 25 | 26 |
    27 | 28 | 29 | 30 | 31 | --------------------------------------------------------------------------------