├── packages ├── generate-service-worker │ ├── __tests__ │ │ ├── mockTemplate │ │ └── index.js │ ├── utils │ │ ├── hash.js │ │ ├── validators.js │ │ ├── validate.js │ │ └── __tests__ │ │ │ └── validate.js │ ├── package.json │ ├── templates │ │ ├── main.js │ │ ├── __tests__ │ │ │ ├── notifications.js │ │ │ └── cache.js │ │ ├── notifications.js │ │ └── cache.js │ ├── LICENSE │ ├── index.js │ └── README.md ├── service-worker-mock │ ├── models │ │ ├── DOMException.js │ │ ├── BroadcastChannel.js │ │ ├── PushEvent.js │ │ ├── MessageChannel.js │ │ ├── FileReader.js │ │ ├── NotificationEvent.js │ │ ├── Event.js │ │ ├── MessageEvent.js │ │ ├── SyncManager.js │ │ ├── ExtendableMessageEvent.js │ │ ├── WindowClient.js │ │ ├── PushManager.js │ │ ├── PushSubscription.js │ │ ├── SyncEvent.js │ │ ├── NavigationPreloadManager.js │ │ ├── Notification.js │ │ ├── FetchEvent.js │ │ ├── Body.js │ │ ├── Client.js │ │ ├── Clients.js │ │ ├── Blob.js │ │ ├── EventTarget.js │ │ ├── CacheStorage.js │ │ ├── MessagePort.js │ │ ├── ServiceWorkerRegistration.js │ │ ├── Headers.js │ │ ├── ExtendableEvent.js │ │ ├── Response.js │ │ ├── Request.js │ │ └── Cache.js │ ├── utils │ │ ├── generateRandomId.js │ │ └── eventHandler.js │ ├── fetch.js │ ├── __tests__ │ │ ├── trigger.js │ │ ├── ServiceWorkerRegistration.js │ │ ├── MessageEvent.js │ │ ├── navigator.js │ │ ├── NotificationEvent.js │ │ ├── IDB.js │ │ ├── FetchEvent.js │ │ ├── fixtures │ │ │ └── basic.js │ │ ├── Response.js │ │ ├── Cache.js │ │ ├── basic.js │ │ ├── Headers.js │ │ ├── Clients.js │ │ ├── ServiceWorkerGlobalScope.js │ │ └── Request.js │ ├── package.json │ ├── LICENSE │ ├── yarn.lock │ ├── README.md │ └── index.js └── service-worker-plugin │ ├── package.json │ ├── utils │ ├── mapPrecacheAssets.js │ └── generateRuntime.js │ ├── templates │ ├── runtime.js │ └── __tests__ │ │ └── runtime.js │ ├── LICENSE │ ├── __tests__ │ └── index.js │ ├── index.js │ └── README.md ├── .babelrc ├── .eslintignore ├── .gitignore ├── demo ├── customMainTemplate.js ├── manifest.json ├── server.js ├── index.html ├── index.js └── webpack.config.js ├── .editorconfig ├── .github └── workflows │ └── CI.yml ├── testing ├── jest-setup.js └── fixtures.js ├── .eslintrc.json ├── LICENSE ├── package.json ├── scripts └── publish.js └── README.md /packages/generate-service-worker/__tests__/mockTemplate: -------------------------------------------------------------------------------- 1 | var foo = "bar"; 2 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "transform-async-to-generator" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | packages/service-worker-plugin/runtime.js 2 | packages/service-worker-mock/node_modules 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | demo/sw-*.js 4 | packages/service-worker-plugin/runtime.js 5 | .idea 6 | -------------------------------------------------------------------------------- /demo/customMainTemplate.js: -------------------------------------------------------------------------------- 1 | self.addEventListener('install', (event) => { 2 | event.waitUntil(self.skipWaiting()); 3 | }); 4 | 5 | console.log('HELLO FROM A CUSTOM TEMPLATE!'); 6 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/DOMException.js: -------------------------------------------------------------------------------- 1 | class DOMException extends Error { 2 | constructor(message) { 3 | super(message); 4 | } 5 | } 6 | module.exports = DOMException; 7 | -------------------------------------------------------------------------------- /packages/service-worker-mock/utils/generateRandomId.js: -------------------------------------------------------------------------------- 1 | function generateRandomId() { 2 | return Math.floor(Math.random() * 1000000000); 3 | } 4 | 5 | module.exports = generateRandomId; 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 2 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/BroadcastChannel.js: -------------------------------------------------------------------------------- 1 | module.exports = class BroadcastChannel { 2 | constructor(channelName) { 3 | this.name = channelName; 4 | } 5 | 6 | postMessage(/* msgDetails */) { 7 | // NOOP 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /packages/service-worker-mock/fetch.js: -------------------------------------------------------------------------------- 1 | module.exports = async (request) => { 2 | const response = new Response('Response from service-worker-mock/fetch.js', { 3 | status: 200, 4 | statusText: 'ok.' 5 | }); 6 | response.url = request.url; 7 | return response; 8 | }; 9 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/PushEvent.js: -------------------------------------------------------------------------------- 1 | const ExtendableEvent = require('./ExtendableEvent'); 2 | 3 | class PushEvent extends ExtendableEvent { 4 | constructor(args) { 5 | super(); 6 | Object.assign(this, args); 7 | } 8 | } 9 | 10 | module.exports = PushEvent; 11 | -------------------------------------------------------------------------------- /packages/generate-service-worker/utils/hash.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | 3 | function buildHashFromConfig(config) { 4 | return crypto 5 | .createHash('md5') 6 | .update(JSON.stringify(config)) 7 | .digest('hex'); 8 | } 9 | 10 | module.exports = buildHashFromConfig; 11 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/MessageChannel.js: -------------------------------------------------------------------------------- 1 | const MessagePort = require('./MessagePort'); 2 | 3 | class MessageChannel { 4 | constructor() { 5 | this.port1 = new MessagePort(); 6 | this.port2 = new MessagePort(); 7 | this.port2._targetPort = this.port1; 8 | } 9 | } 10 | 11 | module.exports = MessageChannel; 12 | -------------------------------------------------------------------------------- /demo/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Service Workers", 3 | "short_name": "SW", 4 | "icons": [{ 5 | "src": "https://s.pinimg.com/images/favicon_red_192.png", 6 | "type": "image/png", 7 | "sizes": "192x192" 8 | }], 9 | "gcm_user_visible_only": true, 10 | "gcm_sender_id": "1021726025582", 11 | "display": "standalone" 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@master 11 | - uses: actions/setup-node@v1 12 | with: 13 | node-version: 8.x 14 | - run: yarn install 15 | - run: yarn lint 16 | - run: yarn test 17 | -------------------------------------------------------------------------------- /demo/server.js: -------------------------------------------------------------------------------- 1 | var WebpackDevServer = require("webpack-dev-server"); 2 | var webpack = require("webpack"); 3 | var config = require('./webpack.config'); 4 | 5 | var compiler = webpack(config); 6 | 7 | var server = new WebpackDevServer(compiler, { 8 | contentBase: 'demo', 9 | inline: true, 10 | filename: 'bundle.js', 11 | publicPath: '/', 12 | stats: { colors: true }, 13 | }); 14 | server.listen(3000, "localhost", function() {}); 15 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/FileReader.js: -------------------------------------------------------------------------------- 1 | // Derived from https://github.com/GoogleChrome/workbox 2 | 3 | // FileReader 4 | // https://w3c.github.io/FileAPI/#APIASynch 5 | class FileReader { 6 | readAsText(blob /* , label */) { 7 | try { 8 | this.result = blob._text; 9 | this.onloadend(); 10 | } catch (err) { 11 | this.error = err; 12 | this.onerror(); 13 | } 14 | } 15 | } 16 | 17 | module.exports = FileReader; 18 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/NotificationEvent.js: -------------------------------------------------------------------------------- 1 | const ExtendableEvent = require('./ExtendableEvent'); 2 | 3 | // https://developer.mozilla.org/en-US/docs/Web/API/NotificationEvent 4 | class NotificationEvent extends ExtendableEvent { 5 | constructor(type, init) { 6 | super(type, init); 7 | this.notification = init ? init.notification : null; 8 | this.action = init ? init.action : null; 9 | } 10 | } 11 | 12 | module.exports = NotificationEvent; 13 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/Event.js: -------------------------------------------------------------------------------- 1 | // Derived from https://github.com/GoogleChrome/workbox 2 | 3 | // Event 4 | // https://dom.spec.whatwg.org/#event 5 | class Event { 6 | constructor(type, eventInitDict = {}) { 7 | this.type = type; 8 | 9 | this.bubbles = eventInitDict.bubbles || false; 10 | this.cancelable = eventInitDict.cancelable || false; 11 | this.composed = eventInitDict.composed || false; 12 | } 13 | } 14 | 15 | module.exports = Event; 16 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/MessageEvent.js: -------------------------------------------------------------------------------- 1 | const ExtendableEvent = require('./ExtendableEvent'); 2 | 3 | // https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/MessageEvent 4 | const defaults = () => ({ 5 | data: null, 6 | origin: '', 7 | lastEventId: '', 8 | source: null, 9 | ports: [] 10 | }); 11 | class MessageEvent extends ExtendableEvent { 12 | constructor(type, init) { 13 | super(type); 14 | Object.assign(this, defaults(), init); 15 | } 16 | } 17 | 18 | module.exports = MessageEvent; 19 | -------------------------------------------------------------------------------- /testing/jest-setup.js: -------------------------------------------------------------------------------- 1 | const Subscription = require('./fixtures').Subscription; 2 | const Cache = require('./fixtures').Cache; 3 | 4 | const noop = () => {}; 5 | 6 | // Browser globals 7 | global.URL = jest.fn(url => ({ search: url, href: url })); 8 | global.Request = jest.fn(() => ({ url: '/' })); 9 | global.Response = Object; 10 | global.fetch = jest.fn(() => Promise.resolve({ status: 200 })); 11 | 12 | global.logger = { 13 | group: noop, 14 | groupEnd: noop, 15 | log: noop, 16 | warn: noop, 17 | error: noop, 18 | }; 19 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/SyncManager.js: -------------------------------------------------------------------------------- 1 | // Derived from https://github.com/GoogleChrome/workbox 2 | 3 | // SyncManager 4 | // https://wicg.github.io/BackgroundSync/spec/#sync-manager-interface 5 | class SyncManager { 6 | constructor() { 7 | this._tagList = new Set(); 8 | } 9 | async register(tagName) { 10 | this._tagList.add(tagName); 11 | return Promise.resolve(); 12 | } 13 | async getTags() { 14 | return Promise.resolve([...this._tagList]); 15 | } 16 | } 17 | 18 | module.exports = SyncManager; 19 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/ExtendableMessageEvent.js: -------------------------------------------------------------------------------- 1 | // Derived from https://github.com/GoogleChrome/workbox 2 | 3 | const ExtendableEvent = require('./ExtendableEvent'); 4 | 5 | 6 | // ExtendableMessageEvent 7 | // https://w3c.github.io/ServiceWorker/#extendablemessageevent-interface 8 | class ExtendableMessageEvent extends ExtendableEvent { 9 | constructor(type, eventInitDict) { 10 | super(type, eventInitDict); 11 | 12 | this.data = eventInitDict.data || null; 13 | } 14 | } 15 | 16 | module.exports = ExtendableMessageEvent; 17 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/WindowClient.js: -------------------------------------------------------------------------------- 1 | const Client = require('./Client'); 2 | 3 | // https://developer.mozilla.org/en-US/docs/Web/API/WindowClient 4 | class WindowClient extends Client { 5 | constructor(...args) { 6 | super(...args); 7 | 8 | this.type = 'window'; 9 | this.focused = false; 10 | } 11 | 12 | focus() { 13 | this.focused = true; 14 | return Promise.resolve(this); 15 | } 16 | 17 | navigate(url) { 18 | this.url = url; 19 | return Promise.resolve(this); 20 | } 21 | } 22 | 23 | module.exports = WindowClient; 24 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/PushManager.js: -------------------------------------------------------------------------------- 1 | const PushSubscription = require('./PushSubscription'); 2 | 3 | // https://developer.mozilla.org/en-US/docs/Web/API/PushManager 4 | class PushManager { 5 | constructor() { 6 | this.subscription = new PushSubscription(); 7 | } 8 | 9 | getSubscription() { 10 | return Promise.resolve(this.subscription); 11 | } 12 | 13 | permissionState() { 14 | return Promise.resolve('granted'); 15 | } 16 | 17 | subscribe() { 18 | return Promise.resolve(this.subscription); 19 | } 20 | } 21 | 22 | module.exports = PushManager; 23 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/PushSubscription.js: -------------------------------------------------------------------------------- 1 | // https://developer.mozilla.org/en-US/docs/Web/API/PushManager 2 | class PushSubscription { 3 | constructor(options) { 4 | this.endpoint = 'test.com/12345'; 5 | this.options = options; 6 | } 7 | 8 | getKey() { 9 | return new ArrayBuffer(this.endpoint.length); 10 | } 11 | 12 | toJSON() { 13 | return { 14 | endpoint: this.endpoint, 15 | options: this.options 16 | }; 17 | } 18 | 19 | unsubscribe() { 20 | return Promise.resolve(true); 21 | } 22 | } 23 | 24 | module.exports = PushSubscription; 25 | -------------------------------------------------------------------------------- /packages/generate-service-worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "generate-service-worker", 3 | "version": "2.0.5", 4 | "main": "index.js", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/pinterest/service-workers.git" 8 | }, 9 | "files": [ 10 | "index.js", 11 | "templates/*.js", 12 | "utils/*.js" 13 | ], 14 | "keywords": [ 15 | "service-workers", 16 | "service", 17 | "workers" 18 | ], 19 | "homepage": "https://github.com/pinterest/service-workers/tree/master/packages/generate-service-worker", 20 | "author": "zackargyle", 21 | "license": "MIT" 22 | } -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Service Worker Plugin 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/SyncEvent.js: -------------------------------------------------------------------------------- 1 | // Derived from https://github.com/GoogleChrome/workbox 2 | 3 | const ExtendableEvent = require('./ExtendableEvent'); 4 | 5 | // SyncEvent 6 | // https://wicg.github.io/BackgroundSync/spec/#sync-event 7 | class SyncEvent extends ExtendableEvent { 8 | constructor(type, init = {}) { 9 | super(type, init); 10 | 11 | if (!init.tag) { 12 | throw new TypeError( 13 | 'Failed to construct \'SyncEvent\': required member tag is undefined.'); 14 | } 15 | 16 | this.tag = init.tag; 17 | this.lastChance = init.lastChance || false; 18 | } 19 | } 20 | module.exports = SyncEvent; 21 | -------------------------------------------------------------------------------- /packages/service-worker-mock/__tests__/trigger.js: -------------------------------------------------------------------------------- 1 | const makeServiceWorkerEnv = require('../index'); 2 | 3 | describe('Trigger Event', () => { 4 | beforeEach(() => { 5 | Object.assign(global, makeServiceWorkerEnv()); 6 | jest.resetModules(); 7 | }); 8 | 9 | it('can trigger a message event and wait until it is finished', async () => { 10 | const fn = jest.fn(); 11 | self.addEventListener('message', (event) => { 12 | event.waitUntil(new Promise((resolve) => setTimeout(() => { 13 | fn(); 14 | resolve(); 15 | }, 20))); 16 | }); 17 | 18 | await self.trigger('message'); 19 | 20 | expect(fn).toHaveBeenCalled(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/NavigationPreloadManager.js: -------------------------------------------------------------------------------- 1 | // https://developer.mozilla.org/en-US/docs/Web/API/NavigationPreloadManager 2 | class NavigationPreloadManager { 3 | constructor() { 4 | this.enabled = false; 5 | } 6 | 7 | enable() { 8 | this.enabled = true; 9 | return Promise.resolve(); 10 | } 11 | 12 | disable() { 13 | this.enabled = false; 14 | return Promise.resolve(); 15 | } 16 | 17 | setHeaderValue() { 18 | throw new Error('NavigationPreloadManager.setHeaderValue not implemented'); 19 | } 20 | 21 | getState() { 22 | throw new Error('NavigationPreloadManager.getState not implemented'); 23 | } 24 | } 25 | 26 | module.exports = NavigationPreloadManager; 27 | -------------------------------------------------------------------------------- /packages/service-worker-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "service-worker-plugin", 3 | "version": "2.0.5", 4 | "main": "index.js", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/pinterest/service-workers.git" 8 | }, 9 | "files": [ 10 | "index.js", 11 | "templates/*.js", 12 | "utils/*.js" 13 | ], 14 | "keywords": [ 15 | "service-workers", 16 | "service", 17 | "workers", 18 | "webpack", 19 | "plugin" 20 | ], 21 | "homepage": "https://github.com/pinterest/service-workers/tree/master/packages/service-worker-plugin", 22 | "author": "zackargyle", 23 | "license": "MIT", 24 | "dependencies": { 25 | "generate-service-worker": "2.0.5" 26 | } 27 | } -------------------------------------------------------------------------------- /packages/service-worker-mock/models/Notification.js: -------------------------------------------------------------------------------- 1 | // https://developer.mozilla.org/en-US/docs/Web/API/Notification 2 | class Notification { 3 | constructor(title, options) { 4 | this.title = title; 5 | Object.assign(this, options); 6 | } 7 | 8 | snapshot() { 9 | const keys = Object.keys(this); 10 | const result = {}; 11 | for (let i = 0; i < keys.length; i += 1) { 12 | if (typeof this[keys[i]] !== 'function') { 13 | result[keys[i]] = this[keys[i]]; 14 | } 15 | } 16 | return result; 17 | } 18 | } 19 | 20 | Notification.requestPermission = function () { 21 | return Promise.resolve(Notification.permission); 22 | }; 23 | 24 | Notification.permission = 'granted'; 25 | 26 | module.exports = Notification; 27 | -------------------------------------------------------------------------------- /demo/index.js: -------------------------------------------------------------------------------- 1 | const runtime = require('../packages/service-worker-plugin/runtime'); 2 | 3 | const node = document.getElementById('sw'); 4 | 5 | // Parse query parameters 6 | const queryParams = location.search.substr(1).split('&').reduce((q, query) => { 7 | const [key, value] = query.split('='); 8 | return (q[key] = value, q); 9 | }, {}); 10 | 11 | // Get service worker to register 12 | const experimentKey = queryParams.key || 'main'; 13 | runtime.register(experimentKey); 14 | runtime.requestNotificationsPermission(); 15 | 16 | // Inject service worker code to HTML for your viewing pleasure 17 | fetch(`sw-${experimentKey}.js`) 18 | .then(res => res.text()) 19 | .then(text => { 20 | node.innerHTML = text; 21 | hljs.highlightBlock(node); 22 | }); 23 | -------------------------------------------------------------------------------- /packages/service-worker-mock/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "service-worker-mock", 3 | "version": "2.0.5", 4 | "main": "index.js", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/pinterest/service-workers.git" 8 | }, 9 | "files": [ 10 | "index.js", 11 | "fetch.js", 12 | "utils", 13 | "models" 14 | ], 15 | "keywords": [ 16 | "service-workers", 17 | "service", 18 | "workers", 19 | "testing", 20 | "mock" 21 | ], 22 | "homepage": "https://github.com/pinterest/service-workers/tree/master/packages/service-worker-mock", 23 | "author": "zackargyle", 24 | "license": "MIT", 25 | "dependencies": { 26 | "dom-urls": "^1.1.0", 27 | "shelving-mock-indexeddb": "^1.1.0", 28 | "url-search-params": "^0.10.0", 29 | "w3c-hr-time": "^1.0.1" 30 | } 31 | } -------------------------------------------------------------------------------- /packages/service-worker-mock/__tests__/ServiceWorkerRegistration.js: -------------------------------------------------------------------------------- 1 | const makeServiceWorkerEnv = require('../index'); 2 | const ServiceWorkerRegistration = require('../models/ServiceWorkerRegistration'); 3 | const SyncManager = require('../models/SyncManager'); 4 | 5 | describe('registration', () => { 6 | beforeEach(() => { 7 | Object.assign(global, makeServiceWorkerEnv()); 8 | jest.resetModules(); 9 | }); 10 | 11 | it('should expose a mocked service worker registration', () => { 12 | expect(global).toHaveProperty('registration'); 13 | expect(global.registration).toBeInstanceOf(ServiceWorkerRegistration); 14 | }); 15 | 16 | it('has a sync interface', () => { 17 | expect(global.registration).toHaveProperty('sync'); 18 | expect(global.registration.sync).toBeInstanceOf(SyncManager); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/FetchEvent.js: -------------------------------------------------------------------------------- 1 | // Derived from https://github.com/GoogleChrome/workbox 2 | 3 | const ExtendableEvent = require('./ExtendableEvent'); 4 | const Request = require('./Request'); 5 | 6 | // FetchEvent 7 | // https://www.w3.org/TR/service-workers-1/#fetch-event-section 8 | class FetchEvent extends ExtendableEvent { 9 | constructor(type, init) { 10 | super(); 11 | this.type = type; 12 | this.isReload = init.isReload || false; 13 | this.clientId = init.clientId || null; 14 | if (init.request instanceof Request) { 15 | this.request = init.request; 16 | } else if (typeof init.request === 'string') { 17 | this.request = new Request(init.request); 18 | } 19 | } 20 | respondWith(response) { 21 | this.promise = response; 22 | } 23 | } 24 | 25 | module.exports = FetchEvent; 26 | -------------------------------------------------------------------------------- /packages/service-worker-mock/__tests__/MessageEvent.js: -------------------------------------------------------------------------------- 1 | const makeServiceWorkerEnv = require('../index'); 2 | 3 | describe('MessageEvent', () => { 4 | beforeEach(() => { 5 | Object.assign(global, makeServiceWorkerEnv()); 6 | jest.resetModules(); 7 | }); 8 | 9 | it('should properly initialize message with defaults', () => { 10 | const event = new MessageEvent('message'); 11 | 12 | expect(event.data).toEqual(null); 13 | expect(event.origin).toEqual(''); 14 | expect(event.lastEventId).toEqual(''); 15 | expect(event.source).toEqual(null); 16 | expect(event.ports).toEqual([]); 17 | }); 18 | 19 | it('should properly initialize message with data', () => { 20 | const init = { 21 | data: 'Hello world' 22 | }; 23 | 24 | const event = new MessageEvent('message', init); 25 | expect(event.data).toEqual('Hello world'); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /packages/service-worker-plugin/utils/mapPrecacheAssets.js: -------------------------------------------------------------------------------- 1 | const isObject = (val) => val && !Array.isArray(val) && typeof val === 'object'; 2 | 3 | function deepCopy(obj) { 4 | var result = {}; 5 | Object.keys(obj).forEach(key => { 6 | result[key] = isObject(obj[key]) ? deepCopy(obj[key]) : obj[key]; 7 | }); 8 | return result; 9 | } 10 | 11 | function mapPrecacheAssets(assets, _config, publicPath) { 12 | const config = deepCopy(_config); 13 | if (config.cache && config.cache.precache) { 14 | config.cache.precache = Object.keys(assets) 15 | .filter(asset => { 16 | return config.cache.precache.some(matcher => { 17 | const regex = new RegExp(matcher); 18 | return regex.test(asset); 19 | }); 20 | }) 21 | .map(asset => publicPath + asset); 22 | } 23 | return config; 24 | } 25 | 26 | module.exports = mapPrecacheAssets; 27 | -------------------------------------------------------------------------------- /packages/service-worker-plugin/utils/generateRuntime.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const runtimeTemplate = fs.readFileSync(path.join(__dirname, '..', 'templates', 'runtime.js'), 'utf-8'); 4 | 5 | function generateLocations(keys, publicPath) { 6 | const locationMap = keys.reduce((locations, key) => { 7 | const location = path.join(publicPath, `sw-${key}.js`); 8 | // eslint-disable-next-line no-param-reassign 9 | locations[key] = location; 10 | return locations; 11 | }, {}); 12 | return JSON.stringify(locationMap, null, 2); 13 | } 14 | 15 | function generateRuntime(keys, publicPath) { 16 | return [ 17 | '/*\n * AUTOGENERATED FROM PROGRESSIVE-WEBAPP-PLUGIN\n */\n', 18 | `var $LocationMap = ${generateLocations(keys, publicPath)}`, 19 | runtimeTemplate 20 | ].join('\n'); 21 | } 22 | 23 | module.exports = generateRuntime; 24 | -------------------------------------------------------------------------------- /packages/service-worker-mock/__tests__/navigator.js: -------------------------------------------------------------------------------- 1 | const makeServiceWorkerEnv = require('../index'); 2 | 3 | describe('navigator', () => { 4 | beforeEach(() => { 5 | Object.assign(global, makeServiceWorkerEnv()); 6 | jest.resetModules(); 7 | }); 8 | 9 | it('has a navigator mock with default userAgent', () => { 10 | expect(global).toHaveProperty('navigator'); 11 | expect(global.navigator).toBeInstanceOf(Object); 12 | expect(global.navigator).toHaveProperty('userAgent', 'Mock User Agent'); 13 | }); 14 | 15 | it('takes a custom userAgent string from options', () => { 16 | Object.assign(global, makeServiceWorkerEnv({ 17 | userAgent: 'Custom User Agent' 18 | })); 19 | 20 | expect(global).toHaveProperty('navigator'); 21 | expect(global.navigator).toBeInstanceOf(Object); 22 | expect(global.navigator).toHaveProperty('userAgent', 'Custom User Agent'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /packages/service-worker-mock/__tests__/NotificationEvent.js: -------------------------------------------------------------------------------- 1 | const makeServiceWorkerEnv = require('../index'); 2 | const NotificationEvent = require('../models/NotificationEvent'); 3 | 4 | describe('NotificationEvent', () => { 5 | beforeEach(() => { 6 | Object.assign(global, makeServiceWorkerEnv()); 7 | jest.resetModules(); 8 | }); 9 | 10 | it('should properly initialize notification from initial data', () => { 11 | const init = { 12 | notification: { data: 'Test data' } 13 | }; 14 | 15 | const event = new NotificationEvent('notification', init); 16 | expect(event.notification.data).toEqual(init.notification.data); 17 | }); 18 | 19 | it('should properly initialize action', () => { 20 | const init = { 21 | action: 'test-action' 22 | }; 23 | 24 | const event = new NotificationEvent('notification', init); 25 | expect(event.action).toEqual(init.action); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/Body.js: -------------------------------------------------------------------------------- 1 | const Blob = require('./Blob'); 2 | 3 | const throwBodyUsed = (method) => { 4 | throw new TypeError(`Failed to execute '${method}': body is already used`); 5 | }; 6 | 7 | class Body { 8 | constructor(body) { 9 | this.bodyUsed = false; 10 | this.body = body === null || body instanceof Blob ? body : new Blob([].concat(body)); 11 | } 12 | arrayBuffer() { 13 | throw new Error('Body.arrayBuffer is not yet supported.'); 14 | } 15 | 16 | blob() { 17 | return this.resolve('blob', body => body); 18 | } 19 | 20 | json() { 21 | return this.resolve('json', body => JSON.parse(body._text)); 22 | } 23 | 24 | text() { 25 | return this.resolve('text', body => body._text); 26 | } 27 | 28 | resolve(name, resolver) { 29 | if (this.bodyUsed) throwBodyUsed(name); 30 | this.bodyUsed = true; 31 | return Promise.resolve(resolver(this.body)); 32 | } 33 | } 34 | 35 | module.exports = Body; 36 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base/legacy", 3 | "parserOptions": { 4 | "ecmaVersion": 8 5 | }, 6 | "globals": { 7 | "Symbol": true, 8 | "Promise": true, 9 | "Map": true, 10 | "Set": true, 11 | "ArrayBuffer": true, 12 | "logger": true, 13 | "$VERSION": true, 14 | "$DEBUG": true, 15 | "$LocationMap": true, 16 | "$Notifications": true, 17 | "$Cache": true, 18 | "$Log": true, 19 | "ServiceWorkerGlobalScope": true, 20 | "expect": true, 21 | "afterEach": true, 22 | "beforeEach": true, 23 | "self": true, 24 | "clients": true, 25 | "describe": true, 26 | "it": true, 27 | "jest": true 28 | }, 29 | "rules": { 30 | "global-require": 0, 31 | "no-prototype-builtins": 0, 32 | "func-names": 0, 33 | "no-use-before-define": 0, 34 | "no-param-reassign": 0, 35 | "no-underscore-dangle": 0, 36 | "no-console": 0, 37 | "class-methods-use-this": 0, 38 | "no-restricted-syntax": 0 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/service-worker-mock/__tests__/IDB.js: -------------------------------------------------------------------------------- 1 | const { 2 | IDBFactory, 3 | IDBKeyRange, 4 | IDBDatabase, 5 | IDBObjectStore 6 | } = require('shelving-mock-indexeddb'); 7 | const makeServiceWorkerEnv = require('../index'); 8 | 9 | describe('IDB', () => { 10 | beforeEach(() => { 11 | Object.assign(global, makeServiceWorkerEnv()); 12 | jest.resetModules(); 13 | }); 14 | 15 | // https://github.com/dhoulb/shelving-mock-indexeddb is test covered already, 16 | // so we are just going to check its exposure on the global mock 17 | it('has IDB mocks', () => { 18 | expect(global).toHaveProperty('indexedDB'); 19 | expect(global.indexedDB).toBeInstanceOf(IDBFactory); 20 | expect(global).toHaveProperty('IDBKeyRange'); 21 | expect(global.IDBKeyRange).toBe(IDBKeyRange); 22 | expect(global).toHaveProperty('IDBDatabase'); 23 | expect(global.IDBDatabase).toBe(IDBDatabase); 24 | expect(global).toHaveProperty('IDBObjectStore'); 25 | expect(global.IDBObjectStore).toBe(IDBObjectStore); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/Client.js: -------------------------------------------------------------------------------- 1 | const generateRandomId = require('../utils/generateRandomId'); 2 | const EventTarget = require('./EventTarget'); 3 | const MessageEvent = require('./MessageEvent'); 4 | const MessagePort = require('./MessagePort'); 5 | 6 | // https://developer.mozilla.org/en-US/docs/Web/API/Client 7 | class Client extends EventTarget { 8 | constructor(url, type, frameType) { 9 | super(); 10 | 11 | this.id = generateRandomId(); 12 | this.url = url; 13 | this.type = type || 'worker'; 14 | this.frameType = frameType; 15 | } 16 | 17 | // TODO: Implement Transferable 18 | postMessage(message, transfer = []) { 19 | const ports = transfer.filter(objOrPort => (objOrPort instanceof MessagePort)); 20 | const event = new MessageEvent('message', { 21 | data: message, 22 | ports 23 | }); 24 | this.dispatchEvent(event); 25 | } 26 | 27 | snapshot() { 28 | return { 29 | url: this.url, 30 | type: this.type, 31 | frameType: this.frameType 32 | }; 33 | } 34 | } 35 | 36 | module.exports = Client; 37 | -------------------------------------------------------------------------------- /packages/generate-service-worker/templates/main.js: -------------------------------------------------------------------------------- 1 | if (!$Cache) { 2 | self.addEventListener('install', (event) => { 3 | event.waitUntil(self.skipWaiting()); 4 | }); 5 | } 6 | 7 | function print(fn) { 8 | return function (message, group) { 9 | if ($DEBUG) { 10 | if (group && logger.groups[group]) { 11 | logger.groups[group].push({ 12 | fn: fn, 13 | message: message 14 | }); 15 | } else { 16 | console[fn].call(console, message); 17 | } 18 | } 19 | }; 20 | } 21 | 22 | const logger = { 23 | groups: {}, 24 | group: group => { 25 | logger.groups[group] = []; 26 | }, 27 | groupEnd: group => { 28 | const groupLogs = logger.groups[group]; 29 | if (groupLogs && groupLogs.length > 0) { 30 | console.groupCollapsed(group); 31 | groupLogs.forEach(log => { 32 | console[log.fn].call(console, log.message); 33 | }); 34 | console.groupEnd(); 35 | } 36 | delete logger.groups[group]; 37 | }, 38 | log: print('log'), 39 | warn: print('warn'), 40 | error: print('error') 41 | }; 42 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/Clients.js: -------------------------------------------------------------------------------- 1 | const WindowClient = require('./WindowClient'); 2 | 3 | // https://developer.mozilla.org/en-US/docs/Web/API/Clients 4 | class Clients { 5 | constructor() { 6 | this.clients = []; 7 | } 8 | 9 | get(id) { 10 | const client = this.clients.find(cli => id === cli.id); 11 | return Promise.resolve(client || null); 12 | } 13 | 14 | matchAll({ type = 'all' } = {}) { 15 | if (type === 'all') { 16 | return Promise.resolve(this.clients); 17 | } 18 | const matchedClients = this.clients.filter(client => client.type === type); 19 | return Promise.resolve(matchedClients); 20 | } 21 | 22 | openWindow(url) { 23 | const windowClient = new WindowClient(url); 24 | this.clients.push(windowClient); 25 | return Promise.resolve(windowClient); 26 | } 27 | 28 | claim() { 29 | return Promise.resolve(this.clients); 30 | } 31 | 32 | snapshot() { 33 | return this.clients.map(client => client.snapshot()); 34 | } 35 | 36 | reset() { 37 | this.clients = []; 38 | } 39 | } 40 | 41 | module.exports = Clients; 42 | -------------------------------------------------------------------------------- /packages/service-worker-plugin/templates/runtime.js: -------------------------------------------------------------------------------- 1 | function noopPromise() { 2 | return { 3 | then: function () { 4 | return noopPromise(); 5 | }, 6 | catch: function () {} 7 | }; 8 | } 9 | 10 | function serviceWorkerRegister(experimentKey) { 11 | if (navigator.serviceWorker) { 12 | if (experimentKey && !$LocationMap[experimentKey]) { 13 | throw new Error('Experiment: "' + experimentKey + '" not found. Must be one of:', Object.keys($LocationMap).join(', ')); 14 | } 15 | const key = experimentKey || 'main'; 16 | return navigator.serviceWorker.register($LocationMap[key]); 17 | } 18 | return noopPromise(); 19 | } 20 | 21 | function requestServiceWorkerNotificationsPermission() { 22 | if (navigator.serviceWorker) { 23 | return navigator.serviceWorker.ready 24 | .then(sw => sw.pushManager.subscribe({ 25 | userVisibleOnly: true 26 | })); 27 | } 28 | return noopPromise(); 29 | } 30 | 31 | module.exports = { 32 | register: serviceWorkerRegister, 33 | requestNotificationsPermission: requestServiceWorkerNotificationsPermission 34 | }; 35 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/Blob.js: -------------------------------------------------------------------------------- 1 | // Blob 2 | // https://w3c.github.io/FileAPI/#dom-blob-blob 3 | class Blob { 4 | constructor(parts = [], options = {}) { 5 | if (!Array.isArray(parts)) { 6 | throw new TypeError('Blob requires an array'); 7 | } 8 | 9 | this.parts = parts; 10 | this.type = options.type || ''; 11 | } 12 | 13 | get size() { 14 | return this.parts.reduce((size, part) => { 15 | return size + (part instanceof Blob ? part.size : String(part).length); 16 | }, 0); 17 | } 18 | 19 | // Warning: non-standard, but used in other mocks for simplicity. 20 | get _text() { 21 | return this.parts.reduce((text, part) => { 22 | return text + (part instanceof Blob ? part._text : String(part)); 23 | }, ''); 24 | } 25 | 26 | clone() { 27 | return new Blob(this.parts.slice(), { 28 | type: this.type 29 | }); 30 | } 31 | 32 | slice(start, end, type) { 33 | const bodyString = this._text; 34 | const slicedBodyString = bodyString.substring(start, end); 35 | return new Blob([slicedBodyString], { type }); 36 | } 37 | } 38 | 39 | module.exports = Blob; 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Pinterest 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 | -------------------------------------------------------------------------------- /packages/service-worker-mock/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Pinterest 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 | -------------------------------------------------------------------------------- /packages/generate-service-worker/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Pinterest 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 | -------------------------------------------------------------------------------- /packages/service-worker-plugin/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Pinterest 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "lint": "eslint packages", 4 | "postinstall": "cd packages/service-worker-mock && yarn install", 5 | "publish:major": "node scripts/publish.js --type major", 6 | "publish:minor": "node scripts/publish.js --type minor", 7 | "publish:bugfix": "node scripts/publish.js --type bugfix", 8 | "start": "node demo/server.js", 9 | "test": "jest" 10 | }, 11 | "devDependencies": { 12 | "babel-jest": "^18.0.0", 13 | "babel-plugin-transform-async-to-generator": "^6.22.0", 14 | "babel-polyfill": "^6.23.0", 15 | "dom-urls": "^1.1.0", 16 | "eslint": "^3.13.1", 17 | "eslint-config-airbnb-base": "^11.0.1", 18 | "eslint-plugin-import": "^2.2.0", 19 | "jest": "^18.1.0", 20 | "shelljs": "^0.7.6", 21 | "webpack": "^1.14.0", 22 | "webpack-dev-server": "^1.16.2", 23 | "yargs": "^6.6.0" 24 | }, 25 | "dependencies": { 26 | "mkdirp": "^0.5.1" 27 | }, 28 | "jest": { 29 | "testEnvironment": "node", 30 | "modulePathIgnorePatterns": [ 31 | ".*/__tests__/fixtures" 32 | ], 33 | "setupFiles": [ 34 | "/testing/jest-setup.js" 35 | ] 36 | }, 37 | "license": "MIT" 38 | } 39 | -------------------------------------------------------------------------------- /packages/generate-service-worker/utils/validators.js: -------------------------------------------------------------------------------- 1 | const V = require('./validate'); 2 | 3 | const StrategyShape = V.shape({ 4 | type: V.oneOf(['offline-only', 'fallback-only', 'prefer-cache', 'race']).required, 5 | matches: V.arrayOfType(V.string).required 6 | }); 7 | 8 | const CacheShape = V.shape({ 9 | offline: V.boolean, 10 | precache: V.arrayOfType(V.string), 11 | strategy: V.arrayOfType(StrategyShape) 12 | }); 13 | 14 | const NotificationsShape = V.shape({ 15 | default: V.shape({ 16 | title: V.string.required, 17 | body: V.string, 18 | icon: V.string, 19 | tag: V.string, 20 | data: V.shape({ 21 | url: V.string 22 | }) 23 | }).required, 24 | duration: V.number, 25 | fallbackURL: V.string 26 | }); 27 | 28 | const LogShape = V.shape({ 29 | installed: V.string, 30 | notificationClicked: V.string, 31 | notificationReceived: V.string, 32 | requestOptions: V.object 33 | }); 34 | 35 | function validate(config) { 36 | if (!config.template) { 37 | if (config.cache && !config.cache.template) { 38 | CacheShape(config.cache); 39 | } 40 | if (config.notifications && !config.notifications.template) { 41 | NotificationsShape(config.notifications); 42 | } 43 | if (config.log) { 44 | LogShape(config.log); 45 | } 46 | } 47 | } 48 | 49 | module.exports = { 50 | CacheShape: CacheShape, 51 | NotificationsShape: NotificationsShape, 52 | LogShape: LogShape, 53 | validate: validate 54 | }; 55 | -------------------------------------------------------------------------------- /packages/service-worker-mock/__tests__/FetchEvent.js: -------------------------------------------------------------------------------- 1 | const makeServiceWorkerEnv = require('../index'); 2 | const FetchEvent = require('../models/FetchEvent'); 3 | 4 | describe('FetchEvent', () => { 5 | it('should construct with Request', () => { 6 | Object.assign(global, makeServiceWorkerEnv()); 7 | 8 | const request = new Request('/test'); 9 | const event = new FetchEvent('fetch', { request }); 10 | 11 | expect(event.request.url).toEqual('https://www.test.com/test'); 12 | }); 13 | 14 | it('should construct with string', () => { 15 | Object.assign(global, makeServiceWorkerEnv()); 16 | 17 | const request = '/test'; 18 | const event = new FetchEvent('fetch', { request }); 19 | 20 | expect(event.request.url).toEqual('https://www.test.com/test'); 21 | }); 22 | 23 | it('should add clientId with Request', () => { 24 | Object.assign(global, makeServiceWorkerEnv()); 25 | 26 | const request = new Request('/test'); 27 | const event = new FetchEvent('fetch', { request, clientId: 'testClientId' }); 28 | 29 | expect(event.request.url).toEqual('https://www.test.com/test'); 30 | expect(event.clientId).toEqual('testClientId'); 31 | }); 32 | 33 | it('should add clientId with string', () => { 34 | Object.assign(global, makeServiceWorkerEnv()); 35 | 36 | const request = '/test'; 37 | const event = new FetchEvent('fetch', { request, clientId: 'testClientId' }); 38 | 39 | expect(event.request.url).toEqual('https://www.test.com/test'); 40 | expect(event.clientId).toEqual('testClientId'); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/EventTarget.js: -------------------------------------------------------------------------------- 1 | class EventTarget { 2 | constructor() { 3 | this.listeners = new Map(); 4 | } 5 | 6 | addEventListener(type, listener /* TODO: support `opts` */) { 7 | if (this.listeners.has(type)) { 8 | this.listeners.get(type).add(listener); 9 | } else { 10 | this.listeners.set(type, createExtendedSet([listener])); 11 | } 12 | } 13 | 14 | dispatchEvent(event) { 15 | const listeners = this.listeners.get(event.type); 16 | 17 | if (listeners) { 18 | // When dispatching messages on another context, 19 | // we need to transfer event data into the context realm, 20 | // otherwise `event.data instanceof Object` won't work. 21 | // TODO: For fetch events we need to transfer the Request 22 | if (event.type === 'message') { 23 | if (typeof event.data === 'object') { 24 | event.data = JSON.parse(JSON.stringify(event.data)); 25 | } 26 | } 27 | for (const listener of listeners) { 28 | listener(event); 29 | } 30 | } 31 | } 32 | 33 | removeEventListener() { 34 | throw new Error('not implemented yet'); 35 | } 36 | 37 | resetEventListeners() { 38 | this.listeners.clear(); 39 | } 40 | 41 | } 42 | 43 | const createExtendedSet = (values) => { 44 | const set = new Set(values); 45 | 46 | Object.defineProperty(set, 'at', { 47 | enumerable: false, 48 | value: function (pos) { 49 | return Array.from(this.values())[pos]; 50 | } 51 | }); 52 | 53 | return set; 54 | }; 55 | 56 | module.exports = EventTarget; 57 | -------------------------------------------------------------------------------- /packages/service-worker-mock/utils/eventHandler.js: -------------------------------------------------------------------------------- 1 | const ExtendableEvent = require('../models/ExtendableEvent'); 2 | const FetchEvent = require('../models/FetchEvent'); 3 | const NotificationEvent = require('../models/NotificationEvent'); 4 | const PushEvent = require('../models/PushEvent'); 5 | const MessageEvent = require('../models/MessageEvent'); 6 | 7 | function createEvent(event, args) { 8 | switch (event) { 9 | case 'fetch': 10 | return new FetchEvent('fetch', getFetchArguments(args)); 11 | case 'notificationclick': 12 | return new NotificationEvent(args); 13 | case 'push': 14 | return new PushEvent(args); 15 | case 'message': 16 | return new MessageEvent('message', args); 17 | default: 18 | return new ExtendableEvent(); 19 | } 20 | } 21 | 22 | function getFetchArguments(args) { 23 | let request = args; 24 | let clientId = null; 25 | 26 | if (typeof args === 'object' && args.request) { 27 | clientId = args.clientId || null; 28 | request = args.request; 29 | } 30 | 31 | return { 32 | request, 33 | clientId 34 | }; 35 | } 36 | 37 | function handleEvent(name, args, callback) { 38 | const event = createEvent(name, args); 39 | callback(event); 40 | return Promise.resolve(event.promise); 41 | } 42 | 43 | function eventHandler(name, args, listeners) { 44 | if (listeners.length === 1) { 45 | return handleEvent(name, args, listeners[0]); 46 | } 47 | return Promise.all( 48 | listeners.map(callback => handleEvent(name, args, callback)) 49 | ); 50 | } 51 | 52 | module.exports = eventHandler; 53 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/CacheStorage.js: -------------------------------------------------------------------------------- 1 | const Cache = require('./Cache'); 2 | 3 | // https://developer.mozilla.org/en-US/docs/Web/API/CacheStorage 4 | class CacheStorage { 5 | constructor() { 6 | this.caches = {}; 7 | } 8 | 9 | async match(request, options = {}) { 10 | const url = request.url || request; 11 | 12 | if (options.cacheName) { 13 | const cache = await this.open(options.cacheName); 14 | return cache.match(request); 15 | } 16 | 17 | const keys = Object.keys(this.caches); 18 | for (let i = 0; i < keys.length; i += 1) { 19 | const cache = this.caches[keys[i]]; 20 | if (cache.store.has(url)) { 21 | return cache.match(request); 22 | } 23 | } 24 | return null; 25 | } 26 | 27 | has(cacheName) { 28 | return Promise.resolve(this.caches.hasOwnProperty(cacheName)); 29 | } 30 | 31 | open(name) { 32 | if (!this.caches[name]) { 33 | this.caches[name] = new Cache(); 34 | } 35 | return Promise.resolve(this.caches[name]); 36 | } 37 | 38 | delete(cacheName) { 39 | if (this.caches.hasOwnProperty(cacheName)) { 40 | delete this.caches[cacheName]; 41 | return Promise.resolve(true); 42 | } 43 | return Promise.resolve(false); 44 | } 45 | 46 | keys() { 47 | return Promise.resolve(Object.keys(this.caches)); 48 | } 49 | 50 | snapshot() { 51 | return Object.keys(this.caches).reduce((obj, key) => { 52 | obj[key] = this.caches[key].snapshot(); 53 | return obj; 54 | }, {}); 55 | } 56 | 57 | reset() { 58 | this.caches = {}; 59 | } 60 | } 61 | 62 | module.exports = CacheStorage; 63 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/MessagePort.js: -------------------------------------------------------------------------------- 1 | const EventTarget = require('./EventTarget'); 2 | const MessageEvent = require('./MessageEvent'); 3 | 4 | class MessagePort extends EventTarget { 5 | constructor() { 6 | super(); 7 | 8 | this._active = true; 9 | this._targetPort = null; 10 | this._onmessage = null; 11 | this._onmessageerror = null; 12 | Object.defineProperty(this, 'onmessage', { 13 | enumerable: true, 14 | set: (handler) => { 15 | this._onmessage = handler; 16 | this.addEventListener('message', handler); 17 | }, 18 | get: () => this._onmessage 19 | }); 20 | Object.defineProperty(this, 'onmessageerror', { 21 | enumerable: true, 22 | set: (handler) => { 23 | this._onmessageerror = handler; 24 | this.addEventListener('messageerror', handler); 25 | }, 26 | get: () => this._onmessageerror 27 | }); 28 | } 29 | 30 | /** 31 | * Posts a message through the channel. Objects listed in transfer are 32 | * transferred, not just cloned, meaning that they are no longer usable on the sending side. 33 | * Throws a "DataCloneError" DOMException if 34 | * transfer contains duplicate objects or port, or if message 35 | * could not be cloned. 36 | * TODO: Implement Transferable 37 | */ 38 | postMessage(message /* , transfer?: Transferable[] */) { 39 | const event = new MessageEvent('message', { 40 | data: message 41 | }); 42 | this._targetPort.dispatchEvent(event); 43 | } 44 | 45 | 46 | close() { 47 | // not implemented yet 48 | } 49 | 50 | start() { 51 | // not implemented yet 52 | } 53 | } 54 | 55 | module.exports = MessagePort; 56 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/ServiceWorkerRegistration.js: -------------------------------------------------------------------------------- 1 | const PushManager = require('./PushManager'); 2 | const NavigationPreloadManager = require('./NavigationPreloadManager'); 3 | const Notification = require('./Notification'); 4 | const NotificationEvent = require('./NotificationEvent'); 5 | const SyncManager = require('./SyncManager'); 6 | const EventTarget = require('./EventTarget'); 7 | 8 | // https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration 9 | class ServiceWorkerRegistration extends EventTarget { 10 | constructor() { 11 | super(); 12 | 13 | this.active = null; 14 | this.installing = null; 15 | this.onupdatefound = null; 16 | this.pushManager = new PushManager(); 17 | this.navigationPreload = new NavigationPreloadManager(); 18 | this.sync = new SyncManager(); 19 | this.scope = '/'; 20 | this.waiting = null; 21 | 22 | this.notifications = []; 23 | } 24 | 25 | getNotifications() { 26 | return Promise.resolve(this.notifications); 27 | } 28 | 29 | showNotification(title, options) { 30 | const notification = new Notification(title, options); 31 | this.notifications.push(notification); 32 | notification.close = () => { 33 | const index = this.notifications.indexOf(notification); 34 | this.notifications.splice(index, 1); 35 | }; 36 | return Promise.resolve(new NotificationEvent('notification', { notification })); 37 | } 38 | 39 | update() { 40 | return Promise.resolve(); 41 | } 42 | 43 | unregister() { 44 | return Promise.resolve(); 45 | } 46 | 47 | snapshot() { 48 | return this.notifications.map(n => n.snapshot()); 49 | } 50 | } 51 | 52 | module.exports = ServiceWorkerRegistration; 53 | -------------------------------------------------------------------------------- /scripts/publish.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const shell = require('shelljs'); 4 | const argv = require('yargs').argv; 5 | 6 | // Constants 7 | const packages = ['generate-service-worker', 'service-worker-plugin', 'service-worker-mock']; 8 | const SEMVER = { major: 0, minor: 1, bugfix: 2 }; 9 | var NEW_VERSION; 10 | 11 | // Update each package 12 | shell.echo(process.cwd()); 13 | shell.cd(path.join(__dirname, '../packages')); 14 | packages.forEach(package => { 15 | shell.cd(`./${package}`); 16 | shell.echo(process.cwd()); 17 | // Update version number 18 | const json = require(path.join(process.cwd(), 'package.json')); 19 | if (!NEW_VERSION) { 20 | const index = SEMVER.hasOwnProperty(argv.type) ? SEMVER[argv.type] : SEMVER.bugfix; 21 | const split = json.version.split('.').map(Number); 22 | split[index] = (split[index] || 0) + 1; 23 | 24 | // Fill in the rest with 0s 25 | var fillIndex = index + 1; 26 | while (fillIndex < 3) { 27 | split[fillIndex++] = 0; 28 | } 29 | NEW_VERSION = split.join('.'); 30 | } 31 | json.version = NEW_VERSION; 32 | 33 | if (json.dependencies) { 34 | Object.keys(json.dependencies).forEach(name => { 35 | if (packages.indexOf(name) !== -1) { 36 | json.dependencies[name] = `${NEW_VERSION}`; 37 | } 38 | }); 39 | } 40 | fs.writeFileSync('./package.json', JSON.stringify(json, null, 2)); 41 | 42 | // Publish to npm 43 | shell.exec('npm publish'); 44 | shell.cd('..'); 45 | }); 46 | 47 | shell.exec(`git checkout -b publish-${NEW_VERSION}`); 48 | shell.exec('git add --all'); 49 | shell.exec(`git commit -m "Publish ${argv.type} to ${NEW_VERSION}"`); 50 | shell.exec(`git push origin publish-${NEW_VERSION}`); 51 | -------------------------------------------------------------------------------- /packages/service-worker-plugin/__tests__/index.js: -------------------------------------------------------------------------------- 1 | jest.mock('mkdirp'); 2 | jest.mock('fs'); 3 | 4 | const ProgressiveWebappPlugin = require('../index'); 5 | 6 | const Compiler = function () { 7 | return { 8 | plugin: jest.fn(), 9 | options: { 10 | output: { 11 | path: '/', 12 | publicPath: '/' 13 | } 14 | } 15 | }; 16 | }; 17 | 18 | function runTest(_config) { 19 | // Run plugin.apply 20 | const config = _config || {}; 21 | const plugin = new ProgressiveWebappPlugin(config.options || {}, config.experiments); 22 | const compiler = config.compiler || Compiler(); 23 | const assets = config.assets || {}; 24 | plugin.apply(compiler); 25 | 26 | // run the 'emit' callback 27 | const emitCallback = compiler.plugin.mock.calls[0][1]; 28 | emitCallback({ assets: assets }, jest.fn()); 29 | return assets; 30 | } 31 | 32 | describe('[service-worker-plugin] index', function () { 33 | it('emits the sw-main file', function () { 34 | const assets = runTest(); 35 | expect(assets['/sw-main.js']).toBeDefined(); 36 | }); 37 | 38 | it('emits experiment sw files', function () { 39 | const assets = runTest({ experiments: { test: {} } }); 40 | expect(Object.keys(assets).length).toEqual(2); 41 | expect(assets['/sw-test.js']).toBeDefined(); 42 | }); 43 | 44 | it('prefixes precache assets with the publicPath', function () { 45 | const options = { cache: { precache: ['.*\\.js'] } }; 46 | const compiler = Compiler(); 47 | compiler.options.output.publicPath = 'https://i.cdn.com/'; 48 | const assets = { 'test-file.js': 'var a = true' }; 49 | runTest({ options, compiler, assets }); 50 | expect(assets['/sw-main.js'].source().includes('https://i.cdn.com/test-file.js')).toEqual(true); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/Headers.js: -------------------------------------------------------------------------------- 1 | // stubs https://developer.mozilla.org/en-US/docs/Web/API/Headers 2 | 3 | class Headers { 4 | constructor(meta) { 5 | if (typeof meta === 'undefined') { 6 | // https://github.com/GoogleChrome/workbox/issues/1461 7 | console.warn('Constructing headers with an undefined argument fails in ' 8 | + 'Chrome <= 56 and Samsung Internet ~6.4. You should use `new Headers({})`.' 9 | ); 10 | } 11 | 12 | if (meta && meta instanceof Headers) { 13 | this._map = new Map(meta._map); 14 | } else if (meta && typeof meta === 'object') { 15 | this._map = new Map(Object.entries(meta) 16 | .map(entry => [entry[0].toLowerCase(), entry[1]]) 17 | ); 18 | } else { 19 | this._map = new Map(); 20 | } 21 | } 22 | 23 | append(name, value) { 24 | if (this._map.has(name.toLowerCase())) { 25 | value = `${this._map.get(name.toLowerCase())},${value}`; 26 | } 27 | this._map.set(name.toLowerCase(), value); 28 | } 29 | 30 | delete(name) { 31 | this._map.delete(name.toLowerCase()); 32 | } 33 | 34 | entries() { 35 | return this._map.entries(); 36 | } 37 | 38 | forEach(callback) { 39 | return this._map.forEach(callback); 40 | } 41 | 42 | get(name) { 43 | return this._map.has(name.toLowerCase()) ? this._map.get(name.toLowerCase()) : null; 44 | } 45 | 46 | has(name) { 47 | return this._map.has(name.toLowerCase()); 48 | } 49 | 50 | keys() { 51 | return this._map.keys(); 52 | } 53 | 54 | set(name, value) { 55 | this._map.set(name.toLowerCase(), value); 56 | } 57 | 58 | values() { 59 | return this._map.values(); 60 | } 61 | 62 | [Symbol.iterator]() { 63 | return this._map.values(); 64 | } 65 | } 66 | 67 | Headers.Headers = (meta) => new Headers(meta); 68 | 69 | module.exports = Headers; 70 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/ExtendableEvent.js: -------------------------------------------------------------------------------- 1 | // Derived from https://github.com/GoogleChrome/workbox 2 | 3 | const Event = require('./Event'); 4 | 5 | // ExtendableEvent 6 | // https://www.w3.org/TR/service-workers-1/#extendable-event 7 | class ExtendableEvent extends Event { 8 | constructor(...args) { 9 | super(...args); 10 | 11 | this.response = null; 12 | 13 | // https://www.w3.org/TR/service-workers-1/#dfn-extend-lifetime-promises 14 | this._extendLifetimePromises = new Set(); 15 | 16 | // Used to keep track of all ExtendableEvent instances. 17 | _allExtendableEvents.add(this); 18 | } 19 | 20 | waitUntil(promise) { 21 | this._extendLifetimePromises.add(promise); 22 | } 23 | } 24 | 25 | 26 | // WORKBOX TODO: if workbox wants to use service-worker-mocks only, 27 | // it needs to migrate at https://github.com/GoogleChrome/workbox/blob/912080a1bf3255c61151ca3d0ebd0895aaf377e2/test/workbox-google-analytics/node/test-index.mjs#L19 28 | // and import `eventsDoneWaiting` from the `ExtendableEvent` 29 | let _allExtendableEvents = new Set(); 30 | ExtendableEvent._allExtendableEvents = _allExtendableEvents; 31 | ExtendableEvent.eventsDoneWaiting = () => { 32 | const allExtendLifetimePromises = []; 33 | 34 | // Create a single list of _extendLifetimePromises values in all events. 35 | // Also add `catch` handlers to each promise so all of them are run, rather 36 | // that the normal behavior `Promise.all` erroring at the first error. 37 | for (const event of _allExtendableEvents) { 38 | const extendLifetimePromisesOrErrors = [...event._extendLifetimePromises] 39 | .map((promise) => promise.catch((err) => err)); 40 | 41 | allExtendLifetimePromises.push(...extendLifetimePromisesOrErrors); 42 | } 43 | 44 | return Promise.all(allExtendLifetimePromises); 45 | }; 46 | 47 | module.exports = ExtendableEvent; 48 | -------------------------------------------------------------------------------- /testing/fixtures.js: -------------------------------------------------------------------------------- 1 | /* External Fixtures */ 2 | 3 | const Cache = (overrides) => Object.assign({ 4 | match: jest.fn(() => Promise.resolve()), 5 | put: jest.fn(() => Promise.resolve()), 6 | }, overrides); 7 | 8 | const Event = (overrides) => Object.assign({ 9 | waitUntil: jest.fn(), 10 | }, overrides); 11 | 12 | const Notification = (overrides) => Object.assign({ 13 | tag: 'default-tag', 14 | data: {}, 15 | close: jest.fn(), 16 | }, overrides); 17 | 18 | const NotificationClickEvent = (notification) => Object.assign(Event(), { 19 | notification: Notification(notification), 20 | }); 21 | 22 | const NotificationData = (overrides) => Object.assign({ 23 | title: 'MOCK_TITLE', 24 | image: 'MOCK_IMAGE.png', 25 | body: 'MOCK_BODY', 26 | }, overrides); 27 | 28 | const PushNotificationEvent = (notification) => Object.assign(Event(), Notification(notification)); 29 | 30 | const Subscription = (overrides) => Object.assign({ 31 | subscriptionId: '12345', 32 | endpoint: '/12345', 33 | }, overrides); 34 | 35 | /* Internal Fixtures */ 36 | 37 | const $LocationMap = (override) => override || ({ 38 | main: '/sw-main.js', 39 | test: '/sw-test.js', 40 | }); 41 | 42 | const $Cache = (override) => override || ({ 43 | 44 | }); 45 | 46 | const $Notifications = (override) => override || ({ 47 | default: { 48 | title: 'Service Workers', 49 | body: 'You’ve got everything working!', 50 | icon: 'https://developers.google.com/web/images/web-fundamentals-icon192x192.png', 51 | tag: 'default-push-notification', 52 | data: { 53 | url: 'https://github.com/pinterest/service-workers', 54 | }, 55 | } 56 | }); 57 | 58 | const $Log = (override) => override || ({ 59 | installed: '__/sw/installed', 60 | notificationClicked: '__/sw/notif-clicked', 61 | notificationReceived: '__/sw/notif-received' 62 | }); 63 | 64 | module.exports = { 65 | // External 66 | Cache, 67 | Event, 68 | Notification, 69 | NotificationClickEvent, 70 | NotificationData, 71 | PushNotificationEvent, 72 | Subscription, 73 | // Internal 74 | $LocationMap, 75 | $Cache, 76 | $Notifications, 77 | $Log, 78 | }; 79 | -------------------------------------------------------------------------------- /packages/service-worker-mock/__tests__/fixtures/basic.js: -------------------------------------------------------------------------------- 1 | const PRECACHE = 'precache-v1'; 2 | const RUNTIME = 'runtime'; 3 | 4 | // A list of local resources we always want to be cached. 5 | const PRECACHE_URLS = [ 6 | 'index.html', 7 | './', // Alias for index.html 8 | 'styles.css', 9 | '../../styles/main.css', 10 | 'demo.js' 11 | ]; 12 | 13 | // The install handler takes care of precaching the resources we always need. 14 | self.addEventListener('install', event => { 15 | event.waitUntil( 16 | caches.open(PRECACHE) 17 | .then(cache => cache.addAll(PRECACHE_URLS)) 18 | .then(self.skipWaiting()) 19 | ); 20 | }); 21 | 22 | // The activate handler takes care of cleaning up old caches. 23 | self.addEventListener('activate', event => { 24 | const currentCaches = [PRECACHE, RUNTIME]; 25 | event.waitUntil( 26 | caches.keys().then(cacheNames => { 27 | return cacheNames.filter(cacheName => !currentCaches.includes(cacheName)); 28 | }).then(cachesToDelete => { 29 | return Promise.all(cachesToDelete.map(cacheToDelete => { 30 | return caches.delete(cacheToDelete); 31 | })); 32 | }).then(() => self.clients.claim()) 33 | ); 34 | }); 35 | 36 | // The fetch handler serves responses for same-origin resources from a cache. 37 | // If no response is found, it populates the runtime cache with the response 38 | // from the network before returning it to the page. 39 | self.addEventListener('fetch', event => { 40 | // Skip cross-origin requests, like those for Google Analytics. 41 | if (event.request.url.startsWith(self.location.origin)) { 42 | event.respondWith( 43 | caches.match(event.request).then(cachedResponse => { 44 | if (cachedResponse) { 45 | return cachedResponse; 46 | } 47 | 48 | return caches.open(RUNTIME).then(cache => { 49 | return fetch(event.request).then(response => { 50 | // Put a copy of the response in the runtime cache. 51 | return cache.put(event.request, response.clone()).then(() => { 52 | return response; 53 | }); 54 | }); 55 | }); 56 | }) 57 | ); 58 | } 59 | }); 60 | -------------------------------------------------------------------------------- /packages/service-worker-mock/__tests__/Response.js: -------------------------------------------------------------------------------- 1 | const Response = require('../models/Response'); 2 | const Headers = require('../models/Headers'); 3 | 4 | describe('Response', () => { 5 | it('should create an error Response', () => { 6 | const response = Response.error(); 7 | expect(response.type).toEqual('error'); 8 | expect(response.body).toBeNull(); 9 | expect(response.status).toEqual(0); 10 | }); 11 | 12 | it('can be created without body', () => { 13 | const stringUrl = 'http://test.com/resource.html'; 14 | const res = new Response(null, { 15 | url: stringUrl, 16 | status: 404, 17 | statusText: 'not found' 18 | }); 19 | 20 | expect(res.url).toEqual(stringUrl); 21 | expect(res.status).toEqual(404); 22 | expect(res.statusText).toEqual('not found'); 23 | }); 24 | 25 | it('has default values ', () => { 26 | const res = new Response(null); 27 | 28 | expect(res.url).toEqual('http://example.com/asset'); 29 | expect(res.status).toEqual(200); 30 | expect(res.statusText).toEqual('OK'); 31 | expect(res.type).toEqual('basic'); 32 | expect(res.headers).toBeInstanceOf(Headers); 33 | }); 34 | 35 | it('can use object headers', () => { 36 | const res = new Response(null, { 37 | headers: { 38 | 'X-Custom': 'custom-value' 39 | } 40 | }); 41 | 42 | expect(res.headers.get('X-Custom')).toEqual('custom-value'); 43 | }); 44 | 45 | it('redirect creates a redirect response', () => { 46 | const stringUrl = 'http://test.com/resource.html'; 47 | const res = Response.redirect(stringUrl, 301); 48 | 49 | expect(res.headers.get('location')).toEqual(stringUrl); 50 | expect(res.status).toEqual(301); 51 | }); 52 | 53 | it('throws RangeError for a wrong status code', () => { 54 | expect(() => { 55 | Response.redirect('https://google.com/', 200); 56 | }).toThrow(RangeError); 57 | }); 58 | 59 | it('uses 302 as the default redirect code', () => { 60 | const res = Response.redirect('http://test.com/resource.html'); 61 | expect(res.status).toEqual(302); 62 | }); 63 | 64 | it('should throw when trying to read body from opaque response'); 65 | }); 66 | -------------------------------------------------------------------------------- /packages/service-worker-mock/__tests__/Cache.js: -------------------------------------------------------------------------------- 1 | const makeServiceWorkerEnv = require('../index'); 2 | 3 | describe('Cache', () => { 4 | beforeEach(() => { 5 | Object.assign(global, makeServiceWorkerEnv()); 6 | jest.resetModules(); 7 | }); 8 | 9 | it('gets all cache keys (relative url)', async () => { 10 | const cache = new Cache(); 11 | 12 | cache.put('/test1.html', new Response()); 13 | cache.put('/test2.html', new Response()); 14 | 15 | const keys = await cache.keys(); 16 | 17 | // The Cache mock stores relative urls as well (non-standard) 18 | expect(keys.length).toBe(4); 19 | }); 20 | 21 | it('gets all cache keys (absolute url)', async () => { 22 | const cache = new Cache(); 23 | 24 | cache.put('http://test.com/test1.html', new Response()); 25 | cache.put('http://test.com/test2.html', new Response()); 26 | 27 | const keys = await cache.keys(); 28 | 29 | expect(keys.length).toBe(2); 30 | }); 31 | 32 | it('gets all matching cache keys for string (relative url)', async () => { 33 | const cache = new Cache(); 34 | 35 | cache.put('/test1.html', new Response()); 36 | cache.put('/test2.html', new Response()); 37 | 38 | const keys = await cache.keys('/test1.html'); 39 | 40 | // The Cache mock stores relative urls as well (non-standard) 41 | expect(keys.length).toBe(2); 42 | }); 43 | 44 | it('gets all matching cache keys for string (absolute url)', async () => { 45 | const cache = new Cache(); 46 | 47 | cache.put('http://test.com/test1.html', new Response()); 48 | cache.put('http://test.com/test2.html', new Response()); 49 | 50 | const keys = await cache.keys('http://test.com/test1.html'); 51 | 52 | // The Cache mock stores relative urls as well (non-standard) 53 | expect(keys.length).toBe(1); 54 | }); 55 | 56 | it('gets all matching cache keys for request', async () => { 57 | const cache = new Cache(); 58 | 59 | cache.put('http://test.com/test1.html', new Response()); 60 | cache.put('http://test.com/test2.html', new Response()); 61 | 62 | const keys = await cache.keys(new Request('http://test.com/test1.html')); 63 | 64 | expect(keys.length).toBe(1); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /packages/service-worker-plugin/templates/__tests__/runtime.js: -------------------------------------------------------------------------------- 1 | const fixtures = require('../../../../testing/fixtures'); 2 | // Injected vars 3 | global.$LocationMap = fixtures.$LocationMap(); 4 | // Import main module 5 | const runtime = require('../runtime'); 6 | 7 | global.navigator = { 8 | serviceWorker: { 9 | get ready() { 10 | return Promise.resolve(navigator.serviceWorker); 11 | }, 12 | pushManager: { 13 | subscribe: jest.fn() 14 | }, 15 | register: jest.fn(() => Promise.resolve()) 16 | } 17 | }; 18 | 19 | describe('[service-worker-plugin/templates] runtime', function () { 20 | afterEach(() => { 21 | global.navigator.serviceWorker.register.mockClear(); 22 | }); 23 | 24 | it('> register should register a service worker', async () => { 25 | await runtime.register(); 26 | expect(navigator.serviceWorker.register.mock.calls.length).toEqual(1); 27 | expect(navigator.serviceWorker.register.mock.calls[0][0]).toEqual($LocationMap.main); 28 | }); 29 | 30 | it('> register should register an experimental service worker', async () => { 31 | await runtime.register('test'); 32 | expect(navigator.serviceWorker.register.mock.calls.length).toEqual(1); 33 | expect(navigator.serviceWorker.register.mock.calls[0][0]).toEqual($LocationMap.test); 34 | }); 35 | 36 | it('> register should throw for an invalid experimental service worker key', function () { 37 | expect(runtime.register.bind(null, 'blah')).toThrow(); 38 | }); 39 | 40 | it('> requestNotificationsPermission should try to subscribe', async () => { 41 | await runtime.requestNotificationsPermission('test'); 42 | const calls = navigator.serviceWorker.pushManager.subscribe.mock.calls; 43 | expect(calls.length).toEqual(1); 44 | expect(calls[0][0]).toEqual({ userVisibleOnly: true }); 45 | }); 46 | 47 | it('> register should return a promise whether or not a serviceWorker is supported', function () { 48 | const oldServiceWorker = navigator.serviceWorker; 49 | delete navigator.serviceWorker; 50 | expect(runtime.register().then(() => {}).then).toBeDefined(); 51 | navigator.serviceWorker = oldServiceWorker; 52 | expect(runtime.register().then(() => {}).then).toBeDefined(); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /demo/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const webpack = require('webpack'); 3 | const ServiceWorkerPlugin = require('../packages/service-worker-plugin'); 4 | 5 | const DEFAULT_SW_CONFIG = { 6 | writePath: path.join(process.cwd(), 'demo'), 7 | debug: true, 8 | }; 9 | 10 | module.exports = { 11 | entry: { 12 | bundle: [ 13 | 'webpack-dev-server/client?http://localhost:3000/', 14 | path.join(process.cwd(), "packages/service-worker-plugin/index.js"), 15 | path.join(process.cwd(), "packages/service-worker-plugin/templates/runtime.js"), 16 | path.join(process.cwd(), "packages/generate-service-worker/index.js"), 17 | path.join(process.cwd(), "packages/generate-service-worker/templates/cache.js"), 18 | path.join(process.cwd(), "packages/generate-service-worker/templates/notifications.js"), 19 | ], 20 | runtime: [ 21 | path.join(process.cwd(), "demo/index.js"), 22 | ] 23 | }, 24 | output: { 25 | path: path.resolve(__dirname, "demo"), 26 | publicPath: '/', 27 | filename: "[name].js" 28 | }, 29 | externals: { 30 | "fs": true 31 | }, 32 | plugins: [ 33 | new webpack.HotModuleReplacementPlugin(), 34 | new ServiceWorkerPlugin(DEFAULT_SW_CONFIG, { 35 | withCache: Object.assign({}, DEFAULT_SW_CONFIG, { 36 | cache: { 37 | offline: true, 38 | precache: [ 39 | '.*\\.js$' 40 | ], 41 | strategy: [{ 42 | type: 'prefer-cache', 43 | matches: ['.*\\.png$', '.*\\.js$'] 44 | }] 45 | }, 46 | }), 47 | withNotifications: Object.assign({}, DEFAULT_SW_CONFIG, { 48 | notifications: { 49 | default: { 50 | title: 'SW Plugin', 51 | body: 'You’ve got everything working!', 52 | icon: 'https://developers.google.com/web/images/web-fundamentals-icon192x192.png', 53 | tag: 'default-push-notification', 54 | data: { 55 | url: 'https://github.com/pinterest/service-workers', 56 | }, 57 | }, 58 | }, 59 | }), 60 | withCustomTemplate: Object.assign({}, DEFAULT_SW_CONFIG, { 61 | template: path.join(__dirname, 'customMainTemplate.js') 62 | }) 63 | }) 64 | ] 65 | }; 66 | -------------------------------------------------------------------------------- /packages/generate-service-worker/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const hash = require('./utils/hash'); 6 | const validate = require('./utils/validators').validate; 7 | 8 | const TEMPLATE_PATH = path.join(__dirname, 'templates'); 9 | 10 | function buildMainTemplate(config) { 11 | const template = config.template || path.join(TEMPLATE_PATH, 'main.js'); 12 | return fs.readFileSync(template, 'utf-8'); 13 | } 14 | 15 | function buildCacheTemplate(config) { 16 | if (!config.cache) { 17 | return ''; 18 | } 19 | const template = config.cache.template || path.join(TEMPLATE_PATH, 'cache.js'); 20 | return fs.readFileSync(template, 'utf-8'); 21 | } 22 | 23 | function buildNotificationsTemplate(config) { 24 | if (!config.notifications) { 25 | return ''; 26 | } 27 | const template = config.notifications.template || path.join(TEMPLATE_PATH, 'notifications.js'); 28 | return fs.readFileSync(template, 'utf-8'); 29 | } 30 | 31 | function buildServiceWorker(config) { 32 | const Cache = config.cache ? JSON.stringify(config.cache, null, 2) : 'undefined'; 33 | const Notifications = config.notifications ? JSON.stringify(config.notifications, null, 2) : 'undefined'; 34 | const Log = config.log ? JSON.stringify(config.log, null, 2) : '{}'; 35 | return [ 36 | '/*\n * AUTOGENERATED FROM GENERATE-SERVICE-WORKER\n */\n', 37 | `const $VERSION = '${hash(config)}';`, 38 | `const $DEBUG = ${config.debug || false};`, 39 | `const $Cache = ${Cache};`, 40 | `const $Notifications = ${Notifications};`, 41 | `const $Log = ${Log};\n`, 42 | buildMainTemplate(config), 43 | buildCacheTemplate(config), 44 | buildNotificationsTemplate(config) 45 | ].join('\n'); 46 | } 47 | 48 | /* 49 | * Public API. This method will generate a root service worker and any number of 50 | * extended configuration service workers (used for testing/experimentation). 51 | * @returns Object { [key]: service-worker } 52 | */ 53 | module.exports = function generateServiceWorkers(baseConfig, experimentConfigs) { 54 | validate(baseConfig); 55 | 56 | const serviceWorkers = { 57 | main: buildServiceWorker(baseConfig) 58 | }; 59 | 60 | Object.keys(experimentConfigs || {}).forEach(key => { 61 | validate(experimentConfigs[key]); 62 | serviceWorkers[key] = buildServiceWorker(experimentConfigs[key]); 63 | }); 64 | 65 | return serviceWorkers; 66 | }; 67 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/Response.js: -------------------------------------------------------------------------------- 1 | // stubs https://developer.mozilla.org/en-US/docs/Web/API/Response 2 | const Body = require('./Body'); 3 | const Headers = require('./Headers'); 4 | 5 | const isSupportedBodyType = (body) => 6 | (body === null) || 7 | (body instanceof Blob) || 8 | (typeof body === 'string'); 9 | 10 | class Response extends Body { 11 | constructor(body = null, options = {}) { 12 | if (!isSupportedBodyType(body)) { 13 | throw new TypeError('Response body must be one of: Blob, USVString, null'); 14 | } 15 | super(body, options); 16 | this.status = typeof options.status === 'number' 17 | ? options.status 18 | : 200; 19 | this.ok = this.status >= 200 && this.status < 300; 20 | this.statusText = options.statusText || 'OK'; 21 | 22 | if (options.headers) { 23 | if (options.headers instanceof Headers) { 24 | this.headers = options.headers; 25 | } else if (typeof options.headers === 'object') { 26 | this.headers = new Headers(options.headers); 27 | } else { 28 | throw new TypeError('Cannot construct response.headers: invalid data'); 29 | } 30 | } else { 31 | this.headers = new Headers({}); 32 | } 33 | 34 | this.type = this.status === 0 ? 'opaque' : 'basic'; 35 | this.redirected = false; 36 | this.url = options.url || 'http://example.com/asset'; 37 | this.method = options.method || 'GET'; 38 | } 39 | 40 | clone() { 41 | return new Response(this.body, { 42 | status: this.status, 43 | statusText: this.statusText, 44 | headers: this.headers, 45 | url: this.url 46 | }); 47 | } 48 | 49 | /** 50 | * Creates a new response with a different URL. 51 | * @param url The URL that the new response is to originate from. 52 | * @param status [Optional] An optional status code for the response (e.g., 302.) 53 | * @returns {Response} 54 | */ 55 | static redirect(url, status = 302) { 56 | // see https://fetch.spec.whatwg.org/#dom-response-redirect 57 | if (![301, 302, 303, 307, 308].includes(status)) { 58 | throw new RangeError('Invalid status code'); 59 | } 60 | return new Response(null, { 61 | status: status, 62 | headers: { Location: new URL(url).href } 63 | }); 64 | } 65 | 66 | static error() { 67 | const errorResponse = new Response(null, { 68 | url: '', 69 | headers: {}, 70 | status: 0 71 | }); 72 | 73 | errorResponse.type = 'error'; 74 | 75 | return errorResponse; 76 | } 77 | } 78 | 79 | module.exports = Response; 80 | -------------------------------------------------------------------------------- /packages/service-worker-mock/__tests__/basic.js: -------------------------------------------------------------------------------- 1 | const makeServiceWorkerEnv = require('../index'); 2 | 3 | // https://github.com/GoogleChrome/samples/blob/gh-pages/service-worker/basic/service-worker.js 4 | describe('basic', () => { 5 | beforeEach(() => { 6 | Object.assign(global, makeServiceWorkerEnv()); 7 | jest.resetModules(); 8 | }); 9 | 10 | it('should attach the listeners', () => { 11 | require('./fixtures/basic'); 12 | expect(Object.keys(self.listeners).length).toEqual(3); 13 | }); 14 | 15 | it('should precache the PRECACHE_URLS on install', async () => { 16 | global.fetch = () => Promise.resolve('FAKE_RESPONSE'); 17 | require('./fixtures/basic'); 18 | 19 | await self.trigger('install'); 20 | const caches = self.snapshot().caches; 21 | Object.keys(caches['precache-v1']).forEach(key => { 22 | expect(caches['precache-v1'][key]).toEqual('FAKE_RESPONSE'); 23 | }); 24 | }); 25 | 26 | it('should delete old caches on activate', async () => { 27 | self.caches.open('TEST'); 28 | expect(self.snapshot().caches.TEST).toBeDefined(); 29 | require('./fixtures/basic'); 30 | 31 | await self.trigger('activate'); 32 | expect(self.snapshot().caches.TEST).toBeUndefined(); 33 | }); 34 | 35 | it('should return a cached response', async () => { 36 | require('./fixtures/basic'); 37 | 38 | const cachedResponse = { clone: () => {} }; 39 | const cachedRequest = new Request('/test'); 40 | const cache = await self.caches.open('TEST'); 41 | cache.put(cachedRequest, cachedResponse); 42 | 43 | const response = await self.trigger('fetch', cachedRequest); 44 | expect(response).toEqual(cachedResponse); 45 | }); 46 | 47 | it('should fetch and cache an uncached request', async () => { 48 | const mockResponse = { clone: () => mockResponse }; 49 | global.fetch = () => Promise.resolve(mockResponse); 50 | require('./fixtures/basic'); 51 | 52 | const request = new Request('/test'); 53 | const response = await self.trigger('fetch', request); 54 | expect(response).toEqual(mockResponse); 55 | const runtimeCache = self.snapshot().caches.runtime; 56 | expect(runtimeCache[request.url]).toEqual(mockResponse); 57 | }); 58 | 59 | it('should add clientId to fetch event', async () => { 60 | expect.assertions(1); 61 | 62 | self.addEventListener('fetch', event => { 63 | expect(event.clientId).toBe('testClientId'); 64 | return Promise.resolve(); 65 | }); 66 | 67 | const request = new Request('/test'); 68 | await self.trigger('fetch', { request, clientId: 'testClientId' }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /packages/service-worker-mock/__tests__/Headers.js: -------------------------------------------------------------------------------- 1 | const Headers = require('../models/Headers'); 2 | 3 | describe('Headers', () => { 4 | it('should construct with no defaults', () => { 5 | const headers = new Headers({}); 6 | expect(headers.get('accept')).toEqual(null); 7 | }); 8 | 9 | it('should construct with Header instance', () => { 10 | const _headers = new Headers({}); 11 | _headers.set('accept', '*/*'); 12 | const headers = new Headers(_headers); 13 | expect(headers.get('accept')).toEqual('*/*'); 14 | }); 15 | 16 | it('should construct with object', () => { 17 | const headers = new Headers({ accept: '*/*' }); 18 | expect(headers.get('accept')).toEqual('*/*'); 19 | }); 20 | 21 | it('should ignore character case', () => { 22 | const headers = new Headers({}); 23 | headers.set('UPPER', 'UPPER'); 24 | expect(headers.get('upper')).toEqual('UPPER'); 25 | 26 | headers.append('UPPER', 'CASE'); 27 | expect(headers.get('upper')).toEqual('UPPER,CASE'); 28 | 29 | headers.delete('UPPER'); 30 | expect(headers.get('upper')).toEqual(null); 31 | }); 32 | 33 | it('should append values', () => { 34 | const headers = new Headers({}); 35 | headers.append('accept', 'application/json'); 36 | headers.append('accept', 'text/javascript'); 37 | expect(headers.get('accept')).toEqual('application/json,text/javascript'); 38 | }); 39 | 40 | it('should be able to set entries', () => { 41 | const headers = new Headers({}); 42 | headers.set('accept', 'application/json'); 43 | expect(headers.get('accept')).toEqual('application/json'); 44 | }); 45 | 46 | it('should check for existing keys', () => { 47 | const headers = new Headers({}); 48 | headers.set('accept', 'application/json'); 49 | expect(headers.has('accept')).toBe(true); 50 | }); 51 | 52 | it('should check for existing keys (false positive)', () => { 53 | const headers = new Headers({}); 54 | headers.set('accept', 'application/json'); 55 | expect(headers.has('accept-not')).toBe(false); 56 | }); 57 | 58 | it('should be iterable', () => { 59 | const headers = new Headers({ 60 | accept: 'test', 61 | connection: 'test' 62 | }); 63 | for (let value of headers) { 64 | expect(value).toEqual('test'); 65 | } 66 | }); 67 | 68 | it('should be iterable via `forEach`', () => { 69 | expect.assertions(2); 70 | 71 | const headers = new Headers({ 72 | accept: 'test', 73 | connection: 'test' 74 | }); 75 | 76 | headers.forEach(value => { 77 | expect(value).toEqual('test'); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /packages/service-worker-plugin/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const mkdirp = require('mkdirp'); 4 | const generateServiceWorkers = require('../generate-service-worker'); 5 | const generateRuntime = require('./utils/generateRuntime'); 6 | const mapPrecacheAssets = require('./utils/mapPrecacheAssets'); 7 | 8 | const runtimePath = path.resolve(__dirname, 'runtime.js'); 9 | 10 | function ProgressiveWebappPlugin(baseConfig, experimentConfigs) { 11 | this.baseConfig = baseConfig || {}; 12 | this.experimentConfigs = experimentConfigs || {}; 13 | } 14 | 15 | ProgressiveWebappPlugin.prototype.apply = function (compiler) { 16 | const publicPath = this.baseConfig.publicPath || compiler.options.output.publicPath; 17 | const writePath = this.baseConfig.writePath || compiler.options.output.path; 18 | mkdirp.sync(writePath); 19 | 20 | // Write the runtime file 21 | const workerKeys = ['main'].concat(Object.keys(this.experimentConfigs)); 22 | const generatedRuntime = generateRuntime(workerKeys, this.baseConfig.runtimePath || publicPath); 23 | fs.writeFileSync(runtimePath, generatedRuntime); 24 | 25 | // Generate service workers 26 | const emit = (compilation, callback) => { 27 | const assets = compilation.assets; 28 | // Update configs with matched precache asset paths 29 | const baseConfigWithPrecache = mapPrecacheAssets(assets, this.baseConfig, publicPath); 30 | const expConfigsWithPrecache = Object.keys(this.experimentConfigs).reduce((result, key) => { 31 | // eslint-disable-next-line no-param-reassign 32 | result[key] = mapPrecacheAssets(assets, this.experimentConfigs[key], publicPath); 33 | return result; 34 | }, {}); 35 | const serviceWorkers = generateServiceWorkers(baseConfigWithPrecache, expConfigsWithPrecache); 36 | 37 | // Write files to file system 38 | Object.keys(serviceWorkers).forEach(key => { 39 | const fullWritePath = path.join(writePath, `sw-${key}.js`); 40 | // Write to file system 41 | fs.writeFileSync(fullWritePath, serviceWorkers[key]); 42 | // Add to compilation assets 43 | // eslint-disable-next-line no-param-reassign 44 | assets[fullWritePath] = { 45 | source: function () { 46 | return serviceWorkers[key]; 47 | }, 48 | size: function () { 49 | return serviceWorkers[key].length; 50 | } 51 | }; 52 | }); 53 | 54 | callback(); 55 | }; 56 | if (compiler.hooks) { 57 | const plugin = { name: 'ServiceWorkerPlugin' }; 58 | compiler.hooks.emit.tap(plugin, emit); 59 | } else { 60 | compiler.plugin('emit', emit); 61 | } 62 | }; 63 | 64 | module.exports = ProgressiveWebappPlugin; 65 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/Request.js: -------------------------------------------------------------------------------- 1 | // stubs https://developer.mozilla.org/en-US/docs/Web/API/Request 2 | const Body = require('./Body'); 3 | const Headers = require('./Headers'); 4 | const URL = require('url').URL || require('dom-urls'); 5 | 6 | 7 | const DEFAULT_HEADERS = { 8 | accept: '*/*' 9 | }; 10 | 11 | const throwBodyUsed = () => { 12 | throw new TypeError('Failed to execute \'clone\': body is already used'); 13 | }; 14 | 15 | class Request extends Body { 16 | constructor(urlOrRequest, options = {}) { 17 | let url = urlOrRequest; 18 | if (urlOrRequest instanceof Request) { 19 | url = urlOrRequest.url; 20 | options = Object.assign({}, { 21 | body: urlOrRequest.body, 22 | credentials: urlOrRequest.credentials, 23 | headers: urlOrRequest.headers, 24 | method: urlOrRequest.method, 25 | mode: urlOrRequest.mode, 26 | referrer: urlOrRequest.referrer 27 | }, options); 28 | } else if (typeof url === 'string' && url.length === 0) { 29 | url = '/'; 30 | } 31 | 32 | if (!url) { 33 | throw new TypeError(`Invalid url: ${urlOrRequest}`); 34 | } 35 | 36 | super(options.body, options); 37 | 38 | if (url instanceof URL) { 39 | this.url = url.href; 40 | } else if (self.useRawRequestUrl) { 41 | this.url = url; 42 | } else { 43 | this.url = new URL(url, self.location.href).href; 44 | } 45 | 46 | this.method = options.method || 'GET'; 47 | this.mode = options.mode || 'same-origin'; // FF defaults to cors 48 | this.referrer = options.referrer && options.referrer !== 'no-referrer' ? options.referrer : ''; 49 | // See https://fetch.spec.whatwg.org/#concept-request-credentials-mode 50 | this.credentials = options.credentials || (this.mode === 'navigate' 51 | ? 'include' 52 | : 'omit'); 53 | 54 | // Transform options.headers to Headers object 55 | if (options.headers) { 56 | if (options.headers instanceof Headers) { 57 | this.headers = options.headers; 58 | } else if (typeof options.headers === 'object') { 59 | this.headers = new Headers(options.headers); 60 | } else { 61 | throw new TypeError('Cannot construct request.headers: invalid data'); 62 | } 63 | } else { 64 | this.headers = new Headers(DEFAULT_HEADERS); 65 | } 66 | } 67 | 68 | clone() { 69 | if (this.bodyUsed) { 70 | throwBodyUsed(); 71 | } 72 | 73 | return new Request(this.url, { 74 | method: this.method, 75 | mode: this.mode, 76 | headers: this.headers, 77 | body: this.body ? this.body.clone() : this.body 78 | }); 79 | } 80 | } 81 | 82 | Request.Request = (url, options) => new Request(url, options); 83 | 84 | module.exports = Request; 85 | -------------------------------------------------------------------------------- /packages/service-worker-mock/__tests__/Clients.js: -------------------------------------------------------------------------------- 1 | const makeServiceWorkerEnv = require('../index'); 2 | const Clients = require('../models/Clients'); 3 | const Client = require('../models/Client'); 4 | const WindowClient = require('../models/WindowClient'); 5 | 6 | const CLIENT_FIXTURES = [ 7 | new Client('/'), 8 | new Client('/', 'sharedworker'), 9 | new WindowClient('https://www.abc.com') 10 | ]; 11 | 12 | describe('Clients', () => { 13 | beforeEach(() => { 14 | Object.assign(global, makeServiceWorkerEnv()); 15 | jest.resetModules(); 16 | }); 17 | 18 | it('should get client by id', async () => { 19 | const clients = new Clients(); 20 | clients.clients = CLIENT_FIXTURES; 21 | 22 | const expectedClient = await clients.get(CLIENT_FIXTURES[0].id); 23 | expect(expectedClient.id).toBe(CLIENT_FIXTURES[0].id); 24 | }); 25 | 26 | it('should get array of matched clients', async () => { 27 | const clients = new Clients(); 28 | clients.clients = CLIENT_FIXTURES; 29 | 30 | // Without options 31 | let matchedClients = await clients.matchAll(); 32 | expect(matchedClients).toHaveLength(3); 33 | 34 | // Get specific client by type 35 | matchedClients = await clients.matchAll({ type: 'sharedworker' }); 36 | expect(matchedClients).toHaveLength(1); 37 | }); 38 | 39 | it('should be able to open a new window', async () => { 40 | const clients = new Clients(); 41 | await clients.openWindow('https://www.abc.com'); 42 | 43 | expect(clients.snapshot()).toHaveLength(1); 44 | expect(clients.snapshot()[0].url).toBe('https://www.abc.com'); 45 | }); 46 | 47 | it('should able to receive messages', async () => { 48 | const clients = new Clients(); 49 | const client = await clients.openWindow('https://www.abc.com'); 50 | const messageHandler = jest.fn(); 51 | 52 | client.addEventListener('message', messageHandler); 53 | client.postMessage({ value: 1 }); 54 | 55 | expect(messageHandler).toBeCalledWith(expect.objectContaining({ 56 | data: { 57 | value: 1 58 | } 59 | })); 60 | }); 61 | 62 | it('can use a MessageChannel to receive answers', async () => { 63 | const clients = new Clients(); 64 | const client = await clients.openWindow('https://www.abc.com'); 65 | const channel = new MessageChannel(); 66 | const messageHandler = jest.fn(); 67 | 68 | channel.port1.onmessage = messageHandler; 69 | client.addEventListener('message', (event) => { 70 | event.ports[0].postMessage({ 71 | answer: 1 72 | }); 73 | }); 74 | client.postMessage({ value: 1 }, [channel.port2]); 75 | 76 | expect(messageHandler).toBeCalledWith(expect.objectContaining({ 77 | data: { 78 | answer: 1 79 | } 80 | })); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /packages/service-worker-mock/models/Cache.js: -------------------------------------------------------------------------------- 1 | // https://developer.mozilla.org/en-US/docs/Web/API/Cache 2 | class Cache { 3 | constructor() { 4 | this.store = new Map(); 5 | } 6 | 7 | match(request) { 8 | const url = request.url || request; 9 | if (this.store.has(url)) { 10 | const value = this.store.get(url); 11 | return Promise.resolve(value.response); 12 | } 13 | return Promise.resolve(null); 14 | } 15 | 16 | matchAll(request) { 17 | const url = request.url || request; 18 | if (this.store.has(url)) { 19 | const value = this.store.get(url); 20 | return Promise.resolve([value.response]); 21 | } 22 | return Promise.resolve(null); 23 | } 24 | 25 | add(request) { 26 | return fetch(request).then(response => { 27 | return this.put(request, response); 28 | }); 29 | } 30 | 31 | addAll(requests) { 32 | return Promise.all(requests.map(request => { 33 | return this.add(request); 34 | })); 35 | } 36 | 37 | put(request, response) { 38 | if (typeof request === 'string') { 39 | let relativeUrl = request; 40 | request = new Request(request); 41 | // Add relative url as well (non-standard) 42 | this.store.set(relativeUrl, { request, response }); 43 | } 44 | 45 | this.store.set(request.url, { request, response }); 46 | return Promise.resolve(); 47 | } 48 | 49 | delete(request) { 50 | const url = request.url || request; 51 | return Promise.resolve(this.store.delete(url)); 52 | } 53 | 54 | // https://w3c.github.io/ServiceWorker/#dom-cache-keys 55 | keys(request, options = {}) { 56 | let req = null; 57 | if (request instanceof Request) { 58 | req = request; 59 | if (request.method !== 'GET' && !options.ignoreMethod) { 60 | return Promise.resolve([]); 61 | } 62 | } else if (typeof request === 'string') { 63 | try { 64 | req = new Request(request); 65 | } catch (err) { 66 | return Promise.reject(err); 67 | } 68 | } 69 | 70 | const values = Array.from(this.store.values()); 71 | 72 | if (req) { 73 | return Promise.resolve(values 74 | .filter((value) => { 75 | return value.request.url === req.url; 76 | }) 77 | .map((value) => value.request) 78 | ); 79 | } 80 | 81 | return Promise.resolve(values.map((value) => value.request)); 82 | } 83 | 84 | snapshot() { 85 | const entries = this.store.entries(); 86 | const snapshot = {}; 87 | for (const entry of entries) { 88 | let key = entry[0]; 89 | if (typeof entry[0] === 'object') { 90 | key = JSON.stringify(key); 91 | } 92 | snapshot[key] = entry[1].response; 93 | } 94 | return snapshot; 95 | } 96 | 97 | reset() { 98 | this.store = new Map(); 99 | } 100 | } 101 | 102 | module.exports = Cache; 103 | -------------------------------------------------------------------------------- /packages/generate-service-worker/__tests__/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const generateServiceWorkers = require('../index'); 3 | 4 | const customTemplateCode = 'var foo = "bar";'; 5 | const customTemplatePath = path.join(__dirname, 'mockTemplate'); 6 | const configs = { 7 | default: {}, 8 | invalid: { notifications: { fetchData: 1 } }, 9 | custom: { 10 | main: { template: customTemplatePath }, 11 | cache: { cache: { 12 | template: customTemplatePath 13 | } }, 14 | notifications: { notifications: { 15 | default: { title: 'test' }, 16 | template: customTemplatePath 17 | } } 18 | }, 19 | notifications: { 20 | notifications: { 21 | default: { 22 | title: 'test' 23 | } 24 | } 25 | }, 26 | cache: { 27 | cache: { 28 | precache: ['/test\\.js'], 29 | strategy: [{ 30 | type: 'prefer-cache', 31 | matches: ['*.js'] 32 | }] 33 | } 34 | } 35 | }; 36 | 37 | describe('[generate-service-worker] index', function () { 38 | it('should generate a main service worker', function () { 39 | const serviceWorkers = generateServiceWorkers(configs.default); 40 | expect(serviceWorkers.main).toBeDefined(); 41 | }); 42 | 43 | it('should generate experimental service workers', function () { 44 | const serviceWorkers = generateServiceWorkers(configs.default, { 45 | 'with-notifications': configs.notifications, 46 | 'with-cache': configs.cache 47 | }); 48 | expect(serviceWorkers.main).toBeDefined(); 49 | expect(serviceWorkers['with-notifications']).toBeDefined(); 50 | expect(serviceWorkers['with-cache']).toBeDefined(); 51 | }); 52 | 53 | it('should throw for invalid root configurations', function () { 54 | expect(generateServiceWorkers.bind(null, configs.invalid)).toThrow(); 55 | }); 56 | 57 | it('should throw for invalid experiment configurations', function () { 58 | expect(generateServiceWorkers.bind(null, {}, { test: configs.invalid })).toThrow(); 59 | }); 60 | 61 | describe('> custom templates', () => { 62 | it('should allow custom main templates', () => { 63 | const sw = generateServiceWorkers(configs.custom.main).main; 64 | expect(sw.includes(customTemplateCode)).toEqual(true); 65 | }); 66 | 67 | it('should allow custom cache templates', () => { 68 | const sw = generateServiceWorkers(configs.custom.cache).main; 69 | expect(sw.includes(customTemplateCode)).toEqual(true); 70 | }); 71 | 72 | it('should allow custom notifications templates', () => { 73 | const sw = generateServiceWorkers(configs.custom.notifications).main; 74 | expect(sw.includes(customTemplateCode)).toEqual(true); 75 | }); 76 | }); 77 | 78 | it('should generate a service worker ', function () { 79 | // Object.keys(configs).forEach(function (name) { 80 | // const options = configs[name]; 81 | // expect(generateServiceWorkers(options)).toMatchSnapshot(`service-worker-config-${name}`); 82 | // }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /packages/generate-service-worker/utils/validate.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-throw-literal */ 2 | 3 | function arrayOfTypeValidation(validator) { 4 | return withRequired(function arrayOfType(value) { 5 | if (!Array.isArray(value)) { 6 | throw `Value ${value} must be an array.`; 7 | } 8 | value.every(validator); 9 | }); 10 | } 11 | 12 | function oneOfTypeValidation(types) { 13 | return withRequired(function oneOf(value) { 14 | const isValidType = types.some(function (Type) { 15 | try { 16 | Type(value); 17 | return true; 18 | } catch (e) { 19 | return false; 20 | } 21 | }); 22 | if (!isValidType) { 23 | throw `Value ${value} not a valid type.`; 24 | } 25 | }); 26 | } 27 | 28 | function oneOfValidation(list) { 29 | return withRequired(function oneOf(value) { 30 | if (list.indexOf(value) === -1) { 31 | throw `Value ${value} not a valid option from list: ${list.join(', ')}.`; 32 | } 33 | }); 34 | } 35 | 36 | function shapeValidation(objShape) { 37 | return withRequired(function shape(value) { 38 | if (value && typeof value !== 'object') { 39 | throw `Value <${value}> must be an object.`; 40 | } 41 | Object.keys(objShape).forEach(function shapeKeyValidation(key) { 42 | try { 43 | objShape[key](value[key]); 44 | } catch (e) { 45 | if (objShape[key].name === 'shape') { 46 | throw e; 47 | } else { 48 | throw `Key: "${key}" failed with "${e}"`; 49 | } 50 | } 51 | }); 52 | }); 53 | } 54 | 55 | function booleanValidation(value) { 56 | if (!value || typeof value !== 'boolean') { 57 | throw `Value ${value} must be of type "boolean".`; 58 | } 59 | } 60 | 61 | function objectValidation(value) { 62 | if (!value || typeof value !== 'object') { 63 | throw `Value ${value} must be non-null "object".`; 64 | } 65 | } 66 | 67 | function stringValidation(value) { 68 | if (typeof value !== 'string') { 69 | throw `Value ${value} must be of type "string".`; 70 | } 71 | } 72 | 73 | function numberValidation(value) { 74 | if (typeof value !== 'number') { 75 | throw `Value ${value} must be of type "number".`; 76 | } 77 | } 78 | 79 | function withRequired(_validator) { 80 | function validator(value) { 81 | return value === undefined || _validator(value); 82 | } 83 | 84 | validator.required = function requiredValidator(value) { 85 | if (value === undefined) { 86 | throw 'Value cannot be undefined.'; 87 | } 88 | _validator(value); 89 | }; 90 | return validator; 91 | } 92 | 93 | module.exports = { 94 | boolean: withRequired(booleanValidation), 95 | object: withRequired(objectValidation), 96 | number: withRequired(numberValidation), 97 | string: withRequired(stringValidation), 98 | arrayOfType: arrayOfTypeValidation, 99 | oneOf: oneOfValidation, 100 | oneOfType: oneOfTypeValidation, 101 | shape: shapeValidation 102 | }; 103 | -------------------------------------------------------------------------------- /packages/generate-service-worker/utils/__tests__/validate.js: -------------------------------------------------------------------------------- 1 | const V = require('../validate'); 2 | 3 | describe('[generate-service-worker/utils] validate', function () { 4 | it('V.string should pass for string', function () { 5 | expect(V.string.bind(null, 'test')).not.toThrow(); 6 | }); 7 | 8 | it('V.string should require a string', function () { 9 | expect(V.string.bind(null, 5)).toThrow('Value 5 must be of type "string".'); 10 | }); 11 | 12 | it('V.string should have a required property', function () { 13 | expect(V.string.required.bind(null, undefined)).toThrow('Value cannot be undefined.'); 14 | }); 15 | 16 | it('V.number should pass for number', function () { 17 | expect(V.number.bind(null, 5)).not.toThrow(); 18 | }); 19 | 20 | it('V.number should require a number', function () { 21 | expect(V.number.bind(null, 'test')).toThrow('Value test must be of type "number".'); 22 | }); 23 | 24 | it('V.number should have a required property', function () { 25 | expect(V.number.required.bind(null, undefined)).toThrow('Value cannot be undefined.'); 26 | }); 27 | 28 | it('V.shape should pass for valid array', function () { 29 | expect(V.shape({ test: V.string }).bind(null, { test: 'test' })).not.toThrow(); 30 | }); 31 | 32 | it('V.shape should throw if not of shape', function () { 33 | expect(V.shape({ test: V.string.required }).bind(null, {})).toThrow('Key: "test" failed with "Value cannot be undefined."'); 34 | }); 35 | 36 | it('V.shape should have a required property', function () { 37 | expect(V.shape(V.string).required.bind(null, undefined)).toThrow('Value cannot be undefined.'); 38 | }); 39 | 40 | it('V.arrayOf should pass for valid array', function () { 41 | expect(V.arrayOfType(V.string).bind(null, ['test'])).not.toThrow(); 42 | }); 43 | 44 | it('V.arrayOf should throw if not an array', function () { 45 | expect(V.arrayOfType(V.string).bind(null, 'test')).toThrow('Value test must be an array.'); 46 | }); 47 | 48 | it('V.arrayOf should throw if not of correct type', function () { 49 | expect(V.arrayOfType(V.string).bind(null, [5])).toThrow('Value 5 must be of type "string".'); 50 | }); 51 | 52 | it('V.arrayOf should have a required property', function () { 53 | expect(V.arrayOfType(V.string).required.bind(null, undefined)).toThrow('Value cannot be undefined.'); 54 | }); 55 | 56 | it('V.oneOfType should pass for listed value', function () { 57 | expect(V.oneOfType([V.string]).bind(null, 'test')).not.toThrow(); 58 | }); 59 | 60 | it('V.oneOfType should throw if value not in list', function () { 61 | expect(V.oneOfType([V.string]).bind(null, 5)).toThrow('Value 5 not a valid type.'); 62 | }); 63 | 64 | it('V.oneOfType should have a required property', function () { 65 | expect(V.oneOfType(V.string).required.bind(null, undefined)).toThrow('Value cannot be undefined.'); 66 | }); 67 | 68 | it('V.oneOf should pass for listed value', function () { 69 | expect(V.oneOf(['test']).bind(null, 'test')).not.toThrow(); 70 | }); 71 | 72 | it('V.oneOf should throw if value not in list', function () { 73 | expect(V.oneOf(['test']).bind(null, 5)).toThrow('Value 5 not a valid option from list: test.'); 74 | }); 75 | 76 | it('V.oneOf should have a required property', function () { 77 | expect(V.oneOf(['test']).required.bind(null, undefined)).toThrow('Value cannot be undefined.'); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /packages/service-worker-mock/__tests__/ServiceWorkerGlobalScope.js: -------------------------------------------------------------------------------- 1 | const makeServiceWorkerEnv = require('../index'); 2 | 3 | describe('installation', () => { 4 | it('should make a valid service worker environment', () => { 5 | Object.assign(global, makeServiceWorkerEnv()); 6 | expect(self).toBeDefined(); 7 | expect(self instanceof ServiceWorkerGlobalScope).toBe(true); 8 | }); 9 | 10 | it('should allow location overrides', () => { 11 | Object.assign(global, makeServiceWorkerEnv({ 12 | locationUrl: '/scope', 13 | locationBase: 'https://oh.yeah' 14 | })); 15 | expect(self.location instanceof URL).toBe(true); 16 | expect(self.location.href).toEqual('https://oh.yeah/scope'); 17 | }); 18 | 19 | describe('environment resets', () => { 20 | Object.assign(global, makeServiceWorkerEnv()); 21 | 22 | it('should allow resetting listeners', () => { 23 | expect(self.listeners.size).toEqual(0); 24 | self.addEventListener('fetch', () => {}); 25 | expect(self.listeners.size).toEqual(1); 26 | self.listeners.reset(); 27 | expect(self.listeners.size).toEqual(0); 28 | }); 29 | 30 | it('should allow resetting caches', async () => { 31 | expect(await self.caches.has('TEST')).toBe(false); 32 | await self.caches.open('TEST'); 33 | expect(await self.caches.has('TEST')).toBe(true); 34 | self.caches.reset(); 35 | expect(await self.caches.has('TEST')).toBe(false); 36 | }); 37 | 38 | it('should allow resetting an individual cache', async () => { 39 | const testResponse = new Response(); 40 | const cache = await self.caches.open('TEST'); 41 | await cache.put(new Request('/'), testResponse); 42 | expect(await cache.match(new Request('/'))).toBe(testResponse); 43 | cache.reset(); 44 | expect(await cache.match(new Request('/'))).toBe(null); 45 | }); 46 | 47 | it('should allow cacheName option for caches.match', async () => { 48 | const testResponse1 = new Response('body1'); 49 | const testResponse2 = new Response('body2'); 50 | const cache1 = await self.caches.open('TEST1'); 51 | const cache2 = await self.caches.open('TEST2'); 52 | 53 | await cache1.put(new Request('/'), testResponse1); 54 | await cache2.put(new Request('/'), testResponse2); 55 | 56 | const cacheResponse = await caches.match(new Request('/'), { 57 | cacheName: 'TEST2' 58 | }); 59 | 60 | expect(cacheResponse).toBe(testResponse2); 61 | }); 62 | 63 | it('should allow resetting clients', async () => { 64 | const client = await self.clients.openWindow('/'); 65 | expect(await clients.get(client.id)).toBe(client); 66 | clients.reset(); 67 | expect(await clients.get(client.id)).toBe(null); 68 | }); 69 | 70 | it('should allow resetting everything', async () => { 71 | self.addEventListener('fetch', () => {}); 72 | await self.caches.open('TEST'); 73 | const client = await self.clients.openWindow('/'); 74 | 75 | expect(self.listeners.size).toEqual(1); 76 | expect(await self.caches.has('TEST')).toBe(true); 77 | expect(await clients.get(client.id)).toBe(client); 78 | self.resetSwEnv(); 79 | expect(self.listeners.size).toEqual(0); 80 | expect(await self.caches.has('TEST')).toBe(false); 81 | expect(await clients.get(client.id)).toBe(null); 82 | }); 83 | 84 | it('should allow resetting IDB'); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /packages/generate-service-worker/templates/__tests__/notifications.js: -------------------------------------------------------------------------------- 1 | const makeServiceWorkerEnv = require('../../../service-worker-mock'); 2 | const fixtures = require('../../../../testing/fixtures'); 3 | // Injected vars 4 | global.$Cache = fixtures.$Cache(); 5 | global.$Log = fixtures.$Log(); 6 | global.$Notifications = fixtures.$Notifications(); 7 | 8 | describe('[generate-service-worker/templates] notifications', function () { 9 | beforeEach(() => { 10 | Object.assign(global, makeServiceWorkerEnv()); 11 | jest.resetModules(); 12 | global.fetch.mockClear(); 13 | require('../notifications'); 14 | }); 15 | 16 | it('should register events on load', async () => { 17 | expect(self.listeners.get('push').at(0).name).toEqual('handleNotificationPush'); 18 | expect(self.listeners.get('notificationclick').at(0).name).toEqual('handleNotificationClick'); 19 | }); 20 | 21 | describe('> handleNotificationPush', () => { 22 | it('[with valid $Log.notificationReceived] should log notification received', async () => { 23 | await self.trigger('push', { tag: 'default-tag' }); 24 | expect(global.fetch.mock.calls[0][0]).toEqual('__/sw/notif-received?endpoint=test.com/12345&tag=default-tag'); 25 | }); 26 | 27 | it('[with valid event.data] should immediately show notification', async () => { 28 | const event = { data: { title: 'test', tag: 'default-tag' } }; 29 | expect(self.snapshot().notifications.length).toEqual(0); 30 | await self.trigger('push', event); 31 | expect(self.snapshot().notifications[0].title).toEqual(event.data.title); 32 | }); 33 | 34 | it('[with invalid event.data] should show the fallback notification data', async () => { 35 | expect(self.snapshot().notifications.length).toEqual(0); 36 | await self.trigger('push'); 37 | expect(self.snapshot().notifications[0]).toEqual($Notifications.default); 38 | }); 39 | }); 40 | 41 | describe('> handleNotificationClick', () => { 42 | it('should close the notification', async () => { 43 | const event = await self.registration.showNotification('Title', { tag: 'default-tag' }); 44 | expect(self.snapshot().notifications[0].title).toEqual(event.notification.title); 45 | await self.trigger('notificationclick', event.notification); 46 | expect(self.snapshot().notifications.length).toEqual(0); 47 | }); 48 | 49 | it('[with valid data.url] should open a new window', async () => { 50 | const notification = { tag: 'default-tag', data: { url: '/fake/url' } }; 51 | expect(self.snapshot().clients.length).toEqual(0); 52 | 53 | const event = await self.registration.showNotification('Title', notification); 54 | await self.trigger('notificationclick', event.notification); 55 | expect(self.snapshot().clients.length).toEqual(1); 56 | }); 57 | 58 | it('[with $Log.notificationClicked] should log a click', async () => { 59 | const event = await self.registration.showNotification('Title', { tag: 'default-tag' }); 60 | await self.trigger('notificationclick', event.notification); 61 | expect(global.fetch.mock.calls[0][0]).toEqual('__/sw/notif-clicked?endpoint=test.com/12345&tag=default-tag'); 62 | }); 63 | 64 | it('[without logClick] should NOT call fetch without logClick url', async () => { 65 | global.$Log = fixtures.$Log({ notificationClicked: null }); 66 | const event = await self.registration.showNotification('Title', { tag: 'default-tag' }); 67 | await self.trigger('notificationclick', event.notification); 68 | expect(global.fetch.mock.calls.length).toEqual(0); 69 | }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /packages/service-worker-mock/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | browser-process-hrtime@^0.1.2: 6 | version "0.1.3" 7 | resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-0.1.3.tgz#616f00faef1df7ec1b5bf9cfe2bdc3170f26c7b4" 8 | integrity sha512-bRFnI4NnjO6cnyLmOV/7PVoDEMJChlcfN0z4s1YMBY989/SvlfMI1lgCnkFUs53e9gQF+w7qu7XdllSTiSl8Aw== 9 | 10 | dom-urls@^1.1.0: 11 | version "1.1.0" 12 | resolved "https://registry.yarnpkg.com/dom-urls/-/dom-urls-1.1.0.tgz#001ddf81628cd1e706125c7176f53ccec55d918e" 13 | dependencies: 14 | urijs "^1.16.1" 15 | 16 | lodash._basefor@^3.0.0: 17 | version "3.0.3" 18 | resolved "https://registry.yarnpkg.com/lodash._basefor/-/lodash._basefor-3.0.3.tgz#7550b4e9218ef09fad24343b612021c79b4c20c2" 19 | integrity sha1-dVC06SGO8J+tJDQ7YSAhx5tMIMI= 20 | 21 | lodash.isarguments@^3.0.0: 22 | version "3.1.0" 23 | resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" 24 | integrity sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo= 25 | 26 | lodash.isarray@^3.0.0: 27 | version "3.0.4" 28 | resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55" 29 | integrity sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U= 30 | 31 | lodash.isplainobject@^3.0.2: 32 | version "3.2.0" 33 | resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-3.2.0.tgz#9a8238ae16b200432960cd7346512d0123fbf4c5" 34 | integrity sha1-moI4rhayAEMpYM1zRlEtASP79MU= 35 | dependencies: 36 | lodash._basefor "^3.0.0" 37 | lodash.isarguments "^3.0.0" 38 | lodash.keysin "^3.0.0" 39 | 40 | lodash.keysin@^3.0.0: 41 | version "3.0.8" 42 | resolved "https://registry.yarnpkg.com/lodash.keysin/-/lodash.keysin-3.0.8.tgz#22c4493ebbedb1427962a54b445b2c8a767fb47f" 43 | integrity sha1-IsRJPrvtsUJ5YqVLRFssinZ/tH8= 44 | dependencies: 45 | lodash.isarguments "^3.0.0" 46 | lodash.isarray "^3.0.0" 47 | 48 | realistic-structured-clone@^1.0.1: 49 | version "1.0.1" 50 | resolved "https://registry.yarnpkg.com/realistic-structured-clone/-/realistic-structured-clone-1.0.1.tgz#1abe82af0b80cd7b109fdaf5d29308032852d45d" 51 | integrity sha1-Gr6CrwuAzXsQn9r10pMIAyhS1F0= 52 | dependencies: 53 | lodash.isplainobject "^3.0.2" 54 | 55 | shelving-mock-event@^1.0.12: 56 | version "1.0.12" 57 | resolved "https://registry.yarnpkg.com/shelving-mock-event/-/shelving-mock-event-1.0.12.tgz#401dc90b3b49cbf2a817ecf2dd5a83eff4de2e14" 58 | integrity sha512-2F+IZ010rwV3sA/Kd2hnC1vGNycsxeBJmjkXR8+4IOlv5e+Wvj+xH+A8Cv8/Z0lUyCut/HcxSpeDccYTVtnuaQ== 59 | 60 | shelving-mock-indexeddb@^1.1.0: 61 | version "1.1.0" 62 | resolved "https://registry.yarnpkg.com/shelving-mock-indexeddb/-/shelving-mock-indexeddb-1.1.0.tgz#e065a8d7987d182d058e2b55f0f79a52d48a38f1" 63 | integrity sha512-akHJAmGL/dplJ4FZNxPxVbOxMw8Ey6wAnB9+3+GCUNqPUcJaskS55GijxZtarTfAYB4XQyu+FLtjcq2Oa3e2Lg== 64 | dependencies: 65 | realistic-structured-clone "^1.0.1" 66 | shelving-mock-event "^1.0.12" 67 | 68 | urijs@^1.16.1: 69 | version "1.19.0" 70 | resolved "https://registry.yarnpkg.com/urijs/-/urijs-1.19.0.tgz#d8aa284d0e7469703a6988ad045c4cbfdf08ada0" 71 | 72 | url-search-params@^0.10.0: 73 | version "0.10.2" 74 | resolved "https://registry.yarnpkg.com/url-search-params/-/url-search-params-0.10.2.tgz#e9da69646e48c6140c6732e1f07fb669525f5a4e" 75 | integrity sha512-d6GYsr992Bo9rzTZFc9BUw3UFAAg3prE9JGVBgW2TLTbI3rSvg4VDa0BFXHMzKkWbAuhrmaFWpucpRJl+3W7Jg== 76 | 77 | w3c-hr-time@^1.0.1: 78 | version "1.0.1" 79 | resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz#82ac2bff63d950ea9e3189a58a65625fedf19045" 80 | integrity sha1-gqwr/2PZUOqeMYmlimViX+3xkEU= 81 | dependencies: 82 | browser-process-hrtime "^0.1.2" 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # *** THIS PROJECT IS NO LONGER MAINTAINED *** 2 | 3 | 4 | Service Worker Toolchain 5 | ========================= 6 | 7 | A collection of service worker generation tools. 8 | Configurable and forkable. 9 | 10 | ## Includes the following packages 11 | 12 | ### [generate-service-worker](https://github.com/pinterest/service-workers/tree/master/packages/generate-service-worker) 13 | A node module for generating service worker files based on provided configuration options. 14 | 15 | ### [service-worker-plugin](https://github.com/pinterest/service-workers/tree/master/packages/service-worker-plugin) 16 | A webpack plugin for generating dynamic service worker files and a runtime helper. 17 | 18 | ### [service-worker-mock](https://github.com/pinterest/service-workers/tree/master/packages/service-worker-mock) 19 | A mock service worker environment generator. Used for testing service worker code. 20 | 21 | ## Why? 22 | There are several other packages that generate service workers ([sw-precache](https://github.com/GoogleChrome/sw-precache), [offline-plugin](https://github.com/NekR/offline-plugin/), etc). This collection of tools was built to allow more complexity while being fully testable, and allowing the generation of multiple service worker files simultaneously for experimentation/rollout. We chose not to use a templating language, but to instead inject globals into the scripts so that our "templates" were pure JavaScript. This makes it easier to test/read/update the code, with the downside of slightly larger output sizes. See the README in each package for more details. 23 | 24 | We encourage forking of the base templates found in [packages/generate-service-worker/templates/](https://github.com/pinterest/service-workers/tree/master/packages/generate-service-worker/templates). 25 | 26 | 27 | ## Contributing 28 | 29 | scripts | description 30 | -------------- | ----------- 31 | `yarn install` | install all dev dependencies 32 | `yarn test` | run the test suite 33 | `yarn run lint`| run eslint 34 | `yarn start` | run the demo for development testing 35 | 36 | To get started contributing, run `yarn start`, which will run a webpack-devserver on `localhost:3000`. In `demo/webpack.config.js` you'll see the configurations used for the demo testing. Each experimental config can be accessed via the `key` query param (i.e. `localhost:3000?key=withNotifications`). This provides a simple way to install a new service worker for testing, and the corresponding generated code will be visible in the DOM itself thanks to [highlight.js](https://highlightjs.org/). Use the `application` tab in the devtools to verify that the service worker was installed. By setting `debug: true` in the plugin config, the devtools console can be used to verify actions are taking place. 37 | 38 | ## Core Contributors 39 | * [Zack Argyle](https://github.com/zackargyle) 40 | * [Yen-Wei Liu](https://github.com/bishwei) 41 | * [Sebastian Herrlinger](https://github.com/kommander) 42 | 43 | ## Contributors ✌⊂(✰‿✰)つ✌ 44 | * [Doug Reeder](https://github.com/DougReeder) 45 | * [Jeff Posnick](https://github.com/jeffposnick) 46 | * [Matt Gaunt](https://github.com/gauntface) 47 | * [Joseph Liccini](https://github.com/josephliccini) 48 | * [Jonathan Creamer](https://github.com/jcreamer898) 49 | * [Brad Erickson](https://github.com/13rac1) 50 | * [Bryan Lee](https://github.com/bryclee) 51 | * [Jamie King](https://github.com/10xlacroixdrinker) 52 | * [Domingos Martins](https://github.com/DomingosMartins) 53 | * [André Naves](https://github.com/andrefgneves) 54 | * [kontrollanten](https://github.com/kontrollanten) 55 | * [cjies](https://github.com/cjies) 56 | * [sreedhard7](https://github.com/sreedhar7) 57 | * [koenvg](https://github.com/koenvg) 58 | * [pwwpche](https://github.com/pwwpche) 59 | * [jelly972](https://github.com/jelly972) 60 | 61 | **Some ideas for contributions:** 62 | * Browserify plugin 63 | * Rollup plugin 64 | 65 | ## License 66 | [MIT](http://isekivacenz.mit-license.org/) 67 | -------------------------------------------------------------------------------- /packages/service-worker-mock/README.md: -------------------------------------------------------------------------------- 1 | Service Worker Mock 2 | ========================= 3 | A mock service worker environment generator. 4 | 5 | ## Why? 6 | Testing service workers is difficult. Each file produces side-effects by calls to `self.addEventListener`, and the service worker environment is unlike a normal web or node context. This package makes it easy to turn a Node.js environment into a faux service worker environment. Additionally, it adds some helpful methods for testing integrations. 7 | 8 | The service worker mock creates an environment with the following properties, based on the current [Mozilla Service Worker Docs](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API). 9 | ```js 10 | const env = { 11 | // Environment polyfills 12 | skipWaiting: Function, 13 | caches: CacheStorage, 14 | clients: Clients, 15 | registration: ServiceWorkerRegistration, 16 | addEventListener: Function, 17 | Request: constructor Function, 18 | Response: constructor Function, 19 | URL: constructor Function, 20 | 21 | // Test helpers 22 | listeners: Map, 23 | trigger: Function, 24 | snapshot: Function, 25 | }; 26 | ``` 27 | 28 | Test Helper | description 29 | -------------- | ----------- 30 | `listeners` | [`Map`] A key/value map of active listeners (`install`/`activate`/`fetch`/etc). 31 | `trigger` | [`Function`] Used to trigger active listeners (`await self.trigger('install')`). 32 | `snapshot` | [`Function`] Used to generate a snapshot of the service worker internals (see below). 33 | 34 | Snapshot Property | description 35 | -------------------- | ----------- 36 | `caches` | [`Object`] A key/value map of current cache contents. 37 | `clients` | [`Array`] A list of active clients. 38 | `notifications` | [`Array`] A list of active notifications 39 | 40 | Additionally we provide a fetch mock in `service-worker-mock/fetch` to easily get up and running (see Getting Started for example). 41 | 42 | ## Getting Started 43 | The service worker mock is best used by applying its result to the global scope, then calling `require('../sw.js')` with the path to your service worker file. The file will use the global mocks for things like adding event listeners. 44 | ```js 45 | const makeServiceWorkerEnv = require('service-worker-mock'); 46 | const makeFetchMock = require('service-worker-mock/fetch'); 47 | 48 | describe('Service worker', () => { 49 | beforeEach(() => { 50 | Object.assign( 51 | global, 52 | makeServiceWorkerEnv(), 53 | makeFetchMock(), 54 | // If you're using sinon ur similar you'd probably use below instead of makeFetchMock 55 | // fetch: sinon.stub().returns(Promise.resolve()) 56 | ); 57 | jest.resetModules(); 58 | }); 59 | it('should add listeners', () => { 60 | require('../sw.js'); 61 | expect(self.listeners.get('install')).toBeDefined(); 62 | expect(self.listeners.get('activate')).toBeDefined(); 63 | expect(self.listeners.get('fetch')).toBeDefined(); 64 | }); 65 | }); 66 | ``` 67 | 68 | ## Use 69 | The following is an example snippet derived from [__tests__/basic.js](https://github.com/pinterest/service-workers/blob/master/packages/service-worker-mock/__tests__/basic.js). The test is based on the [service worker example](https://github.com/GoogleChrome/samples/blob/gh-pages/service-worker/basic/service-worker.js) provided by Google. In it, we will verify that on `activate`, the service worker deletes old caches and creates the new one. 70 | 71 | ```js 72 | const makeServiceWorkerEnv = require('service-worker-mock'); 73 | 74 | describe('Service worker', () => { 75 | beforeEach(() => { 76 | Object.assign(global, makeServiceWorkerEnv()); 77 | jest.resetModules(); 78 | }); 79 | 80 | it('should delete old caches on activate', async () => { 81 | require('../sw.js'); 82 | 83 | // Create old cache 84 | await self.caches.open('OLD_CACHE'); 85 | expect(self.snapshot().caches.OLD_CACHE).toBeDefined(); 86 | 87 | // Activate and verify old cache is removed 88 | await self.trigger('activate'); 89 | expect(self.snapshot().caches.OLD_CACHE).toBeUndefined(); 90 | expect(self.snapshot().caches['precache-v1']).toBeDefined(); 91 | }); 92 | }); 93 | ``` 94 | 95 | ## License 96 | 97 | MIT 98 | -------------------------------------------------------------------------------- /packages/generate-service-worker/templates/notifications.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* -------- NOTIFICATIONS --------- */ 4 | 5 | self.addEventListener('push', handleNotificationPush); 6 | self.addEventListener('notificationclick', handleNotificationClick); 7 | 8 | /* -------- NOTIFICATIONS HANDLERS --------- */ 9 | 10 | function handleNotificationPush(event) { 11 | logger.log('Push notification received'); 12 | 13 | if ($Log.notificationReceived) { 14 | event.waitUntil(logNotificationReceived(event)); 15 | } 16 | 17 | // Show notification or fallback 18 | if (event.data && event.data.title) { 19 | event.waitUntil(showNotification(event.data)); 20 | } else if ($Notifications.fallbackURL) { 21 | event.waitUntil( 22 | self.registration.pushManager.getSubscription() 23 | .then(fetchNotification) 24 | .then(convertResponseToJson) 25 | .then(showNotification) 26 | .catch(showNotification) 27 | ); 28 | } else { 29 | logger.warn('No notification.data and no fallbackURL.'); 30 | event.waitUntil(showNotification()); 31 | } 32 | } 33 | 34 | function handleNotificationClick(event) { 35 | logger.log('Push notification clicked.', event.notification.tag); 36 | 37 | if ($Log.notificationClicked) { 38 | event.waitUntil(logNotificationClick(event)); 39 | } 40 | 41 | // Open the url if provided 42 | if (event.notification.data && event.notification.data.url) { 43 | const url = event.notification.data.url; 44 | event.waitUntil(openWindow(url)); 45 | } else if (event.notification.tag.indexOf(':') !== -1) { 46 | // TODO: Deprecate 47 | const url = event.notification.tag.split(':')[2] || '/'; 48 | event.waitUntil(openWindow(url)); 49 | } else { 50 | logger.warn('Cannot route click with no data.url property. Using "/".', event.notification.tag); 51 | event.waitUntil(openWindow('/')); 52 | } 53 | 54 | event.notification.close(); 55 | logger.groupEnd(event.notification.tag); 56 | } 57 | 58 | /* -------- NOTIFICATIONS HELPERS --------- */ 59 | 60 | function showNotification(data) { 61 | if (!data || !data.tag) { 62 | // eslint-disable-next-line no-param-reassign 63 | data = $Notifications.default; 64 | } 65 | logger.group(data.tag); 66 | logger.log('Show notification.', data.tag); 67 | return self.registration 68 | .showNotification(data.title, data) 69 | .then(delayDismissNotification); 70 | } 71 | 72 | function fetchNotification(subscription) { 73 | if (!subscription) { 74 | logger.warn('No subscription found.'); 75 | throw new Error('No subscription found.'); 76 | } 77 | logger.log('Fetching remote notification data.'); 78 | const queries = { 79 | endpoint: subscription.endpoint 80 | }; 81 | const url = formatUrl($Notifications.fallbackURL, queries); 82 | return fetch(url, { credentials: 'include' }); 83 | } 84 | 85 | function convertResponseToJson(response) { 86 | if (response.status !== 200) { 87 | throw new Error('Notification data fetch failed.'); 88 | } 89 | return response.json(); 90 | } 91 | 92 | function delayDismissNotification() { 93 | setTimeout(function serviceWorkerDismissNotification() { 94 | self.registration.getNotifications() 95 | .then(notifications => { 96 | notifications.forEach(notification => { 97 | notification.close(); 98 | logger.log('Dismissing notification.', notification.tag); 99 | logger.groupEnd(notification.tag); 100 | }); 101 | }); 102 | }, $Notifications.duration || 5000); 103 | } 104 | 105 | function openWindow(url) { 106 | if (clients.openWindow) { 107 | return clients.openWindow(url); 108 | } 109 | return Promise.resolve(); 110 | } 111 | 112 | function logNotificationReceived(event) { 113 | return logAction(event, $Log.notificationReceived); 114 | } 115 | 116 | function logNotificationClick(event) { 117 | return logAction(event.notification, $Log.notificationClicked); 118 | } 119 | 120 | function logAction(notification, url) { 121 | logger.log(`Send log event to ${url}.`, notification.tag); 122 | return self.registration.pushManager.getSubscription().then((subscription) => { 123 | const query = { 124 | endpoint: subscription.endpoint, 125 | tag: notification.tag 126 | }; 127 | return fetch(formatUrl(url, query), { credentials: 'include' }); 128 | }); 129 | } 130 | 131 | function formatUrl(url, queries) { 132 | const prefix = url.includes('?') ? '&' : '?'; 133 | const query = Object.keys(queries).map(function (key) { 134 | return `${key}=${queries[key]}`; 135 | }).join('&'); 136 | return url + prefix + query; 137 | } 138 | -------------------------------------------------------------------------------- /packages/generate-service-worker/README.md: -------------------------------------------------------------------------------- 1 | Generate Service Worker 2 | ========================= 3 | A node module for generating service worker files based on provided configuration options. 4 | 5 | ## Why? 6 | There are several other popular service worker generators out there ([sw-precache](https://github.com/GoogleChrome/sw-precache), [offline-plugin](https://github.com/NekR/offline-plugin/), etc), but they focus only on caching, and are not testable or easy to experiment with. Service workers also include support for other tools like notifications and homescreen installs. This generator attempts to account for a wider variety of configurable options. 7 | 8 | GenerateServiceWorker supports generating a service worker with a root configuration, and any number of other experimental service workers. **This is perfect for experimenting with different caching strategies, or rolling out service worker changes.** The runtime file generated by [service-worker-plugin](https://github.com/pinterest/service-workers/tree/master/packages/service-worker-plugin) makes it particularly easy to utilize your own experiment framework alongside the generated experimental service worker files if you can statically host the service workers. 9 | 10 | Caching strategies inspired by [sw-toolkit](https://github.com/GoogleChrome/sw-toolbox). 11 | 12 | ## Use 13 | 14 | ```js 15 | const generateServiceWorkers = require('generate-service-worker'); 16 | 17 | const serviceWorkers = generateServiceWorkers({ 18 | cache: { 19 | offline: true, 20 | precache: ['/static/js/bundle-81hj9isadf973adfsh10.js'], 21 | strategy: [{ 22 | type: 'prefer-cache', 23 | matches: ['\\.js'] 24 | }], 25 | } 26 | }, { 27 | 'roll_out_notifications': { 28 | notifications: { 29 | default: { 30 | title: 'Pinterest', 31 | body: 'You\'ve got new Pins!' 32 | } 33 | }, 34 | } 35 | }); 36 | ``` 37 | 38 | ## Configurations 39 | GenerateServiceWorker currently supports caching and notifications. The following are the configuration options for each. 40 | 41 | ### Caching 42 | The `cache` key is used for defining caching strategies. The strings in `precache` will be used to prefetch assets and insert them into the cache. The regexes in `strategy.matches` are used at runtime to determine which strategy to use for a given GET request. All cached items will be removed at installation of a new service worker version. Additionally, you can use your own custom cache template by including the full path in the `template` property. We suggest forking our `templates/cache.js` file to get started and to be familiar with how variable injection works in the codebase. If the `offline` option is set to `true`, the service worker will assume that an html response is an "App Shell". It will cache the html response and return it only in the case of a static route change while offline. 43 | ```js 44 | const CacheType = { 45 | offline?: boolean, 46 | precache?: Array, 47 | strategy?: Array, 48 | template?: string, 49 | }; 50 | const StrategyType = { 51 | type: 'offline-only' | 'fallback-only' | 'prefer-cache' | 'race', 52 | matches: Array, 53 | }; 54 | ``` 55 | 56 | ### Strategy Types 57 | strategy | description 58 | --------------- | ----------- 59 | `offline-only` | Only serve from cache if browser is offline. 60 | `fallback-only` | Only serve from cache if fetch returns an error status (>= 400) 61 | `prefer-cache` | Always pull from cache if data is available 62 | `race` | Pull from cache and make fetch request. Whichever returns first should be used. (Good for some low-end phones) 63 | 64 | 65 | ### Notifications 66 | The `notifications` key is used for including browser notification events in your service worker. To enable the notifications in your app, you can call `runtime.requestNotificationsPermission()` from the generated runtime file. The backend work is not included. You will still need to push notifications to your provider and handle registration. Additionally, you can use your own custom notifications template by including the full path in the `template` property. We suggest forking our `templates/notifications.js` file to get started and to be familiar with how variable injection works in the codebase. 67 | ```js 68 | const NotificationsType = { 69 | default: { 70 | title: string, 71 | body?: string, 72 | icon?: string, 73 | tag?: string, 74 | data?: { 75 | url: string 76 | } 77 | }, 78 | duration?: number, 79 | template?: string, 80 | }); 81 | 82 | ``` 83 | 84 | ### Event Logging 85 | The `log` key is used for defining which service worker events your API wants to know about. Each `string` should be a valid url path that will receive a 'GET' request for the corresponding event. 86 | ```js 87 | const LogType = { 88 | notificationClicked?: string, 89 | notificationReceived?: string 90 | }; 91 | ``` 92 | 93 | ## License 94 | 95 | MIT 96 | -------------------------------------------------------------------------------- /packages/service-worker-mock/__tests__/Request.js: -------------------------------------------------------------------------------- 1 | const makeServiceWorkerEnv = require('../index'); 2 | const NodeURL = require('url').URL; 3 | const DomURL = require('dom-urls'); 4 | 5 | describe('Request', () => { 6 | beforeEach(() => { 7 | Object.assign(global, makeServiceWorkerEnv()); 8 | jest.resetModules(); 9 | }); 10 | 11 | it('takes a string URL only', () => { 12 | const stringUrl = 'http://test.com/resource.html'; 13 | const req = new Request(stringUrl); 14 | 15 | expect(req.url).toEqual(stringUrl); 16 | }); 17 | 18 | it('takes a DOM URL instance only', () => { 19 | const stringUrl = 'http://test.com/resource.html'; 20 | const domUrl = new DomURL(stringUrl); 21 | const req = new Request(domUrl); 22 | 23 | expect(req.url).toEqual(stringUrl); 24 | }); 25 | 26 | it('takes a Node URL instance only', () => { 27 | const stringUrl = 'http://test.com/resource.html'; 28 | const domUrl = new NodeURL(stringUrl); 29 | const req = new Request(domUrl); 30 | 31 | expect(req.url).toEqual(stringUrl); 32 | }); 33 | 34 | it('takes an absolute url path only (concatenated to location.href)', () => { 35 | const stringUrl = '/resource.html'; 36 | const req = new Request(stringUrl); 37 | 38 | expect(req.url).toEqual('https://www.test.com' + stringUrl); 39 | }); 40 | 41 | it('takes a relative url path only (concatenated to location.href)', () => { 42 | const stringUrl = 'resource.html'; 43 | const req = new Request(stringUrl); 44 | 45 | expect(req.url).toEqual('https://www.test.com/' + stringUrl); 46 | }); 47 | 48 | it('an empty url defaults to location.href (with trailing slash, that is how browsers do)', () => { 49 | const stringUrl = ''; 50 | const req = new Request(stringUrl); 51 | 52 | expect(req.url).toEqual('https://www.test.com/'); 53 | }); 54 | 55 | it('takes a Request instance only', () => { 56 | const stringUrl = 'http://test.com/resource.html'; 57 | const reqInstance = new Request(stringUrl); 58 | const req = new Request(reqInstance); 59 | 60 | expect(req.url).toEqual(stringUrl); 61 | }); 62 | 63 | it('takes Request properties as options', async () => { 64 | const stringUrl = '/resource.html'; 65 | const options = { 66 | body: 'override body', 67 | credentials: 'override-credentials', 68 | headers: { 69 | 'x-override': 'override value' 70 | }, 71 | method: 'OMY', 72 | mode: 'no-mode', 73 | referrer: 'http://referrer.com/' 74 | }; 75 | const req = new Request(stringUrl, options); 76 | 77 | expect(req.url).toEqual('https://www.test.com' + stringUrl); 78 | expect(await req.text()).toEqual(options.body); 79 | expect(req.body.size).toBe(13); 80 | expect(req.credentials).toEqual(options.credentials); 81 | expect(req.method).toEqual(options.method); 82 | expect(req.mode).toEqual(options.mode); 83 | expect(req.referrer).toEqual(options.referrer); 84 | expect(req.headers.get('x-override')).toEqual('override value'); 85 | }); 86 | 87 | it('overrides Request properties with options', async () => { 88 | const stringUrl = 'http://test.com/resource.html'; 89 | const reqInstance = new Request(stringUrl); 90 | const options = { 91 | body: 'override body', 92 | credentials: 'override-credentials', 93 | headers: { 94 | 'x-override': 'override value' 95 | }, 96 | method: 'OMY', 97 | mode: 'no-mode', 98 | referrer: 'http://referrer.com/' 99 | }; 100 | const req = new Request(reqInstance, options); 101 | 102 | expect(req.url).toEqual(stringUrl); 103 | expect(await req.text()).toEqual(options.body); 104 | expect(req.credentials).toEqual(options.credentials); 105 | expect(req.method).toEqual(options.method); 106 | expect(req.mode).toEqual(options.mode); 107 | expect(req.referrer).toEqual(options.referrer); 108 | expect(req.headers.get('x-override')).toEqual('override value'); 109 | }); 110 | 111 | it('can be cloned with method, mode, headers and body', async () => { 112 | const stringUrl = 'http://test.com/resource.html'; 113 | const options = { 114 | body: 'override body', 115 | headers: { 116 | 'X-Custom': 'custom-value' 117 | } 118 | }; 119 | const originalReq = new Request(stringUrl, options); 120 | const req = originalReq.clone(); 121 | 122 | expect(req.url).toEqual(stringUrl); 123 | expect(await req.text()).toEqual(options.body); 124 | expect(req.body.size).toBe(13); 125 | expect(req.mode).toEqual(originalReq.mode); 126 | expect(req.method).toEqual(originalReq.method); 127 | expect(req.headers.get('X-Custom')).toEqual('custom-value'); 128 | }); 129 | 130 | it('takes a string body', async () => { 131 | const stringUrl = 'http://test.com/resource.html'; 132 | const reqInstance = new Request(stringUrl, { 133 | body: 'content' 134 | }); 135 | const req = new Request(reqInstance); 136 | 137 | expect(await req.text()).toEqual('content'); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /packages/service-worker-plugin/README.md: -------------------------------------------------------------------------------- 1 | Service Worker Plugin 2 | ========================= 3 | A webpack plugin for generating dynamic service worker files and a runtime helper. 4 | 5 | ## Why? 6 | There are several other popular service worker plugins out there ([offline-plugin](https://github.com/NekR/offline-plugin/), etc), but they focus only on caching, and are not testable or easy to experiment with. Service workers also include support for other tools like notifications and homescreen installs. This plugin attempts to account for a wider variety of configurable options by utilizing [generate-service-worker](https://github.com/pinterest/service-workers/tree/master/packages/generate-service-worker). 7 | 8 | ServiceWorkerPlugin will generate any number of service workers, and provide a runtime file for dynamically registering whichever service worker you want. **This is perfect for experimenting with different caching strategies, or rolling out service worker changes.** In a webpack world where all of our files are hashed and dynamically generated, being able to experiment with precaching and runtime caching approaches is incredibly important. The runtime file makes it particularly easy to utilize your own experiment framework alongside the generated experimental service worker files if you can statically host the service workers. 9 | 10 | ## Use 11 | 12 | ```js 13 | const ServiceWorkerPlugin = require('service-worker-plugin'); 14 | 15 | const rootConfig = { 16 | cache: { 17 | offline: true, 18 | precache: ['\\.js'], 19 | strategy: [{ 20 | type: 'prefer-cache', 21 | matches: ['\\.js'] 22 | }] 23 | } 24 | }; 25 | 26 | module.exports = { 27 | entry: "./entry.js", 28 | output: { 29 | publicPath: '/static', 30 | }, 31 | plugins: [ 32 | new ServiceWorkerPlugin(rootConfig, { 33 | experiment_with_notifications: Object.assign({}, rootConfig, { 34 | notifications: { 35 | default: { 36 | title: 'Pinterest', 37 | body: 'You\'ve got new Pins!' 38 | } 39 | }, 40 | log: { 41 | notificationClicked: '/api/notifications/web/click/' 42 | } 43 | }) 44 | }) 45 | ] 46 | }; 47 | 48 | // Registering the service worker in your browser bundle 49 | const runtime = require('service-worker-plugin/runtime'); 50 | if (inPrecacheExperiment) { 51 | runtime.register('experiment_with_notifications'); 52 | } else { 53 | runtime.register(); 54 | } 55 | ``` 56 | 57 | ## Configurations 58 | ServiceWorkerPlugin has the following configuration options. 59 | 60 | Option key | description 61 | --------------- | ----------- 62 | `publicPath` | The path to your hosted precache assets (ie. '/static/js/') 63 | `writePath` | The path to where the plugin should write the files to disk 64 | `runtimePath` | The path to your hosted service workers (ie. '/') 65 | `cache` | 66 | `notifications` | 67 | 68 | ### Caching 69 | The `cache` key is used for defining caching strategies. The regexes in `precache` will be used to resolve webpack-generated assets to hard-coded paths for precaching. The regexes in `strategy.matches` are used at runtime to determine which strategy to use for a given GET request. All cached items will be removed at installation of a new service worker version. Additionally, you can use your own custom cache template by including the full path in the `template` property. We suggest forking our `templates/cache.js` file to get started and to be familiar with how variable injection works in the codebase. If the `offline` option is set to `true`, the service worker will assume that an html response is an "App Shell". It will cache the html response and return it only in the case of a static route change while offline. 70 | ```js 71 | const CacheType = { 72 | offline?: boolean, 73 | precache?: Array, 74 | strategy?: Array, 75 | template?: string, 76 | }; 77 | const StrategyType = { 78 | type: 'offline-only' | 'fallback-only' | 'prefer-cache' | 'race', 79 | matches: Array, 80 | }; 81 | ``` 82 | 83 | ### Strategy Types 84 | strategy | description 85 | --------------- | ----------- 86 | `offline-only` | Only serve from cache if browser is offline. 87 | `fallback-only` | Only serve from cache if fetch returns an error status (>= 400) 88 | `prefer-cache` | Always pull from cache if data is available 89 | `race` | Pull from cache and make fetch request. Whichever returns first should be used. (Good for some low-end phones) 90 | 91 | 92 | ### Notifications 93 | The `notifications` key is used for including browser notification events in your service worker. To enable the notifications in your app, you can call `runtime.requestNotificationsPermission()` from the generated runtime file. The backend work is not included. You will still need to push notifications to your provider and handle registration. Additionally, you can use your own custom notifications template by including the full path in the `template` property. We suggest forking our `templates/notifications.js` file to get started and to be familiar with how variable injection works in the codebase. 94 | ```js 95 | const NotificationsType = { 96 | default: { 97 | title: string, 98 | body?: string, 99 | icon?: string, 100 | tag?: string, 101 | data?: { 102 | url: string 103 | } 104 | }, 105 | duration?: number, 106 | template?: string, 107 | }); 108 | 109 | ``` 110 | 111 | ### Event Logging 112 | The `log` key is used for defining which service worker events your API wants to know about. Each `string` should be a valid url path that will receive a 'GET' request for the corresponding event. 113 | ```js 114 | const LogType = { 115 | notificationClicked?: string, 116 | notificationShown?: string, 117 | }; 118 | ``` 119 | 120 | ## License 121 | 122 | MIT 123 | -------------------------------------------------------------------------------- /packages/service-worker-mock/index.js: -------------------------------------------------------------------------------- 1 | // If the WHATWG URL implementation is available via the first-party `url` 2 | // module, in Node 7+, then prefer that. Otherwise, fall back on the `dom-urls` 3 | // implementation, which lacks support for the `searchParams` property. 4 | const URL = require('url').URL || require('dom-urls'); 5 | const { 6 | IDBFactory, 7 | IDBKeyRange, 8 | IDBDatabase, 9 | IDBObjectStore, 10 | reset: resetIDB 11 | } = require('shelving-mock-indexeddb'); 12 | const { Performance } = require('w3c-hr-time'); 13 | 14 | const Blob = require('./models/Blob'); 15 | const Body = require('./models/Body'); 16 | const Cache = require('./models/Cache'); 17 | const CacheStorage = require('./models/CacheStorage'); 18 | const Client = require('./models/Client'); 19 | const WindowClient = require('./models/WindowClient'); 20 | const Clients = require('./models/Clients'); 21 | const DOMException = require('./models/DOMException'); 22 | const ExtendableEvent = require('./models/ExtendableEvent'); 23 | const ExtendableMessageEvent = require('./models/ExtendableMessageEvent'); 24 | const Event = require('./models/Event'); 25 | const EventTarget = require('./models/EventTarget'); 26 | const FetchEvent = require('./models/FetchEvent'); 27 | const Headers = require('./models/Headers'); 28 | const MessageEvent = require('./models/MessageEvent'); 29 | const MessageChannel = require('./models/MessageChannel'); 30 | const MessagePort = require('./models/MessagePort'); 31 | const Notification = require('./models/Notification'); 32 | const NotificationEvent = require('./models/NotificationEvent'); 33 | const PushEvent = require('./models/PushEvent'); 34 | const PushManager = require('./models/PushManager'); 35 | const PushSubscription = require('./models/PushSubscription'); 36 | const Request = require('./models/Request'); 37 | const Response = require('./models/Response'); 38 | const ServiceWorkerRegistration = require('./models/ServiceWorkerRegistration'); 39 | const SyncEvent = require('./models/SyncEvent'); 40 | const URLSearchParams = require('url-search-params'); 41 | const BroadcastChannel = require('./models/BroadcastChannel'); 42 | const FileReader = require('./models/FileReader'); 43 | 44 | const eventHandler = require('./utils/eventHandler'); 45 | 46 | const defaults = (envOptions) => Object.assign({ 47 | locationUrl: 'https://www.test.com/sw.js', 48 | userAgent: 'Mock User Agent', 49 | useRawRequestUrl: false 50 | }, envOptions); 51 | 52 | const makeListenersWithReset = (listeners, resetEventListeners) => { 53 | Object.defineProperty(listeners, 'reset', { 54 | enumerable: false, 55 | value: resetEventListeners 56 | }); 57 | return listeners; 58 | }; 59 | 60 | class ServiceWorkerGlobalScope extends EventTarget { 61 | constructor(envOptions) { 62 | super(); 63 | 64 | const options = defaults(envOptions); 65 | 66 | // For backwards compatibility, resetting global scope listeners 67 | // will reset ExtenableEvents as well 68 | this.listeners = makeListenersWithReset(this.listeners, () => { 69 | this.resetEventListeners(); 70 | ExtendableEvent._allExtendableEvents.clear(); 71 | }); 72 | this.useRawRequestUrl = options.useRawRequestUrl; 73 | this.location = new URL(options.locationUrl, options.locationBase); 74 | this.skipWaiting = () => Promise.resolve(); 75 | this.caches = new CacheStorage(); 76 | this.clients = new Clients(); 77 | this.registration = new ServiceWorkerRegistration(); 78 | 79 | // Constructors 80 | this.Blob = Blob; 81 | this.Body = Body; 82 | this.BroadcastChannel = BroadcastChannel; 83 | this.Cache = Cache; 84 | this.Client = Client; 85 | this.DOMException = DOMException; 86 | this.Event = Event; 87 | this.EventTarget = EventTarget; 88 | this.ExtendableEvent = ExtendableEvent; 89 | this.ExtendableMessageEvent = ExtendableMessageEvent; 90 | this.FetchEvent = FetchEvent; 91 | this.FileReader = FileReader; 92 | this.Headers = Headers; 93 | this.importScripts = () => {}; 94 | this.indexedDB = new IDBFactory(); 95 | this.IDBKeyRange = IDBKeyRange; 96 | this.IDBDatabase = IDBDatabase; 97 | this.IDBObjectStore = IDBObjectStore; 98 | this.resetIDB = resetIDB; 99 | this.MessageEvent = MessageEvent; 100 | this.MessageChannel = MessageChannel; 101 | this.MessagePort = MessagePort; 102 | this.Notification = Notification; 103 | this.NotificationEvent = NotificationEvent; 104 | this.PushEvent = PushEvent; 105 | this.PushManager = PushManager; 106 | this.PushSubscription = PushSubscription; 107 | this.performance = new Performance(); 108 | this.Request = Request; 109 | this.Response = Response; 110 | this.SyncEvent = SyncEvent; 111 | this.ServiceWorkerGlobalScope = ServiceWorkerGlobalScope; 112 | this.URL = URL; 113 | this.URLSearchParams = URLSearchParams; 114 | this.navigator = {}; 115 | this.navigator.userAgent = options.userAgent; 116 | 117 | this.WindowClient = WindowClient; 118 | 119 | this.trigger = (name, args) => { 120 | if (this.listeners.has(name)) { 121 | return eventHandler( 122 | name, 123 | args, 124 | Array.from(this.listeners.get(name).values()) 125 | ); 126 | } 127 | return Promise.resolve(); 128 | }; 129 | 130 | // Instance variable to avoid issues with `this` 131 | this.snapshot = () => { 132 | return { 133 | caches: this.caches.snapshot(), 134 | clients: this.clients.snapshot(), 135 | notifications: this.registration.snapshot() 136 | }; 137 | }; 138 | 139 | // Allow resetting without rewriting 140 | this.resetSwEnv = () => { 141 | this.caches.reset(); 142 | this.clients.reset(); 143 | this.listeners.reset(); 144 | }; 145 | 146 | this.self = this; 147 | } 148 | } 149 | 150 | module.exports = function makeServiceWorkerEnv(envOptions) { 151 | return new ServiceWorkerGlobalScope(envOptions); 152 | }; 153 | -------------------------------------------------------------------------------- /packages/generate-service-worker/templates/cache.js: -------------------------------------------------------------------------------- 1 | /* -------- CACHE --------- */ 2 | 3 | const CURRENT_CACHE = `SW_CACHE:${$VERSION}`; 4 | const APP_SHELL_CACHE = 'SW_APP_SHELL'; 5 | 6 | const isValidResponse = res => (res.ok || (res.status === 0 && res.type === 'opaque')); 7 | const isNavigation = req => req.mode === 'navigate' || (req.method === 'GET' && req.headers.get('accept').includes('text/html')); 8 | 9 | /* -------- CACHE LISTENERS --------- */ 10 | 11 | self.addEventListener('install', handleInstall); 12 | self.addEventListener('activate', handleActivate); 13 | if ($Cache.precache || $Cache.offline || $Cache.strategy) { 14 | self.addEventListener('fetch', handleFetch); 15 | } 16 | 17 | /* -------- CACHE HANDLERS --------- */ 18 | 19 | function handleInstall(event) { 20 | logger.log('Entering install handler.'); 21 | self.skipWaiting(); 22 | if ($Cache.precache) { 23 | event.waitUntil(precache()); 24 | } 25 | } 26 | 27 | function handleActivate(event) { 28 | logger.log('Entering activate handler.'); 29 | const cachesCleared = caches.keys().then(cacheNames => { 30 | logger.group('cleanup'); 31 | return Promise.all(cacheNames.map(cacheName => { 32 | if (CURRENT_CACHE !== cacheName) { 33 | logger.log(`Deleting cache key: ${cacheName}`, 'cleanup'); 34 | return caches.delete(cacheName); 35 | } 36 | return Promise.resolve(); 37 | })).then(() => logger.groupEnd('cleanup')); 38 | }); 39 | event.waitUntil(cachesCleared); 40 | } 41 | 42 | function handleFetch(event) { 43 | if (isNavigation(event.request)) { 44 | if ($Cache.offline) { 45 | event.respondWith( 46 | fetchAndCacheAppShell(event.request) 47 | .catch(() => caches.match(APP_SHELL_CACHE)) 48 | .catch(() => undefined) 49 | ); 50 | } 51 | } else if (event.request.method === 'GET') { 52 | const strategy = getStrategyForUrl(event.request.url); 53 | if (strategy) { 54 | logger.group(event.request.url); 55 | logger.log(`Using strategy ${strategy.type}.`, event.request.url); 56 | event.respondWith( 57 | applyEventStrategy(strategy, event).then(response => { 58 | logger.groupEnd(event.request.url); 59 | return response; 60 | }).catch(() => undefined) 61 | ); 62 | } 63 | } 64 | } 65 | 66 | /* -------- CACHE HELPERS --------- */ 67 | 68 | function applyEventStrategy(strategy, event) { 69 | const request = event.request; 70 | switch (strategy.type) { 71 | case 'offline-only': 72 | return fetchAndCache(request, strategy)().catch(getFromCache(request)); 73 | case 'fallback-only': 74 | return fetchAndCache(request, strategy)().then(fallbackToCache(request)); 75 | case 'prefer-cache': 76 | return getFromCache(request)().catch(fetchAndCache(request, strategy)); 77 | case 'race': 78 | return getFromFastest(request, strategy)(); 79 | default: 80 | return Promise.reject(`Strategy not supported: ${strategy.type}`); 81 | } 82 | } 83 | 84 | function insertInCache(request, response) { 85 | logger.log('Inserting in cache.', request.url); 86 | return caches.open(CURRENT_CACHE) 87 | .then(cache => cache.put(request, response)); 88 | } 89 | 90 | function getFromCache(request) { 91 | return () => { 92 | return caches.match(request).then(response => { 93 | if (response) { 94 | logger.log('Found entry in cache.', request.url); 95 | return response; 96 | } 97 | logger.log('No entry found in cache.', request.url); 98 | throw new Error(`No cache entry found for ${request.url}`); 99 | }); 100 | }; 101 | } 102 | 103 | function getStrategyForUrl(url) { 104 | if ($Cache.strategy) { 105 | return $Cache.strategy.find(strategy => { 106 | return strategy.matches.some(match => { 107 | const regex = new RegExp(match); 108 | return regex.test(url); 109 | }); 110 | }); 111 | } 112 | return null; 113 | } 114 | 115 | function fetchAndCache(request) { 116 | return () => { 117 | logger.log('Fetching remote data.', request.url); 118 | return fetch(request).then(response => { 119 | if (isValidResponse(response)) { 120 | logger.log('Caching remote response.', request.url); 121 | insertInCache(request, response.clone()); 122 | } else { 123 | logger.log('Fetch error.', request.url); 124 | } 125 | return response; 126 | }); 127 | }; 128 | } 129 | 130 | function fetchAndCacheAppShell(request) { 131 | return fetch(request).then(response => { 132 | if (isValidResponse(response)) { 133 | logger.log('Caching app shell.', request.url); 134 | insertInCache(APP_SHELL_CACHE, response.clone()); 135 | } 136 | return response; 137 | }); 138 | } 139 | 140 | function fallbackToCache(request) { 141 | return (response) => { 142 | if (!isValidResponse(response)) { 143 | return getFromCache(request)(); 144 | } 145 | return response; 146 | }; 147 | } 148 | 149 | function getFromFastest(request, strategy) { 150 | return () => new Promise((resolve, reject) => { 151 | var errors = 0; 152 | 153 | function raceReject() { 154 | errors += 1; 155 | if (errors === 2) { 156 | reject(new Error('Network and cache both failed.')); 157 | } 158 | } 159 | 160 | function raceResolve(response) { 161 | if (response instanceof Response) { 162 | resolve(response); 163 | } else { 164 | raceReject(); 165 | } 166 | } 167 | 168 | getFromCache(request)() 169 | .then(raceResolve) 170 | .catch(raceReject); 171 | 172 | fetchAndCache(request, strategy)() 173 | .then(raceResolve) 174 | .catch(raceReject); 175 | }); 176 | } 177 | 178 | function precache() { 179 | logger.group('precaching'); 180 | return caches.open(CURRENT_CACHE).then(cache => { 181 | return Promise.all( 182 | $Cache.precache.map(urlToPrefetch => { 183 | logger.log(urlToPrefetch, 'precaching'); 184 | const cacheBustedUrl = new URL(urlToPrefetch, location.href); 185 | cacheBustedUrl.search += (cacheBustedUrl.search ? '&' : '?') + `cache-bust=${Date.now()}`; 186 | 187 | const request = new Request(cacheBustedUrl, { mode: 'no-cors' }); 188 | return fetch(request).then(response => { 189 | if (!isValidResponse(response)) { 190 | logger.error(`Failed for ${urlToPrefetch}.`, 'precaching'); 191 | return undefined; 192 | } 193 | return cache.put(urlToPrefetch, response); 194 | }); 195 | }) 196 | ); 197 | }).then(() => logger.groupEnd('precaching')); 198 | } 199 | -------------------------------------------------------------------------------- /packages/generate-service-worker/templates/__tests__/cache.js: -------------------------------------------------------------------------------- 1 | const makeServiceWorkerEnv = require('../../../service-worker-mock'); 2 | const fixtures = require('../../../../testing/fixtures'); 3 | 4 | // Injected vars 5 | global.$VERSION = '18asd9a8dfy923'; 6 | 7 | // Constants 8 | const CURRENT_CACHE = `SW_CACHE:${$VERSION}`; 9 | const TEST_JS_PATH = 'https://www.test.com/test.js'; 10 | let cachedResponse; 11 | let runtimeResponse; 12 | 13 | describe('[generate-service-worker/templates] cache', function test() { 14 | beforeEach(() => { 15 | Object.assign(global, makeServiceWorkerEnv()); 16 | global.fetch.mockClear(); 17 | jest.resetModules(); 18 | 19 | cachedResponse = new Response('cached', {}); 20 | runtimeResponse = new Response('runtime', {}); 21 | }); 22 | 23 | describe('precache', () => { 24 | beforeEach(() => { 25 | global.$Cache = fixtures.$Cache(); 26 | require('../cache'); 27 | }); 28 | 29 | it('should not precache if empty', async () => { 30 | global.$Cache.precache = undefined; 31 | expect(self.snapshot().caches.hasOwnProperty(CURRENT_CACHE)).toEqual(false); 32 | await self.trigger('install'); 33 | expect(self.snapshot().caches.hasOwnProperty(CURRENT_CACHE)).toEqual(false); 34 | }); 35 | 36 | it('should handle precaching', async () => { 37 | global.$Cache.precache = [TEST_JS_PATH]; 38 | global.fetch.mockImplementation(() => Promise.resolve(runtimeResponse)); 39 | 40 | expect(self.snapshot().caches.hasOwnProperty(CURRENT_CACHE)).toEqual(false); 41 | await self.trigger('install'); 42 | expect(self.snapshot().caches[CURRENT_CACHE][TEST_JS_PATH]).toEqual(runtimeResponse); 43 | }); 44 | }); 45 | 46 | describe('[offline]', () => { 47 | beforeEach(() => { 48 | global.$Cache.offline = true; 49 | require('../cache'); 50 | }); 51 | 52 | it('should return the precached response if offline', async () => { 53 | // Fill cache with item 54 | const cachedHtml = 'Hi'; 55 | const cache = await self.caches.open(CURRENT_CACHE); 56 | await cache.put('SW_APP_SHELL', cachedHtml); 57 | 58 | // Go offline 59 | global.fetch.mockImplementation(() => { 60 | return new Promise(() => { 61 | throw new Error('offline'); 62 | }); 63 | }); 64 | 65 | const response = await self.trigger('fetch', new Request('/', { mode: 'navigate' })); 66 | expect(response[0]).toEqual(cachedHtml); 67 | }); 68 | 69 | it('should return the fetched response if online', async () => { 70 | // Fill cache with item 71 | const cachedHtml = 'Hi'; 72 | const cache = await self.caches.open(CURRENT_CACHE); 73 | await cache.put('SW_APP_SHELL', cachedHtml); 74 | 75 | global.fetch.mockImplementation(() => Promise.resolve(runtimeResponse)); 76 | 77 | const response = await self.trigger('fetch', new Request('/', { mode: 'navigate' })); 78 | expect(response[0]).toEqual(runtimeResponse); 79 | }); 80 | }); 81 | 82 | describe('[strategy] offline-only', () => { 83 | beforeEach(() => { 84 | global.$Cache = fixtures.$Cache({ 85 | strategy: [{ 86 | type: 'offline-only', 87 | matches: ['.*\\.js'] 88 | }] 89 | }); 90 | require('../cache'); 91 | }); 92 | 93 | it('should return fetch response if not offline', async () => { 94 | global.fetch.mockImplementation(() => Promise.resolve(runtimeResponse)); 95 | 96 | const cache = await self.caches.open(CURRENT_CACHE); 97 | await cache.put(new Request('/test.js'), cachedResponse); 98 | 99 | const response = await self.trigger('fetch', new Request('/test.js')); 100 | expect(response[0]).toEqual(runtimeResponse); 101 | }); 102 | 103 | it('should return cached data if offline', async () => { 104 | // Go offline 105 | global.fetch.mockImplementation(() => { 106 | return new Promise(() => { 107 | throw new Error('offline'); 108 | }); 109 | }); 110 | 111 | // Fill cache with item 112 | const cache = await self.caches.open(CURRENT_CACHE); 113 | await cache.put(new Request('/test.js'), cachedResponse); 114 | 115 | const response = await self.trigger('fetch', new Request('/test.js')); 116 | expect(response[0]).toEqual(cachedResponse); 117 | }); 118 | 119 | it('should fail gracefully if nothing in cache and offline', async () => { 120 | // Go offline 121 | global.fetch.mockImplementation(() => { 122 | return new Promise(() => { 123 | throw new Error('offline'); 124 | }); 125 | }); 126 | const response = await self.trigger('fetch', new Request('/test.js')); 127 | expect(response[0]).toEqual(undefined); 128 | }); 129 | }); 130 | 131 | describe('[strategy] fallback-only', () => { 132 | beforeEach(() => { 133 | global.$Cache = fixtures.$Cache({ 134 | strategy: [{ 135 | type: 'fallback-only', 136 | matches: ['.*\\.js'] 137 | }] 138 | }); 139 | require('../cache'); 140 | }); 141 | 142 | it('should use fetch response if valid', async () => { 143 | global.fetch.mockImplementation(() => Promise.resolve(runtimeResponse)); 144 | 145 | const response = await self.trigger('fetch', new Request('/test.js')); 146 | expect(response[0]).toEqual(runtimeResponse); 147 | }); 148 | 149 | it('should use cached response if invalid fetch response', async () => { 150 | global.fetch.mockImplementation(() => Promise.resolve(new Response('missing', { status: 404 }))); 151 | 152 | // Fill cache with item 153 | const cache = await self.caches.open(CURRENT_CACHE); 154 | await cache.put(new Request('/test.js'), cachedResponse); 155 | 156 | const response = await self.trigger('fetch', new Request('/test.js')); 157 | expect(response[0]).toEqual(cachedResponse); 158 | }); 159 | 160 | it('should fail gracefully if nothing in cache and fetch fails', async () => { 161 | global.fetch.mockImplementation(() => Promise.resolve(new Response('missing', { status: 404 }))); 162 | 163 | const response = await self.trigger('fetch', new Request('/test.js')); 164 | expect(response[0]).toEqual(undefined); 165 | }); 166 | }); 167 | 168 | describe('[strategy] prefer-cache', () => { 169 | beforeEach(() => { 170 | global.$Cache = fixtures.$Cache({ 171 | strategy: [{ 172 | type: 'prefer-cache', 173 | matches: ['.*\\.js'] 174 | }] 175 | }); 176 | require('../cache'); 177 | }); 178 | 179 | it('should use cached response if available', async () => { 180 | global.fetch.mockImplementation(() => Promise.resolve(runtimeResponse)); 181 | 182 | // Fill cache with item 183 | const cache = await self.caches.open(CURRENT_CACHE); 184 | await cache.put(new Request('/test.js'), cachedResponse); 185 | 186 | const response = await self.trigger('fetch', new Request('/test.js')); 187 | expect(global.fetch.mock.calls.length).toEqual(0); 188 | expect(response[0]).toEqual(cachedResponse); 189 | }); 190 | 191 | it('should perform fetch if no cache match', async () => { 192 | global.fetch.mockImplementation(() => Promise.resolve(runtimeResponse)); 193 | 194 | const response = await self.trigger('fetch', new Request('/test.js')); 195 | expect(global.fetch.mock.calls.length).toEqual(1); 196 | expect(response[0]).toEqual(runtimeResponse); 197 | }); 198 | 199 | it('should cache fetched response', async () => { 200 | global.fetch.mockImplementation(() => Promise.resolve(runtimeResponse)); 201 | 202 | expect(self.snapshot().caches[CURRENT_CACHE]).toEqual(undefined); 203 | const request = new Request('/test.js'); 204 | await self.trigger('fetch', request); 205 | const cacheValue = await self.snapshot().caches[CURRENT_CACHE][request.url].text(); 206 | const runtimeResponseValue = await runtimeResponse.text(); 207 | expect(cacheValue).toEqual(runtimeResponseValue); 208 | }); 209 | }); 210 | 211 | describe('[strategy] race', () => { 212 | beforeEach(() => { 213 | global.$Cache = fixtures.$Cache({ 214 | strategy: [{ 215 | type: 'race', 216 | matches: ['.*\\.js'] 217 | }] 218 | }); 219 | require('../cache'); 220 | }); 221 | 222 | it('should use cached response when it is faster', async () => { 223 | // Slow fetch 224 | global.fetch.mockImplementation(() => new Promise(resolve => { 225 | setTimeout(() => resolve(runtimeResponse), 50); 226 | })); 227 | 228 | // Fill cache with item 229 | const cache = await self.caches.open(CURRENT_CACHE); 230 | await cache.put(new Request('/test.js'), cachedResponse); 231 | 232 | const response = await self.trigger('fetch', new Request('/test.js')); 233 | expect(response[0]).toEqual(cachedResponse); 234 | }); 235 | 236 | it('should use fetched response when it is faster', async () => { 237 | global.fetch.mockImplementation(() => Promise.resolve(runtimeResponse)); 238 | // Slow cache 239 | self.caches.match = () => new Promise(resolve => { 240 | setTimeout(() => resolve(runtimeResponse), 50); 241 | }); 242 | 243 | // Fill cache with item 244 | const cache = await self.caches.open(CURRENT_CACHE); 245 | await cache.put(new Request('/test.js'), cachedResponse); 246 | 247 | const response = await self.trigger('fetch', new Request('/test.js')); 248 | expect(response[0]).toEqual(runtimeResponse); 249 | }); 250 | }); 251 | }); 252 | --------------------------------------------------------------------------------