diff --git a/__tests__/client/service-worker/events/fetch.spec.js b/__tests__/client/service-worker/events/fetch.spec.js index 8cbd54a3..2032799f 100644 --- a/__tests__/client/service-worker/events/fetch.spec.js +++ b/__tests__/client/service-worker/events/fetch.spec.js @@ -15,7 +15,7 @@ */ import { - expiration, cacheRouter, appShell, match, + expiration, cacheRouter, appShell, match, remove, createCacheName, } from '@americanexpress/one-service-worker'; import createFetchMiddleware from '../../../../src/client/service-worker/events/fetch'; @@ -36,19 +36,19 @@ function waitFor(asyncTarget, getTarget = () => asyncTarget.mock.calls) { return Promise.all(getTarget().reduce((array, next) => array.concat(next), [])); } -function createFetchEvent(url = '/index.html') { +function createFetchRequestResponse(url) { const request = new Request(url); const response = new Response('body', { url: request.url }); - ['json', 'text', 'clone'].forEach((method) => { - response[method] = response[method].bind(response); - jest.spyOn(response, method); - }); + return [request, response]; +} + +function createFetchEvent(url = '/index.html') { + const [request, response] = createFetchRequestResponse(url); const event = new global.FetchEvent('fetch', { request, }); event.response = response; ['waitUntil', 'respondWith'].forEach((method) => { - event[method] = event[method].bind(event); jest.spyOn(event, method); }); event.waitForCompletion = async () => { @@ -64,8 +64,6 @@ function createFetchEventsChainForURLS(middleware, urls = [], initEvent) { const event = createFetchEvent(url); if (typeof initEvent === 'function') { initEvent(event, index); - } else if (initEvent !== null) { - fetch.mockImplementationOnce(() => Promise.resolve(event.response)); } middleware(event); await event.waitForCompletion(); @@ -107,15 +105,18 @@ function createServiceWorkerEnvironment(target = global) { .then((results) => (results && results.map((result) => result.clone())) || []); }; // eslint-disable-next-line no-param-reassign - target.fetch = jest.fn(() => Promise.resolve({ - json: jest.fn(() => Promise.resolve()), - text: jest.fn(() => Promise.resolve()), - // eslint-disable-next-line no-restricted-globals - clone: jest.fn(new Response(null, { url: self.location })), - })); + target.fetch = jest.fn((url) => { + const [, response] = createFetchRequestResponse(url); + return Promise.resolve(response); + }); } describe('createFetchMiddleware', () => { + beforeAll(() => { + process.env.ONE_APP_BUILD_VERSION = '5.0.0'; + process.env.HOLOCRON_MODULE_MAP = '{ "modules": {} }'; + }); + test('createFetchMiddleware exports a function and calls middleware', () => { expect(createFetchMiddleware()).toBeInstanceOf(Function); expect(appShell).toHaveBeenCalledTimes(1); @@ -164,7 +165,6 @@ describe('createFetchMiddleware', () => { ], (event, index) => { // eslint-disable-next-line no-param-reassign if (index === 0) event.request.mode = 'navigate'; - fetch.mockImplementationOnce(() => Promise.resolve(event.response)); }); expect(fetch).toHaveBeenCalledTimes(0); @@ -173,4 +173,310 @@ describe('createFetchMiddleware', () => { }); }); }); + + describe('caching', () => { + const setOneAppVersion = (version = '1.2.3-rc.4-abc123') => { + process.env.ONE_APP_BUILD_VERSION = version; + }; + const setModuleMap = (moduleMap = { modules: {} }) => { + process.env.HOLOCRON_MODULE_MAP = JSON.stringify(moduleMap); + }; + + beforeEach(() => { + setOneAppVersion(); + setModuleMap({ + clientCacheRevision: '123', + modules: { + 'test-root': { + baseUrl: 'https://example.com/cdn/modules/test-root/2.2.2', + }, + 'child-module': { + baseUrl: 'https://cdn.example.com/nested/cdn/path/modules/child-module/2.3.4/', + }, + 'alt-child-module': { + baseUrl: 'https://example.com/alt-child-module/3.4.5/', + }, + 'local-module': { + baseUrl: 'http://localhost:3001/static/modules/local-module/4.5.6/', + }, + }, + }); + }); + + describe('not caching invalid urls', () => { + beforeAll(() => { + createServiceWorkerEnvironment(); + }); + + test('does not match any of the preset routers and does nothing with expiration or invalidation', async () => { + const middleware = createFetchMiddleware(); + const events = await createFetchEventsChainForURLS(middleware, [ + // module urls + 'https://example.com/index.html', + 'https://example.com/index.browser.js', + 'https://example.com/test-root.browser.js', + 'https://example.com/modules/test-root.legacy.browser.js', + 'https://example.com/modules/test-root/test-root.legacy.browser.js', + 'https://example.com/modules/test-root/2.2.2/test-root.legacy.browser.js', + 'https://example.com/test-root/Chunk.test-root.browser.js', + ]); + + expect.assertions(3 * events.length); + events.forEach((event) => { + // expect no activity to cache the response or attribute meta-data + expect(event.waitUntil).not.toHaveBeenCalled(); + // expect no responses from middleware + expect(event.respondWith).not.toHaveBeenCalled(); + // expect nothing set on the cache + expect(caches.snapshot()).toEqual({}); + }); + }); + }); + + describe('caching valid urls', () => { + let middleware; + + beforeAll(() => { + createServiceWorkerEnvironment(); + middleware = createFetchMiddleware(); + }); + + const urls = new Map([ + [ + 'one-app', + [ + 'https://example.com/cdn/app/1.2.3-rc.4-abc123/vendors.js', + 'https://cdn.example.com/static/cdn/app/1.2.3-rc.4-abc123/app~vendors.js', + 'https://cdn.example.com/cdn/app/1.2.3-rc.4-abc123/legacy/i18n/de-DE.js', + 'https://example.com/cdn/app/1.2.3-rc.4-abc123/i18n/es-MX.js', + 'https://example.com/_/static/app/1.2.3-rc.4-abc123/runtime.js', + 'https://example.com/_/static/app/1.2.3-rc.4-abc123/i18n/en-US.js', + 'https://example.com/_/static/app/1.2.3-rc.4-abc123/app.js', + 'https://example.com/static/app/1.2.3-rc.4-abc123/legacy/app.js', + 'https://cdn.example.com/cdn/app/1.2.3-rc.4-abc123/legacy/app~vendors.js', + 'https://example.com/_/static/app/1.2.3-rc.4-abc123/legacy/i18n/en-US.js', + // during development + 'http://localhost:3001/static/app/1.2.3-rc.4-abc123/i18n/en-US.js', + 'http://localhost:3000/_/static/app/1.2.3-rc.4-abc123/app.js', + ], + ], + [ + 'modules', + [ + // bare essentials + 'https://example.com/cdn/modules/test-root/2.2.2/test-root.browser.js', + 'https://cdn.example.com/nested/cdn/path/modules/child-module/2.3.4/child-module.browser.js', + 'https://example.com/alt-child-module/3.4.5/alt-child-module.browser.js?clientCacheRevision=123', + // legacy module + 'https://example.com/cdn/modules/test-root/2.2.2/test-root.legacy.browser.js', + // chunks + 'https://example.com/cdn/modules/test-root/2.2.2/TestRootChunk.test-root.chunk.browser.js', + 'https://example.com/cdn/modules/test-root/2.2.2/TestRootChunk.test-root.chunk.legacy.browser.js', + // clientCacheRevision key + 'https://example.com/cdn/modules/test-root/2.2.2/test-root.browser.js?clientCacheRevision=123', + // during development + 'http://localhost:3001/static/modules/local-module/4.5.6/local-root.browser.js', + 'http://localhost:3001/static/modules/local-module/4.5.6/local-root.legacy.browser.js?clientCacheRevision=123', + ], + ], + [ + 'lang-packs', + [ + // lang-packs + 'https://example.com/cdn/modules/test-root/2.2.2/en-US/test-root.json', + 'https://cdn.example.com/nested/cdn/path/modules/child-module/2.3.4/de-DE/child-module.json', + 'https://example.com/cdn/modules/test-root/2.2.2/locale/zs-ASFJKHASKF/test-root.json', + 'https://cdn.example.com/nested/cdn/path/modules/child-module/2.3.4/fr-FR/integration.json', + // during development + 'http://localhost:3001/static/modules/local-module/4.5.6/en-us/integration.json', + 'http://localhost:3001/static/modules/local-module/4.5.6/locale/en-CA/local-module.json', + 'http://localhost:3001/static/modules/local-module/4.5.6/en/production.json', + ], + ], + ]); + + urls.forEach((urlsToTest, cacheName) => { + urlsToTest.forEach((url) => { + test(`matches (${cacheName}) ${url}`, async () => { + const event = createFetchEvent(url); + middleware(event); + await event.waitForCompletion(); + const { + clone, json, text, ...eventResponse + } = event.response; + await expect( + match(url, { cacheName: createCacheName(cacheName) }) + ).resolves.toEqual(eventResponse); + }); + }); + }); + }); + + describe('invalidation', () => { + beforeEach(() => { + // clear all internal caches already used + // https://github.com/zackargyle/service-workers/blob/master/packages/service-worker-mock/models/CacheStorage.js#L6 + caches.caches = {}; + }); + + test('caches all app assets and invalidates app version', async () => { + expect.assertions(11); + + const createOneAppEvents = async (oneAppVersion) => { + const middleware = createFetchMiddleware({ oneAppVersion }); + const events = await createFetchEventsChainForURLS(middleware, [ + `https://example.com/cdn/app/${oneAppVersion}/app~vendors.js`, + `https://example.com/cdn/app/${oneAppVersion}/vendors.js`, + `https://example.com/cdn/app/${oneAppVersion}/runtime.js`, + `https://example.com/cdn/app/${oneAppVersion}/i18n/en-US.js`, + `https://example.com/cdn/app/${oneAppVersion}/app.js`, + ]); + return events; + }; + + const oldVersionEvents = await createOneAppEvents('0.1.2'); + const newVersionEvents = await createOneAppEvents('1.2.3-rc.4-abc123'); + expect(fetch).toHaveBeenCalledTimes(10); + + await Promise.all(newVersionEvents.map(async (event) => { + await expect(match(event.request)).resolves.toBeInstanceOf(Response); + })); + + await Promise.all(oldVersionEvents.map(async (event) => { + await expect(match(event.request)).resolves.toBe(null); + })); + }); + + test('caches only one language pack per module and invalidates the existing lang pack', async () => { + expect.assertions(12); + + const middleware = createFetchMiddleware(); + const events = await createFetchEventsChainForURLS(middleware, [ + 'https://example.com/cdn/modules/test-root/2.2.2/test-root.browser.js', + 'https://example.com/cdn/modules/test-root/2.2.2/locale/en-US/test-root.json', + 'https://example.com/cdn/modules/test-root/2.2.2/locale/en-CA/test-root.json', + ]); + + expect(fetch).toHaveBeenCalledTimes(3); + events.forEach((event, index) => { + expect(event.respondWith).toHaveBeenCalledTimes(1); + expect(event.respondWith).toHaveBeenCalledWith(Promise.resolve(event.response)); + if (index === 2) { + // on the third and final call, we expect to waitUntil the original + // cache item was removed as an additional step when invalidating + expect(event.waitUntil).toHaveBeenCalledTimes(4); + } else { + // otherwise the previous two calls should + expect(event.waitUntil).toHaveBeenCalledTimes(3); + } + }); + await expect(match(new Request('https://example.com/cdn/modules/test-root/2.2.2/locale/en-US/test-root.json'))).resolves.toBe(null); + await expect(match(new Request('https://example.com/cdn/modules/test-root/2.2.2/locale/en-CA/test-root.json'))).resolves.toBeInstanceOf(Response); + }); + + test('caches a module and invalidates with differing `clientCacheRevision` key', async () => { + expect.assertions(10); + + let middleware = createFetchMiddleware(); + + const url = 'https://example.com/cdn/modules/test-root/2.2.2/test-root.browser.js?clientCacheRevision=123'; + const event = createFetchEvent(url); + fetch.mockImplementationOnce(() => Promise.resolve(event.response)); + middleware(event); + await event.waitForCompletion(); + + expect(event.waitUntil).toHaveBeenCalledTimes(3); + expect(event.respondWith).toHaveBeenCalledTimes(1); + expect(event.respondWith).toHaveBeenCalledWith(Promise.resolve(event.response)); + + // a second response with a differing clientCacheRevision key + + setModuleMap({ + ...JSON.parse(process.env.HOLOCRON_MODULE_MAP), + clientCacheRevision: 'def', + }); + + middleware = createFetchMiddleware(); + + const nextUrl = 'https://example.com/cdn/modules/test-root/2.2.2/test-root.browser.js?clientCacheRevision=def'; + const nextEvent = createFetchEvent(nextUrl); + middleware(nextEvent); + await nextEvent.waitForCompletion(); + + expect(nextEvent.waitUntil).toHaveBeenCalledTimes(4); + expect(remove).toHaveBeenCalledTimes(1); + expect(nextEvent.respondWith).toHaveBeenCalledTimes(1); + expect(nextEvent.respondWith).toHaveBeenCalledWith(Promise.resolve(nextEvent.response)); + + const cachesSnapShot = caches.snapshot(); + expect(cachesSnapShot['__sw/modules'][url]).toBeUndefined(); + expect(cachesSnapShot['__sw/modules'][nextUrl]).toEqual(nextEvent.response); + expect( + JSON.parse(cachesSnapShot['__sw/__meta']['http://localhost/__sw/__meta/modules/test-root/test-root.browser.js'].body.parts.join('')) + ).toEqual({ + name: 'test-root', + version: '2.2.2', + path: '/test-root.browser.js', + revision: 'def', + type: 'modules', + url: nextUrl, + cacheName: '__sw/modules', + }); + }); + + test('caches a module and invalidates with differing module `version`', async () => { + expect.assertions(10); + + let middleware = createFetchMiddleware(); + + const url = 'https://example.com/cdn/modules/test-root/2.2.2/test-root.browser.js'; + const event = createFetchEvent(url); + fetch.mockImplementationOnce(() => Promise.resolve(event.response)); + middleware(event); + await event.waitForCompletion(); + + expect(event.waitUntil).toHaveBeenCalledTimes(3); + expect(event.respondWith).toHaveBeenCalledTimes(1); + expect(event.respondWith).toHaveBeenCalledWith(Promise.resolve(event.response)); + + // a second response with a differing module version + + setModuleMap({ + clientCacheRevision: '123', + modules: { + 'test-root': { + baseUrl: 'https://example.com/cdn/modules/test-root/3.2.1', + }, + }, + }); + + middleware = createFetchMiddleware(); + + const nextUrl = 'https://example.com/cdn/modules/test-root/3.2.1/test-root.browser.js'; + const nextEvent = createFetchEvent(nextUrl); + middleware(nextEvent); + await nextEvent.waitForCompletion(); + + expect(nextEvent.waitUntil).toHaveBeenCalledTimes(4); + expect(remove).toHaveBeenCalledTimes(1); + expect(nextEvent.respondWith).toHaveBeenCalledTimes(1); + expect(nextEvent.respondWith).toHaveBeenCalledWith(Promise.resolve(nextEvent.response)); + + const cachesSnapShot = caches.snapshot(); + expect(cachesSnapShot['__sw/modules'][url]).toBeUndefined(); + expect(cachesSnapShot['__sw/modules'][nextUrl]).toEqual(nextEvent.response); + expect( + JSON.parse(cachesSnapShot['__sw/__meta']['http://localhost/__sw/__meta/modules/test-root/test-root.browser.js'].body.parts.join('')) + ).toEqual({ + name: 'test-root', + version: '3.2.1', + path: '/test-root.browser.js', + revision: '123', + type: 'modules', + url: nextUrl, + cacheName: '__sw/modules', + }); + }); + }); + }); }); diff --git a/__tests__/client/service-worker/events/utility.spec.js b/__tests__/client/service-worker/events/utility.spec.js new file mode 100644 index 00000000..e89253b0 --- /dev/null +++ b/__tests__/client/service-worker/events/utility.spec.js @@ -0,0 +1,281 @@ +/* + * Copyright 2020 American Express Travel Related Services Company, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { + match, put, getMetaData, setMetaData, remove, +} from '@americanexpress/one-service-worker'; +import Request from 'service-worker-mock/models/Request'; +import { + markResourceForRemoval, + createResourceMetaData, + invalidateCacheResource, + setCacheResource, + fetchCacheResource, +} from '../../../../src/client/service-worker/events/utility'; + + +jest.mock('@americanexpress/one-service-worker', () => ({ + put: jest.fn(() => Promise.resolve()), + match: jest.fn(() => Promise.resolve()), + getMetaData: jest.fn(() => Promise.resolve({})), + setMetaData: jest.fn(() => Promise.resolve()), + remove: jest.fn(() => Promise.resolve()), + createCacheName: jest.fn((passthrough) => ['__sw', passthrough].join('/')), +})); + +beforeAll(() => { + global.fetch = jest.fn(() => Promise.resolve()); + global.Request = Request; +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe(markResourceForRemoval.name, () => { + test('invalidates each matrix of validation', () => { + const existingMetaData = { + revision: '101010', + locale: 'en-US', + version: '1.0.0', + }; + const newMetaData = { ...existingMetaData }; + expect(markResourceForRemoval(existingMetaData, newMetaData)).toBe(false); + [ + // version change, whether up or down, will invalidate + ['version', '4.5.6'], + // only a single locale and lang-pack per cache + ['locale', 'en-CA'], + // if the clientCacheRevision has changed, we should update + ['revision', '42'], + // we are not invalidating for cacheName changes + ['cacheName', 'change-cache', false], + ].forEach(([propName, value, result = true]) => { + // set the value for a given prop name to validate + newMetaData[propName] = value; + // run validation and observe expected result + expect(markResourceForRemoval(existingMetaData, newMetaData)).toBe(result); + // reset the property to match the existingMetaData + newMetaData[propName] = existingMetaData[propName]; + }); + }); +}); + +describe(createResourceMetaData.name, () => { + test.each([ + // app + [ + 'https://example.com/cdn/app/1.2.3-rc.4-abc123/app.js', + ['app', 'https://example.com/cdn/app/1.2.3-rc.4-abc123/'], { + type: 'one-app', + cacheName: '__sw/one-app', + name: 'app', + version: '1.2.3-rc.4-abc123', + path: 'app.js', + }, + ], + [ + 'https://example.com/cdn/app/1.2.3-rc.4-abc123/i18n/en-US.js', + ['app', 'https://example.com/cdn/app/1.2.3-rc.4-abc123/'], { + type: 'one-app', + cacheName: '__sw/one-app', + name: 'app', + version: '1.2.3-rc.4-abc123', + path: 'i18n/language.js', + locale: 'en-US', + }, + ], + // modules + [ + 'https://example.com/cdn/modules/test-root/2.2.2/test-root.browser.js', + ['module', 'https://example.com/cdn/modules/test-root/2.2.2/'], { + type: 'modules', + name: 'module', + version: '2.2.2', + cacheName: '__sw/modules', + path: 'test-root.browser.js', + }, + ], + [ + 'https://example.com/cdn/modules/test-root/2.2.2/locale/en-US/test-root.json', + ['module', 'https://example.com/cdn/modules/test-root/2.2.2/'], { + name: 'module', + version: '2.2.2', + type: 'lang-packs', + path: 'en-US/test-root.json', + cacheName: '__sw/lang-packs', + locale: 'en-US', + }, + ], + [ + 'https://example.com/cdn/modules/test-root/2.2.2/test-root.browser.js', + ['module', 'https://example.com/cdn/modules/test-root/2.2.2/'], { + type: 'modules', + name: 'module', + version: '2.2.2', + cacheName: '__sw/modules', + path: 'test-root.browser.js', + }, + ], + [ + 'https://example.com/cdn/modules/test-root/2.2.2/test-root.browser.js', + ['module', 'https://example.com/cdn/modules/test-root/2.2.2/', '101010'], { + type: 'modules', + name: 'module', + version: '2.2.2', + cacheName: '__sw/modules', + path: 'test-root.browser.js', + revision: '101010', + }, + ], + ])('extracts metadata from %s', (url, resourceInfo, result) => { + // eslint-disable-next-line no-param-reassign + const meta = createResourceMetaData({ request: { url } }, resourceInfo); + expect(meta).toEqual({ ...result, url }); + }); +}); + +describe(invalidateCacheResource.name, () => { + const existingMetaData = { + cacheName: '__sw/modules', + name: 'my-module', + path: 'my-module.browser.js', + type: 'modules', + url: 'https://example.com/cdn/modules/my-module/1.0.0/my-module.browser.js', + version: '1.0.0', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('gets the resource metadata and validates the incoming request', async () => { + expect.assertions(6); + + const waitUntil = jest.fn(); + const meta = { ...existingMetaData }; + const event = { request: { url: meta.url }, waitUntil }; + const response = {}; + const responseHandler = invalidateCacheResource(event, meta); + + getMetaData.mockImplementationOnce(() => Promise.resolve({})); + + expect(responseHandler(response)).toBe(response); + expect(waitUntil).toHaveBeenCalledTimes(1); + expect(getMetaData).toHaveBeenCalledTimes(1); + expect(getMetaData).toHaveBeenCalledWith({ cacheName: 'modules/my-module/my-module.browser.js' }); + await waitUntil.mock.calls[0][0]; + expect(setMetaData).toHaveBeenCalledTimes(1); + expect(remove).not.toHaveBeenCalled(); + }); + + test('invalidates the incoming request due to version change', async () => { + expect.assertions(7); + + const meta = { ...existingMetaData }; + const newVersion = '1.0.5'; + const newUrl = meta.url.replace(meta.version, newVersion); + + const waitUntil = jest.fn(); + const event = { request: { url: newUrl }, waitUntil }; + const response = {}; + const responseHandler = invalidateCacheResource(event, meta); + + getMetaData.mockImplementationOnce(() => Promise.resolve({ ...meta, version: newVersion })); + + expect(responseHandler(response)).toBe(response); + expect(waitUntil).toHaveBeenCalledTimes(1); + expect(getMetaData).toHaveBeenCalledTimes(1); + expect(getMetaData).toHaveBeenCalledWith({ cacheName: 'modules/my-module/my-module.browser.js' }); + await waitUntil.mock.calls[0][0]; + expect(waitUntil).toHaveBeenCalledTimes(2); + expect(setMetaData).toHaveBeenCalledTimes(1); + expect(remove).toHaveBeenCalledTimes(1); + }); +}); + +describe(setCacheResource.name, () => { + const mockMetaData = { + path: 'my-module.browser.js', + url: 'https://example.com/cdn/modules/my-module/1.0.0/my-module.browser.js', + cacheName: '__sw/modules', + }; + + test('calls "put" on the cache with the resource', async () => { + expect.assertions(5); + + const clone = jest.fn(() => 'clone'); + const waitUntil = jest.fn(); + const meta = { cacheName: mockMetaData.cacheName }; + const event = { request: { url: mockMetaData.url, clone }, waitUntil }; + const response = { clone }; + const responseHandler = setCacheResource(event, meta); + + expect(responseHandler(response)).toBe(response); + expect(waitUntil).toHaveBeenCalledTimes(1); + expect(clone).toHaveBeenCalledTimes(2); + expect(put).toHaveBeenCalledTimes(1); + expect(put).toHaveBeenCalledWith('clone', 'clone', meta); + }); +}); + +describe(fetchCacheResource.name, () => { + const mockMetaData = { + path: 'my-module.browser.js', + url: 'https://example.com/cdn/modules/my-module/1.0.0/my-module.browser.js', + cacheName: '__sw/modules', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('calls "match" on the cache falling back to "fetch" and finally runs invalidation', async () => { + expect.assertions(5); + + const clone = jest.fn(() => 'clone'); + const waitUntil = jest.fn(); + const meta = { ...mockMetaData }; + const event = { request: { url: meta.url, clone }, waitUntil }; + const response = { clone }; + + fetch.mockImplementationOnce(() => Promise.resolve(response)); + + await expect(fetchCacheResource(event, meta)).resolves.toBe(response); + expect(clone).toHaveBeenCalledTimes(4); + expect(fetch).toHaveBeenCalledTimes(1); + expect(match).toHaveBeenCalledTimes(1); + expect(match).toHaveBeenCalledWith('clone', { cacheName: mockMetaData.cacheName }); + }); + + test('calls "match" and responds from the cache', async () => { + expect.assertions(5); + + const clone = jest.fn(() => 'clone'); + const waitUntil = jest.fn(); + const meta = { ...mockMetaData }; + const event = { request: { url: meta.url, clone }, waitUntil }; + const response = { clone }; + + match.mockImplementationOnce(() => Promise.resolve(response)); + + await expect(fetchCacheResource(event, meta)).resolves.toBe(response); + expect(clone).toHaveBeenCalledTimes(1); + expect(fetch).not.toHaveBeenCalled(); + expect(match).toHaveBeenCalledTimes(1); + expect(match).toHaveBeenCalledWith('clone', { cacheName: mockMetaData.cacheName }); + }); +}); diff --git a/__tests__/client/service-worker/worker.spec.js b/__tests__/client/service-worker/worker.spec.js index 02b9a81c..445f3a1b 100644 --- a/__tests__/client/service-worker/worker.spec.js +++ b/__tests__/client/service-worker/worker.spec.js @@ -40,6 +40,11 @@ beforeEach(() => { jest.clearAllMocks(); }); +beforeAll(() => { + process.env.ONE_APP_BUILD_VERSION = '5.0.0'; + process.env.HOLOCRON_MODULE_MAP = '{ "modules": {} }'; +}); + describe('service worker script', () => { beforeAll(() => { self.postMessage = jest.fn(); diff --git a/__tests__/server/middleware/pwa/service-worker.spec.js b/__tests__/server/middleware/pwa/service-worker.spec.js index bf8537d2..e2efd014 100644 --- a/__tests__/server/middleware/pwa/service-worker.spec.js +++ b/__tests__/server/middleware/pwa/service-worker.spec.js @@ -16,8 +16,10 @@ import serviceWorkerMiddleware from '../../../../src/server/middleware/pwa/service-worker'; import { getServerPWAConfig } from '../../../../src/server/middleware/pwa/config'; +import { getClientModuleMapCache } from '../../../../src/server/utils/clientModuleMapCache'; jest.mock('../../../../src/server/middleware/pwa/config'); +jest.mock('../../../../src/server/utils/clientModuleMapCache'); const serviceWorkerStandardScript = '[service-worker-script]'; const serviceWorkerRecoveryScript = '[service-worker-recovery-script]'; @@ -40,6 +42,12 @@ function createServiceWorkerConfig({ type, scope } = {}) { }; } +beforeAll(() => { + getClientModuleMapCache.mockImplementation(() => ({ + browser: { modules: {} }, + })); +}); + describe('service worker middleware', () => { test('middleware factory returns function', () => { expect(serviceWorkerMiddleware()).toBeInstanceOf(Function); @@ -73,7 +81,7 @@ describe('service worker middleware', () => { expect(res.type).toHaveBeenCalledWith('js'); expect(res.set).toHaveBeenCalledWith('Service-Worker-Allowed', '/'); expect(res.set).toHaveBeenCalledWith('Cache-Control', 'no-store, no-cache'); - expect(res.send).toHaveBeenCalledWith(serviceWorkerStandardScript); + expect(res.send).toHaveBeenCalledWith(Buffer.from(serviceWorkerStandardScript)); }); test('middleware responds with service worker noop script', () => { @@ -95,7 +103,7 @@ describe('service worker middleware', () => { expect(res.type).toHaveBeenCalledWith('js'); expect(res.set).toHaveBeenCalledWith('Service-Worker-Allowed', '/'); expect(res.set).toHaveBeenCalledWith('Cache-Control', 'no-store, no-cache'); - expect(res.send).toHaveBeenCalledWith(serviceWorkerRecoveryScript); + expect(res.send).toHaveBeenCalledWith(Buffer.from(serviceWorkerRecoveryScript)); }); test('middleware responds with service worker escape hatch script', () => { @@ -117,6 +125,32 @@ describe('service worker middleware', () => { expect(res.type).toHaveBeenCalledWith('js'); expect(res.set).toHaveBeenCalledWith('Service-Worker-Allowed', '/'); expect(res.set).toHaveBeenCalledWith('Cache-Control', 'no-store, no-cache'); - expect(res.send).toHaveBeenCalledWith(serviceWorkerEscapeHatchScript); + expect(res.send).toHaveBeenCalledWith(Buffer.from(serviceWorkerEscapeHatchScript)); + }); + + test('replaces HOLOCRON_MODULE_MAP in service worker script', () => { + getServerPWAConfig.mockImplementationOnce(() => { + const config = createServiceWorkerConfig({ type: 'standard' }); + config.serviceWorkerScript = 'process.env.HOLOCRON_MODULE_MAP'; + return config; + }); + + const middleware = serviceWorkerMiddleware(); + const next = jest.fn(); + const res = {}; + res.send = jest.fn(() => res); + res.set = jest.fn(() => res); + res.type = jest.fn(() => res); + + expect(middleware(null, res, next)).toBe(res); + + expect(res.send).toHaveBeenCalledTimes(1); + expect(res.type).toHaveBeenCalledTimes(1); + expect(res.set).toHaveBeenCalledTimes(2); + expect(next).not.toHaveBeenCalled(); + expect(res.type).toHaveBeenCalledWith('js'); + expect(res.set).toHaveBeenCalledWith('Service-Worker-Allowed', '/'); + expect(res.set).toHaveBeenCalledWith('Cache-Control', 'no-store, no-cache'); + expect(res.send).toHaveBeenCalledWith(Buffer.from(`'${JSON.stringify(getClientModuleMapCache().browser)}'`)); }); }); diff --git a/docs/api/modules/App-Configuration.md b/docs/api/modules/App-Configuration.md index 869f3623..3901e1af 100644 --- a/docs/api/modules/App-Configuration.md +++ b/docs/api/modules/App-Configuration.md @@ -255,6 +255,19 @@ To enable installing an app, please set the value for `start_url`, `icons` and ` in the web manifest. If desired, a route can be used to match the `start_url` and used when an installed PWA is opened directly from the device. +#### Caching + +When the service worker is enabled, both Holocron module and One App resources are cached +in the browser using [Cache Storage](https://developer.mozilla.org/en-US/docs/Web/API/CacheStorage) +and [Cache](https://developer.mozilla.org/en-US/docs/Web/API/Cache) API. The cached resources +are available when offline and each resource in the cache is validated (or invalidated) by meta +data associated with each resource that is cached and/or requested. There are four meta properties +that are used for invalidation on a per module basis in tandem to One App static resources; +`version`, `locale` (if applicable - language packs, etc) and the Holocron module map +`clientCacheRevision` key. If a resource is invalidated for a newer or older version +for example, the service worker will remove the stale resource from the cache and place in the +recently requested resource. + **Shape** ```js if (!global.BROWSER) { diff --git a/package-lock.json b/package-lock.json index 511331e8..4a0548a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5618,6 +5618,16 @@ "@types/node": ">= 8" } }, + "@rollup/plugin-babel": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.0.4.tgz", + "integrity": "sha512-MBtNoi5gqBEbqy1gE9jZBfPsi10kbuK2CEu9bx53nk1Z3ATRvBOoZ/GsbhXOeVbS76xXi/DeYM+vYX6EGIDv9A==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.7.4", + "@rollup/pluginutils": "^3.0.8" + } + }, "@rollup/plugin-node-resolve": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-8.0.1.tgz", @@ -5644,6 +5654,16 @@ } } }, + "@rollup/plugin-replace": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.3.3.tgz", + "integrity": "sha512-XPmVXZ7IlaoWaJLkSCDaa0Y6uVo5XQYHhiMFzOd5qSv5rE+t/UJToPIOE56flKIxBFQI27ONsxb7dqHnwSsjKQ==", + "dev": true, + "requires": { + "@rollup/pluginutils": "^3.0.8", + "magic-string": "^0.25.5" + } + }, "@rollup/pluginutils": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", @@ -19112,6 +19132,15 @@ "integrity": "sha512-OHhSbtcviqMPt7yfw5ef5aghS2jzFVKEFyCJndQt2YpSQ9qRVSEv2axSJI1paVThEu+FFGs584h/1YhxjVqajA==", "dev": true }, + "magic-string": { + "version": "0.25.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", + "integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==", + "dev": true, + "requires": { + "sourcemap-codec": "^1.4.4" + } + }, "make-dir": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", @@ -22890,6 +22919,12 @@ "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz", "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=" }, + "sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "dev": true + }, "spawn-command": { "version": "0.0.2-1", "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2-1.tgz", diff --git a/package.json b/package.json index 320b62b8..a8ff895d 100644 --- a/package.json +++ b/package.json @@ -129,7 +129,9 @@ "@babel/register": "^7.10.4", "@commitlint/cli": "^9.0.1", "@commitlint/config-conventional": "^9.0.1", + "@rollup/plugin-babel": "^5.0.4", "@rollup/plugin-node-resolve": "^8.0.1", + "@rollup/plugin-replace": "^2.3.3", "accepts": "^1.3.7", "acorn": "^7.2.0", "amex-jest-preset-react": "^6.1.0", diff --git a/scripts/build-service-workers.js b/scripts/build-service-workers.js index 09df1ff2..030d5023 100644 --- a/scripts/build-service-workers.js +++ b/scripts/build-service-workers.js @@ -18,16 +18,36 @@ const path = require('path'); const rollup = require('rollup'); +const replace = require('@rollup/plugin-replace'); const resolve = require('@rollup/plugin-node-resolve').default; -const babel = require('rollup-plugin-babel'); +const babel = require('@rollup/plugin-babel').default; -async function buildServiceWorkerScripts({ dev = false, watch = false, minify = true } = {}) { +async function buildServiceWorkerScripts({ + buildVersion, + dev = false, + watch = false, + minify = true, +} = {}) { const inputDirectory = path.resolve(__dirname, '../src/client/service-worker'); const buildFolderDirectory = path.resolve(__dirname, '../lib/server/middleware/pwa', 'scripts'); const plugins = [ + replace({ + 'process.env.ONE_APP_BUILD_VERSION': `"${buildVersion}"`, + }), resolve(), - babel(), + babel({ + // we need to override the current .babelrc and not extend it + babelrc: false, + babelHelpers: 'bundled', + presets: [['amex', { + 'preset-env': { + spec: true, + // preserve ES syntax and allow rollup to handle the final output + modules: false, + }, + }]], + }), ]; if (minify) { @@ -70,7 +90,9 @@ async function buildServiceWorkerScripts({ dev = false, watch = false, minify = if (require.main === module) { (async function buildWorkers({ dev }) { - await buildServiceWorkerScripts({ dev }); + // eslint-disable-next-line global-require + const { buildVersion } = require('../.build-meta.json'); + await buildServiceWorkerScripts({ dev, buildVersion }); }({ dev: process.env.NODE_ENV === 'development', })); diff --git a/src/client/service-worker/events/README.md b/src/client/service-worker/events/README.md new file mode 100644 index 00000000..1748e710 --- /dev/null +++ b/src/client/service-worker/events/README.md @@ -0,0 +1,29 @@ +# Service Worker Events + +### `install` + +Initializes the service worker and [skips waiting](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/skipWaiting) +during service worker installation. + +### `activate` + +[Claims all the open window clients](https://developer.mozilla.org/en-US/docs/Web/API/Clients/claim) +when the service worker is activated. + +### `fetch` + +Supports caching One App and Holocron module resources in tandem to supporting +offline navigation. + +There are two pseudo environment variables used in One App service worker fetch +handler, accessed via `process.env`: + +- `process.env.ONE_APP_BUILD_VERSION` +- `process.env.HOLOCRON_MODULE_MAP` + +These values are valid to use in node/testing environment as is, however during +build and runtime, these values are textually replaced. `ONE_APP_BUILD_VERSION` +is replaced with One App version during build time, after the app is built. For +`HOLOCRON_MODULE_MAP`, the value is injected into the service worker in the +[service worker middleware](../../../server/middleware/pwa/service-worker.js) +with the most current Holocron module map that was loaded/polled. \ No newline at end of file diff --git a/src/client/service-worker/events/fetch.js b/src/client/service-worker/events/fetch.js index 313b2afb..98921e69 100644 --- a/src/client/service-worker/events/fetch.js +++ b/src/client/service-worker/events/fetch.js @@ -23,8 +23,54 @@ import { } from '@americanexpress/one-service-worker'; import { OFFLINE_CACHE_NAME } from '../constants'; +import { + createResourceMetaData, + fetchCacheResource, + getOneAppVersion, + getHolocronModuleMap, +} from './utility'; + +function createAppCachingMiddleware(oneAppVersion) { + return function appResourceCachingMiddleware(event, context) { + if (event.request.url.includes(oneAppVersion)) { + const [baseAppUrl] = event.request.url.split(oneAppVersion); + const meta = createResourceMetaData( + event, + ['app', `${baseAppUrl}${oneAppVersion}/`] + ); + context.set('cacheName', meta.cacheName); + context.set('request', event.request.clone()); + event.respondWith(fetchCacheResource(event, meta)); + } + }; +} + +function createHolocronCachingMiddleware(holocronModuleMap) { + const { clientCacheRevision, modules } = holocronModuleMap; + const moduleNames = Object.keys(modules); + const moduleEntries = moduleNames.map((name) => [name, modules[name].baseUrl]); + + return function holocronCachingMiddleware(event, context) { + // TODO: optimize - revise algorithm for matching + const matchingModule = moduleEntries + .find(([, baseUrl]) => event.request.url.startsWith(baseUrl)); + + if (matchingModule) { + const meta = createResourceMetaData( + event, + matchingModule.concat(clientCacheRevision) + ); + context.set('cacheName', meta.cacheName); + context.set('request', event.request.clone()); + event.respondWith(fetchCacheResource(event, meta)); + } + }; +} -export default function createFetchMiddleware() { +export default function createFetchMiddleware({ + oneAppVersion = getOneAppVersion(), + holocronModuleMap = getHolocronModuleMap(), +} = {}) { return createMiddleware([ appShell({ route: '/_/pwa/shell', @@ -34,6 +80,8 @@ export default function createFetchMiddleware() { match: /manifest\.webmanifest$/, cacheName: OFFLINE_CACHE_NAME, }), + createHolocronCachingMiddleware(holocronModuleMap), + createAppCachingMiddleware(oneAppVersion), expiration(), ]); } diff --git a/src/client/service-worker/events/utility.js b/src/client/service-worker/events/utility.js new file mode 100644 index 00000000..54fbfd2e --- /dev/null +++ b/src/client/service-worker/events/utility.js @@ -0,0 +1,133 @@ +/* + * Copyright 2020 American Express Travel Related Services Company, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { + match, put, remove, getMetaData, setMetaData, createCacheName, +} from '@americanexpress/one-service-worker'; + +// language +const appLocaleRegExp = /i18n\/([^/]*)\.js$/; +const moduleLocaleRegExp = /([a-z]{2,3}(-[a-zA-Z]{1,})?)\/[^/]*\.json$/; +// url: +// matches the full url path in the first capture group and +// the search params in the second capture group +const clientCacheRevisionRegexp = /(.*)(\?[^/]*)$/; + +export function getOneAppVersion() { + return process.env.ONE_APP_BUILD_VERSION; +} + +export function getHolocronModuleMap() { + return JSON.parse(process.env.HOLOCRON_MODULE_MAP); +} + +export function createResourceMetaData(event, resourceInfo) { + const [name, baseUrl, revision] = resourceInfo; + const [version] = baseUrl.replace(/\/$/, '').split('/').reverse(); + const { request } = event; + + let type = name === 'app' ? 'one-app' : 'modules'; + + let path; + let locale; + if (moduleLocaleRegExp.test(request.url)) { + type = 'lang-packs'; + [path, locale] = request.url.match(moduleLocaleRegExp); + } else if (appLocaleRegExp.test(request.url)) { + [path, locale] = request.url.match(appLocaleRegExp); + // write over the intl resource to allow locale invalidation + path = path.replace(locale, 'language'); + } else { + // if not an i18n resource type, we only extract the base filename as the path + // we remove the clientCacheRevision from the url if present + path = request.url.replace(baseUrl, '').replace(clientCacheRevisionRegexp, '$1'); + } + + const metaData = { + // type can be one of ['one-app', 'modules', 'lang-packs'] + type, + // name will be the module name for module resources (module, lang-pack) + // and will be 'app' if it is a one-app resource + name, + // the version is the current version of any resource + version, + // path will be the base filename and is used to point the correct meta-data with a resource + path, + // url will be the original url requested, used to add/delete any resource + url: request.url, + // cacheName is the name of the cache storing the resource + // it is used to match and put resources into the correct cache, and remove it when needed + cacheName: createCacheName(type), + }; + + // optional meta data when applicable + // revision will be included if the clientCacheRevision key is present in the url + if (revision) metaData.revision = revision; + // locale will be included when either a module language pack or one-app i18n file + if (locale) metaData.locale = locale; + + return metaData; +} + +export function markResourceForRemoval(cachedMetaRecord, newMetaRecord) { + if (cachedMetaRecord.revision !== newMetaRecord.revision) return true; + if (cachedMetaRecord.version !== newMetaRecord.version) return true; + if (cachedMetaRecord.locale !== newMetaRecord.locale) return true; + return false; +} + +export function invalidateCacheResource(event, meta) { + const [resourcePath] = meta.path.split('/').reverse(); + const cacheName = [meta.type, meta.name, resourcePath].join('/'); + return (response) => { + event.waitUntil( + getMetaData({ + cacheName, + }).then((cachedMeta) => { + if (cachedMeta.url && markResourceForRemoval(cachedMeta, meta)) { + event.waitUntil( + remove(new Request(cachedMeta.url), { cacheName: cachedMeta.cacheName }) + ); + } + return setMetaData({ + cacheName, + metadata: meta, + }); + }) + ); + return response; + }; +} + +export function setCacheResource(event, meta) { + return (response) => { + event.waitUntil( + put(event.request.clone(), response.clone(), { + cacheName: meta.cacheName, + }) + ); + return response; + }; +} + +export function fetchCacheResource(event, meta) { + return match(event.request.clone(), { cacheName: meta.cacheName }) + .then( + (cachedResponse) => cachedResponse + || fetch(event.request.clone()).then(setCacheResource(event, meta)) + ) + .then(invalidateCacheResource(event, meta)); +} diff --git a/src/server/middleware/pwa/service-worker.js b/src/server/middleware/pwa/service-worker.js index 4e57eae9..b0deeb47 100644 --- a/src/server/middleware/pwa/service-worker.js +++ b/src/server/middleware/pwa/service-worker.js @@ -14,8 +14,15 @@ * permissions and limitations under the License. */ +import { getClientModuleMapCache } from '../../utils/clientModuleMapCache'; + import { getServerPWAConfig } from './config'; +function processServiceWorkerScript(script) { + const holocronModuleMap = `'${JSON.stringify(getClientModuleMapCache().browser)}'`; + return Buffer.from(script.toString().replace('process.env.HOLOCRON_MODULE_MAP', holocronModuleMap)); +} + export default function serviceWorkerMiddleware() { return function serviceWorkerMiddlewareHandler(req, res, next) { const { serviceWorker, serviceWorkerScope, serviceWorkerScript } = getServerPWAConfig(); @@ -24,6 +31,6 @@ export default function serviceWorkerMiddleware() { .type('js') .set('Service-Worker-Allowed', serviceWorkerScope) .set('Cache-Control', 'no-store, no-cache') - .send(serviceWorkerScript); + .send(processServiceWorkerScript(serviceWorkerScript)); }; }