├── .gitignore ├── LICENSE ├── README.md ├── package.json └── tulo.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | sample_client/node_modules/ 44 | 45 | # TypeScript v1 declaration files 46 | typings/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | 79 | # Next.js build output 80 | .next 81 | 82 | # Nuxt.js build / generate output 83 | .nuxt 84 | dist 85 | 86 | # Gatsby files 87 | .cache/ 88 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 89 | # https://nextjs.org/blog/next-9-1#public-directory-support 90 | # public 91 | 92 | # vuepress build output 93 | .vuepress/dist 94 | 95 | # Serverless directories 96 | .serverless/ 97 | 98 | # FuseBox cache 99 | .fusebox/ 100 | 101 | # DynamoDB Local files 102 | .dynamodb/ 103 | 104 | # TernJS port file 105 | .tern-port 106 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 OSLabs Beta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tulo.js 2 | 3 | _Making service workers easy to use so that your app can be fast and reliable, even offline._ 4 | 5 | Welcome to **tulo.js**, a service worker library that allows you to implement caching strategies via the powerful [Service Worker browser API](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) to make your website more robust. 6 | 7 | The current version of tulo.js supports the following functionality: 8 | 9 | - Configure caching strategies for different files (markup, stylesheets, images, fonts, etc.) based on your business needs 10 | - Sign in to **[tulojs.com](https://tulojs.com)** to monitor caching activity from your deployed website for each resource/file including average load times, resource size, and user connection types (e.g. 4G, 2G, Offline) 11 | 12 | Thanks for checking out our library! Please let us know of any feature requests or bugs by raising a GitHub issue. 13 | 14 | ## Getting Started 15 | 16 | ### Installation 17 | 18 | 1) Run ```npm i tulo-js``` in your project's root directory to install the tulo-js npm package. 19 | 20 | ### Add a service worker 21 | 22 | 2) Run ```touch service-worker.js``` in your project's `public/` directory (or wherever you store static assets) to create the service worker file. You could call this file `sw.js` (or whatever you like) if you prefer a shorter name. 23 | 24 | 3) If you are using Express.js to serve your front-end, create an endpoint to respond to GET requests to `/tulo` that sends `node_modules/tulo-js/tulo.js` as a response. Otherwise, adjust your import statement in the next step to `node_modules/tulo-js/tulo.js` in the next step instead of `/tulo` (see below). 25 | 26 | 4) At the top of `service-worker.js`, import the tulo library: 27 | 28 | ```js 29 | // Use the below import statement if you set up an Express endpoint 30 | import { cacheGenerator } from '/tulo'; 31 | // Otherwise, import the library from node_modules 32 | import { cacheGenerator } from 'node_modules/tulo-js/tulo.js'; 33 | ``` 34 | 35 | If you are having trouble importing tulo-js from node_modules, run the command below in your terminal from the root directory to copy the library functionality into your client-side code. To learn more about service worker imports, check out [Jeff Posnick's article on limitations of ES Module imports in service workers](https://web.dev/es-modules-in-sw/). 36 | 37 | ```js 38 | cp ./node_modules/tulo-js/tulo.js ./public 39 | ``` 40 | 41 | 5) Add a version number to `service-worker.js`. Remember to update this version number whenever you make updates to this file. This will ensure that a new service worker is installed then activated and your caches are automatically refreshed when you update your caching strategy. 42 | 43 | ```js 44 | const version = 1.0; // update version number when you change this file to register changes 45 | ``` 46 | 47 | 6) Develop a caching strategy for each of your website's resources (i.e. pages, stylesheets, images, logos, fonts, icons, audio/video, etc.). For example, you might want your pages to be requested fresh from the network whenever possible, so your caching strategy would be `NetworkFirst`. A `NetworkFirst` strategy will retrieve the resource from the network and add it to the cache. If the network fails or the server is down on a subsequent request, the resource will be served from the cache as a fallback. That way, if your users go offline, they can still access your pages from the cache if it has been populated on previous requests. That is the magic of service workers! Here are the caching strategies currently supported by tulo.js: 48 | 49 | - `NetworkFirst`: Requests resource from the network, serves response to user, and adds resource to the specified cache. If the network request fails – either due to a faulty/offline connection or a server error – the service worker will check the cache for that resource and serve it to the client if found 50 | - `CacheFirst`: Checks caches to see if the requested resource has already been cached, and serves it to the client if so. Otherwise, requests resource from the network and stores it in the specified cache 51 | - `NetworkOnly`: Requests resource from the network and serves response to user. If the network request fails, a message is sent in response that the resource could not be found 52 | 53 | 7) For each unique caching strategy (e.g. a caching strategy for images), write a cache specification in `service-worker.js`. Sample code for caching your images is provided below. See step 8 for a boilerplate cache spec you can copy and paste into your `service-worker.js` file. 54 | 55 | ```js 56 | const imageCacheSpec = { 57 | name: 'imageCache' + version, 58 | types: ['image'], 59 | urls: ['/logo.png', '/icon.png', 'banner.png'], 60 | strategy: 'CacheFirst', 61 | expiration: 60*60*1000 62 | }; 63 | ``` 64 | 65 | 8) Here is a boilerplate cache spec you can copy and paste in your file: 66 | 67 | ```js 68 | const sampleCacheSpec = { 69 | name: 'sampleCache' + version, // give your cache a name, concatenated to the version so you can verify your cache is up-to-date in the browser 70 | types: [], // input HTML MIME types e.g. text/html, text/css, image/gif, etc. 71 | urls: [], // input any file paths to be cached using this cacheSpec 72 | strategy: '', // currently supported strategies are: CacheFirst, NetworkFirst, NetworkOnly 73 | expiration: 60*60*1000 // in milliseconds - this field is OPTIONAL - if omitted, these urls will be refreshed when the service worker restarts 74 | } 75 | ``` 76 | 77 | 9) At the bottom of `service-worker.js`, add all your cache specifications into an array, and pass it as an argument to the `cacheGenerator` function. 78 | 79 | ```js 80 | // If you have multiple cacheSpecs for different file types, include your page/markup caches first followed by images, stylesheets, fonts, etc. 81 | cacheGenerator([pagesCacheSpec, imageCacheSpec, stylesCacheSpec, fontCacheSpec]); 82 | ``` 83 | 84 | ### Register Service Worker 85 | 86 | 10) In your project's root file, add the below code snippet to register your service worker. If you are running a React app, this would be in your top-level component (i.e. `App.jsx` or `index.jsx`). If you are creating a project with static HTML pages, add this snippet in your root HTML file (i.e. `index.html`) at the bottom of your body tag within an opening `` tag. 87 | 88 | ```js 89 | if (navigator.serviceWorker) { 90 | await navigator.serviceWorker.register('service-worker.js', { 91 | type: 'module', 92 | scope: '/' 93 | }) 94 | // To ensure your service worker registers properly, chain then/catch below - feel free to remove once it is successfully registering 95 | .then((registration) => console.log(`Service worker registered in scope: ${registration.scope}`)) 96 | .catch((e) => console.log(`Service worker registration failed: ${e}`)); 97 | } 98 | ``` 99 | 100 | ### Check your service worker and caches in DevTools 101 | 102 | 11) Serve your application. Open up Google Chrome and navigate to your website. Open up your Chrome DevTools by clicking inspect (or entering cmd+option+I on Mac, ctrl+shift+I on Windows). Navigate to the Application panel and click Service Worker on the sidebar. You should see a new service worker installed and activated. 103 | 104 | 12) Click on Cache Storage in the Application panel sidebar under Cache. Here you should be able to see each cache you created in `service-worker.js` and the files stored in them. 105 | 106 | ### Sign in on tulojs.com for monitoring and insights 107 | 108 | 13) Visit [tulojs.com/dashboard](https://www.tulojs.com/dashboard) to monitor your caching strategies in production. You'll be able to view the caching strategies you implemented on a per resource basis, including statistics on cache events and your users. For example, what percentage of the time is your site's logo image being fetched from the cache versus the network? What is the difference in average load time when it is fetched from the cache versus the network? What percentage of your users are accessing your `about` page when their connection is offline? 109 | 110 | ## Notes & Resources 111 | 112 | - Service Workers only work with HTTPS (localhost is an exception) 113 | - [web.dev](https://web.dev/) has many fantastic articles on service workers, caching, and more – check out the [overview on workers](https://web.dev/workers-overview/) to get started 114 | - [Workbox](https://developers.google.com/web/tools/workbox) is a robust library for service worker implementation if you are interested in diving deeper on caching possibilities (it served as an inspiration for making tulo.js as a lightweight library with monitoring insights) 115 | - [serviceworke.rs](https://serviceworke.rs/) is a great website with a cookbook for service workers if you want to get your hands dirty building from scratch 116 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tulo-js", 3 | "version": "1.0.1", 4 | "description": "Making service workers easy to use so that your app can be fast, reliable, and even offline.", 5 | "main": "tulo.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/oslabs-beta/tulo-js.git" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "bugs": { 17 | "url": "https://github.com/oslabs-beta/tulo-js/issues" 18 | }, 19 | "homepage": "https://github.com/oslabs-beta/tulo-js#readme" 20 | } 21 | -------------------------------------------------------------------------------- /tulo.js: -------------------------------------------------------------------------------- 1 | export const cacheGenerator = (cacheSpecs, batchSize) => { 2 | let expirations = {}; 3 | const METRICS_BATCH_SIZE = batchSize ?? 10; 4 | 5 | //simple mutex 6 | let isLocked = false; 7 | const lock = () => (isLocked = true); 8 | const unLock = () => (isLocked = false); 9 | 10 | const sendMetrics = async (metrics) => { 11 | if (isLocked) 12 | return setTimeout(async () => await sendMetrics(metrics), 1000); 13 | lock(); 14 | const metricsCache = await caches.open('metrics'); 15 | metrics.connection = navigator.onLine 16 | ? navigator.connection.effectiveType 17 | : 'offline'; 18 | metrics.device = navigator.userAgent; 19 | metricsCache.put( 20 | `/${metrics.url}_${metrics.timestamp}`, 21 | new Response(JSON.stringify(metrics)) 22 | ); 23 | const cacheSize = (await metricsCache.keys()).length; 24 | if (navigator.onLine && cacheSize >= METRICS_BATCH_SIZE) { 25 | const metricsQueue = []; 26 | let sentToServer = false; 27 | try { 28 | for (const request of await metricsCache.keys()) { 29 | const response = await metricsCache.match(request); 30 | metricsQueue.push(await response.json()); 31 | } 32 | //sends to server 33 | const res = await fetch('https://www.tulojs.com/api/metrics', { 34 | method: 'POST', 35 | headers: { 36 | 'Content-Type': 'application/json', 37 | }, 38 | body: JSON.stringify(metricsQueue), 39 | }); 40 | 41 | console.log(res ? '' : '');//console logging a empty character to avoid a strange issue when fetch seems to hang forever 42 | sentToServer = true; 43 | } catch (err) { 44 | console.error('Sending to Server Failed', err); 45 | sentToServer = false; 46 | } finally { 47 | const cacheSize = (await metricsCache.keys()).length; 48 | if (sentToServer) { 49 | for (const request of await metricsCache.keys()) { 50 | await metricsCache.delete(request); 51 | } 52 | } 53 | } 54 | } 55 | unLock(); 56 | }; 57 | 58 | const setUpCache = () => { 59 | try { 60 | return cacheSpecs.forEach(async (spec) => { 61 | const cache = await caches.open(spec.name); 62 | if (spec.expiration) 63 | expirations[spec.name] = spec.expiration + Date.now(); 64 | return cache.addAll(spec.urls); 65 | }); 66 | } catch (err) { 67 | console.error('Error opening cache'); 68 | } 69 | }; 70 | 71 | self.addEventListener('install', (e) => { 72 | skipWaiting(); 73 | e.waitUntil(setUpCache()); 74 | }); 75 | 76 | const deleteOldCaches = async () => { 77 | const cacheNames = await caches.keys(); 78 | for (const cacheName of cacheNames) { 79 | let found = false; 80 | for (const spec of cacheSpecs) { 81 | if (cacheName === spec.name) { 82 | found = true; 83 | break; 84 | } 85 | } 86 | 87 | if (!found) caches.delete(cacheName); 88 | } 89 | }; 90 | 91 | self.addEventListener('activate', (e) => { 92 | e.waitUntil(deleteOldCaches().then(() => clients.claim())); 93 | }); 94 | 95 | const getSpec = (request) => { 96 | for (const spec of cacheSpecs) { 97 | const types = spec.types; 98 | for (let type of types) { //match request first on type 99 | if (request.headers.get('Accept').includes(type)) { 100 | const urls = spec.urls; 101 | for(let url of urls){ //if a matching type was found, match based on filename 102 | if(request.url.includes(url)){ 103 | return spec; 104 | } 105 | } 106 | } 107 | } 108 | } 109 | }; 110 | 111 | const grabFromCache = async (e, spec, comment) => { 112 | const { request } = e; 113 | const { name } = spec; 114 | 115 | if (expirations[name] && Date.now() > expirations[name]) { 116 | //cache expired 117 | expirations[name] = spec.expiration + Date.now(); 118 | try { 119 | const responseFromNetwork = await grabFromNetwork( 120 | e, 121 | spec, 122 | 'Cache Expired' 123 | ); 124 | e.waitUntil(addToCache(request, responseFromNetwork.clone(), name)); 125 | return responseFromNetwork; 126 | } catch (err) { 127 | return noMatch(); 128 | } 129 | } 130 | 131 | const start = performance.now(); 132 | const cache = await caches.open(name); 133 | const response = await cache.match(request); 134 | const end = performance.now(); 135 | if (response) { 136 | sendMetrics({ 137 | strategy: spec.strategy, 138 | url: request.url, 139 | message: (comment ? comment : '') + ':Found in Cache', 140 | size: response.headers.get('content-length'), 141 | loadtime: end - start, 142 | timestamp: Date.now(), 143 | }); 144 | return response; 145 | } 146 | }; 147 | 148 | const grabFromNetwork = async (e, spec, comment) => { 149 | const { request } = e; 150 | const start = performance.now(); 151 | const response = await fetch(request); 152 | const end = performance.now(); 153 | 154 | if (spec) { //Send Metrics to tulo only if there is a matching cache spec 155 | sendMetrics({ 156 | strategy: spec.strategy, 157 | url: request.url, 158 | message: (comment ? comment : '') + ':Found in Network', 159 | size: response.headers.get('content-length'), 160 | loadtime: end - start, 161 | timestamp: Date.now(), 162 | }); 163 | } 164 | 165 | return response; 166 | }; 167 | 168 | const addToCache = async (request, response, cacheName) => { 169 | const cache = await caches.open(cacheName); 170 | return cache.put(request, response); 171 | }; 172 | 173 | const noMatch = () => { 174 | const response = new Response('No Match Found'); 175 | return response; 176 | }; 177 | 178 | const networkOnly = async (e, spec) => { 179 | try { 180 | return await grabFromNetwork(e, spec); 181 | } catch (err) { 182 | return noMatch(); 183 | } 184 | }; 185 | 186 | const cacheOnly = async (e, spec) => { 187 | return (await grabFromCache(e, spec)) ?? noMatch(); 188 | }; 189 | 190 | const cacheFirst = async (e, spec) => { 191 | try { 192 | let response = await grabFromCache(e, spec); 193 | if(!response){ 194 | response = await grabFromNetwork(e, spec, 'Not Found in Cache'); 195 | e.waitUntil(addToCache(e.request, response.clone(), spec.name)); 196 | } 197 | return response; 198 | } catch (err) { 199 | return noMatch(); 200 | } 201 | }; 202 | 203 | const networkFirst = async (e, spec) => { 204 | try { 205 | const response = await grabFromNetwork(e, spec); 206 | e.waitUntil(addToCache(e.request, response.clone(), spec.name)); 207 | return response; 208 | } catch (err) { 209 | return ( 210 | (await grabFromCache(e, spec, 'Not Found in Network')) ?? noMatch() 211 | ); 212 | } 213 | }; 214 | 215 | const runStrategy = async (e) => { 216 | const { request } = e; 217 | const spec = getSpec(request); 218 | if (!spec) { 219 | //no cache found for resource 220 | return await networkOnly(e); 221 | } 222 | 223 | const { strategy } = spec; 224 | switch (strategy) { 225 | case 'CacheFirst': 226 | return await cacheFirst(e, spec); 227 | case 'NetworkFirst': 228 | return await networkFirst(e, spec); 229 | case 'CacheOnly': 230 | return await cacheOnly(e, spec); 231 | case 'NetworkOnly': 232 | return await networkOnly(e, spec); 233 | default: 234 | console.error( 235 | `${strategy} for ${request.url} does not exist - sending to network` 236 | ); 237 | return await networkOnly(e); 238 | } 239 | }; 240 | 241 | self.addEventListener('fetch', (e) => { 242 | e.respondWith(runStrategy(e)); 243 | }); 244 | }; 245 | --------------------------------------------------------------------------------