├── .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 |
--------------------------------------------------------------------------------