From 9077f17f91370f72c7ce1b5da5a7e08aae4fe259 Mon Sep 17 00:00:00 2001 From: Mike Tobia Date: Thu, 14 May 2020 18:03:51 -0400 Subject: [PATCH] feat(pwa/web-manifest): add middleware & config support --- __tests__/integration/one-app.spec.js | 112 ++++++------ .../server/middleware/pwa/config.spec.js | 89 +++++++++- .../server/middleware/pwa/validation.spec.js | 126 ++++++++++++- .../server/middleware/pwa/webManifest.spec.js | 52 ++++++ __tests__/server/middleware/sendHtml.spec.js | 11 +- __tests__/server/ssrServer.spec.js | 23 ++- docs/api/modules/App-Configuration.md | 34 ++++ .../0.0.3/src/components/FrankLloydRoot.jsx | 3 +- .../frank-lloyd-root/0.0.3/src/pwa.js | 31 ++++ src/server/middleware/pwa/config.js | 43 ++++- src/server/middleware/pwa/index.js | 1 + src/server/middleware/pwa/validation.js | 168 ++++++++++++++++-- src/server/middleware/pwa/webManifest.js | 27 +++ src/server/middleware/sendHtml.js | 5 +- src/server/ssrServer.js | 3 +- 15 files changed, 639 insertions(+), 89 deletions(-) create mode 100644 __tests__/server/middleware/pwa/webManifest.spec.js create mode 100644 src/server/middleware/pwa/webManifest.js diff --git a/__tests__/integration/one-app.spec.js b/__tests__/integration/one-app.spec.js index 187e8037d..5708d6482 100644 --- a/__tests__/integration/one-app.spec.js +++ b/__tests__/integration/one-app.spec.js @@ -828,54 +828,62 @@ describe('Tests that require Docker setup', () => { }); describe('progressive web app', () => { - let agent; - let scriptUrl; + const scriptUrl = `${appAtTestUrls.fetchUrl}/_/pwa/service-worker.js`; + const webManifestUrl = `${appAtTestUrls.fetchUrl}/_/pwa/manifest.webmanifest`; - beforeAll(async () => { - const https = require('https'); - // using this to avoid erroring from a self signed certificate - agent = new https.Agent({ - rejectUnauthorized: false, + const fetchServiceWorker = () => fetch(scriptUrl, defaultFetchOptions); + const fetchWebManifest = () => fetch(webManifestUrl, defaultFetchOptions); + const loadInitialRoot = async () => { + await addModuleToModuleMap({ + moduleName: 'frank-lloyd-root', + version: '0.0.0', }); + // wait for change to be picked up + await waitFor(5000); + }; + const loadPWARoot = async () => { + await addModuleToModuleMap({ + moduleName: 'frank-lloyd-root', + version: '0.0.3', + }); + // wait for change to be picked up + await waitFor(5000); + }; - scriptUrl = [appAtTestUrls.fetchUrl, '/_/pwa/service-worker.js'].join(''); + afterAll(() => { + writeModuleMap(originalModuleMap); }); - test('does not load PWA resources from server by default', async () => { - const serviceWorkerResponse = await fetch(scriptUrl, { agent }); + describe('default pwa state', () => { + test('does not load PWA resources from server by default', async () => { + expect.assertions(2); - expect(serviceWorkerResponse.status).toBe(404); - }); + const serviceWorkerResponse = await fetchServiceWorker(); + const webManifestResponse = await fetchWebManifest(); - describe('progressive web app enabled', () => { - beforeAll(async () => { - await Promise.all([ - addModuleToModuleMap({ - moduleName: 'frank-lloyd-root', - version: '0.0.3', - }), - // for data fetching - addModuleToModuleMap({ - moduleName: 'needy-frank', - version: '0.0.1', - }), - ]); - // wait for change to be picked up - await waitFor(5000); + expect(serviceWorkerResponse.status).toBe(404); + expect(webManifestResponse.status).toBe(404); }); + }); - afterAll(async () => { - writeModuleMap(originalModuleMap); - }); + describe('progressive web app enabled', () => { + // we load in the pwa enabled frank-lloyd-root + beforeAll(loadPWARoot); test('loads PWA resources from server ', async () => { - expect.assertions(3); + expect.assertions(6); - const serviceWorkerResponse = await fetch(scriptUrl, { agent }); + const serviceWorkerResponse = await fetchServiceWorker(); expect(serviceWorkerResponse.status).toBe(200); expect(serviceWorkerResponse.headers.get('cache-control')).toEqual('no-store, no-cache'); expect(serviceWorkerResponse.headers.get('service-worker-allowed')).toEqual('/'); + + const webManifestResponse = await fetchWebManifest(); + + expect(webManifestResponse.status).toBe(200); + expect(webManifestResponse.headers.get('cache-control')).toBeDefined(); + expect(webManifestResponse.headers.get('content-type')).toEqual('application/manifest+json'); }); test('service worker has a valid registration', async () => { @@ -900,33 +908,33 @@ describe('Tests that require Docker setup', () => { updateViaCache: 'none', }); }); + }); - describe('progressive web app disabled', () => { - beforeAll(async () => { - await addModuleToModuleMap({ - moduleName: 'frank-lloyd-root', - version: '0.0.0', - }); - // wait for change to be picked up - await waitFor(5000); - }); + describe('progressive web app disabled', () => { + // we load back up to frank-lloyd-root@0.0.0 to make sure the system is off + beforeAll(loadInitialRoot); - afterAll(async () => { - writeModuleMap(originalModuleMap); - }); + test('does not load PWA resources from server after shutdown', async () => { + expect.assertions(2); - test('service worker is no longer registered and removed with root module change', async () => { - expect.assertions(1); + const serviceWorkerResponse = await fetchServiceWorker(); + const webManifestResponse = await fetchWebManifest(); - await browser.url(`${appAtTestUrls.browserUrl}/success`); + expect(serviceWorkerResponse.status).toBe(404); + expect(webManifestResponse.status).toBe(404); + }); - // eslint-disable-next-line prefer-arrow-callback - const result = await browser.executeAsync(function getRegistration(done) { - navigator.serviceWorker.getRegistration().then(done); - }); + test('service worker is no longer registered and removed with root module change', async () => { + expect.assertions(1); + + await browser.url(`${appAtTestUrls.browserUrl}/success`); - expect(result).toBe(null); + // eslint-disable-next-line prefer-arrow-callback + const result = await browser.executeAsync(function getRegistration(done) { + navigator.serviceWorker.getRegistration().then(done); }); + + expect(result).toBe(null); }); }); }); diff --git a/__tests__/server/middleware/pwa/config.spec.js b/__tests__/server/middleware/pwa/config.spec.js index 581161587..f3f073d32 100644 --- a/__tests__/server/middleware/pwa/config.spec.js +++ b/__tests__/server/middleware/pwa/config.spec.js @@ -15,12 +15,14 @@ */ import { + getWebAppManifestConfig, getServerPWAConfig, getClientPWAConfig, configurePWA, } from '../../../../src/server/middleware/pwa/config'; jest.mock('fs', () => ({ + existsSync: () => false, readFileSync: (filePath) => Buffer.from(filePath.endsWith('noop.js') ? '[service-worker-noop-script]' : '[service-worker-script]'), })); @@ -34,6 +36,10 @@ describe('pwa configuration', () => { }); test('getters return default state', () => { + expect(getWebAppManifestConfig()).toMatchObject({ + webManifest: null, + webManifestEnabled: false, + }); expect(getServerPWAConfig()).toMatchObject({ serviceWorker: false, serviceWorkerRecoveryMode: false, @@ -43,12 +49,13 @@ describe('pwa configuration', () => { expect(getClientPWAConfig()).toMatchObject({ serviceWorker: false, serviceWorkerRecoveryMode: false, - serviceWorkerScriptUrl: false, serviceWorkerScope: null, + serviceWorkerScriptUrl: false, + webManifestUrl: false, }); }); - describe('configuration', () => { + describe('service worker configuration', () => { test('enabling the service worker with minimum config', () => { configurePWA({ serviceWorker: true }); @@ -64,6 +71,7 @@ describe('pwa configuration', () => { serviceWorkerRecoveryMode: false, serviceWorkerScope: '/', serviceWorkerScriptUrl: '/_/pwa/service-worker.js', + webManifestUrl: false, }); }); @@ -82,6 +90,7 @@ describe('pwa configuration', () => { serviceWorkerRecoveryMode: true, serviceWorkerScope: '/', serviceWorkerScriptUrl: '/_/pwa/service-worker.js', + webManifestUrl: false, }); }); @@ -100,6 +109,7 @@ describe('pwa configuration', () => { serviceWorkerRecoveryMode: true, serviceWorkerScope: '/', serviceWorkerScriptUrl: '/_/pwa/service-worker.js', + webManifestUrl: false, }); }); @@ -117,8 +127,9 @@ describe('pwa configuration', () => { expect(getClientPWAConfig()).toMatchObject({ serviceWorker: false, serviceWorkerRecoveryMode: false, - serviceWorkerScriptUrl: false, serviceWorkerScope: null, + serviceWorkerScriptUrl: false, + webManifestUrl: false, }); process.env.ONE_SERVICE_WORKER = true; @@ -137,8 +148,9 @@ describe('pwa configuration', () => { expect(getClientPWAConfig()).toMatchObject({ serviceWorker: false, serviceWorkerRecoveryMode: false, - serviceWorkerScriptUrl: false, serviceWorkerScope: null, + serviceWorkerScriptUrl: false, + webManifestUrl: false, }); }); @@ -147,4 +159,73 @@ describe('pwa configuration', () => { expect(configurePWA()).toMatchObject({ serviceWorker: false }); }); }); + + describe('web app manifest configuration', () => { + test('enabling the web manifest', () => { + configurePWA({ + serviceWorker: true, + webManifest: { + name: 'One App Test', + }, + }); + + expect(getServerPWAConfig()).toMatchObject({ + webManifest: true, + serviceWorker: true, + serviceWorkerRecoveryMode: false, + serviceWorkerScope: '/', + }); + expect(getClientPWAConfig()).toMatchObject({ + serviceWorker: true, + serviceWorkerRecoveryMode: false, + serviceWorkerScope: '/', + serviceWorkerScriptUrl: '/_/pwa/service-worker.js', + webManifestUrl: '/_/pwa/manifest.webmanifest', + }); + }); + + test('using a function for the web manifest', () => { + configurePWA({ + serviceWorker: true, + webManifest: () => ({ + name: 'One App Test', + }), + }); + + expect(getServerPWAConfig()).toMatchObject({ + webManifest: true, + serviceWorker: true, + serviceWorkerRecoveryMode: false, + serviceWorkerScope: '/', + }); + expect(getClientPWAConfig()).toMatchObject({ + serviceWorker: true, + serviceWorkerRecoveryMode: false, + serviceWorkerScope: '/', + serviceWorkerScriptUrl: '/_/pwa/service-worker.js', + webManifestUrl: '/_/pwa/manifest.webmanifest', + }); + }); + + test('opting out of the web manifest', () => { + configurePWA({ + serviceWorker: true, + webManifest: null, + }); + + expect(getServerPWAConfig()).toMatchObject({ + webManifest: false, + serviceWorker: true, + serviceWorkerRecoveryMode: false, + serviceWorkerScope: '/', + }); + expect(getClientPWAConfig()).toMatchObject({ + serviceWorker: true, + serviceWorkerRecoveryMode: false, + serviceWorkerScope: '/', + serviceWorkerScriptUrl: '/_/pwa/service-worker.js', + webManifestUrl: false, + }); + }); + }); }); diff --git a/__tests__/server/middleware/pwa/validation.spec.js b/__tests__/server/middleware/pwa/validation.spec.js index 03535d0ed..3e783aa30 100644 --- a/__tests__/server/middleware/pwa/validation.spec.js +++ b/__tests__/server/middleware/pwa/validation.spec.js @@ -51,20 +51,140 @@ describe('validation', () => { expect(validatePWAConfig({ serviceWorker: 'true', scope: 42, + webManifest: [], })).toEqual({}); - expect(console.warn).toHaveBeenCalledTimes(2); - expect(console.warn).toHaveBeenCalledWith('invalid value type given for configuration key "serviceWorker" (expected "Boolean") - ignoring'); - expect(console.warn).toHaveBeenCalledWith('invalid value type given for configuration key "scope" (expected "String") - ignoring'); + expect(console.warn).toHaveBeenCalledTimes(3); + expect(console.warn).toHaveBeenCalledWith('Invalid value type given for configuration key "serviceWorker" (expected "Boolean") - ignoring'); + expect(console.warn).toHaveBeenCalledWith('Invalid value type given for configuration key "scope" (expected "String") - ignoring'); + expect(console.warn).toHaveBeenCalledWith('Invalid value type given for configuration key "webManifest" (expected "WebManifest") - ignoring'); }); test('valid keys emits no warnings or errors and returns valid configuration', () => { const validConfig = { serviceWorker: true, scope: '/', + webManifest: { name: 'my-app' }, }; expect(validatePWAConfig(validConfig)).toEqual(validConfig); expect(console.warn).not.toHaveBeenCalled(); expect(console.error).not.toHaveBeenCalled(); }); + + describe('web manifest validation', () => { + test('web app manifest has valid keys emits no warnings or errors and returns valid configuration', () => { + const validConfig = { + serviceWorker: true, + scope: '/', + webManifest: { + name: 'One App Test', + }, + }; + expect(validatePWAConfig(validConfig)).toEqual(validConfig); + expect(console.warn).not.toHaveBeenCalled(); + expect(console.error).not.toHaveBeenCalled(); + }); + + test('expects name to be required', () => { + const validConfig = { + serviceWorker: true, + scope: '/', + webManifest: { + short_name: 'One App Test', + }, + }; + expect(validatePWAConfig(validConfig)).toEqual(validConfig); + expect(console.warn).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledTimes(1); + }); + + test('ignores unrecognized keys given', () => { + const validConfig = { + serviceWorker: true, + scope: '/', + webManifest: { + name: 'One App Test', + my_name: 'One App Test', + }, + }; + expect(validatePWAConfig(validConfig)).toEqual({ ...validConfig, webManifest: { name: 'One App Test' } }); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.error).not.toHaveBeenCalled(); + }); + + test('warns and ignores when invalid enumerable keys are used', () => { + const validConfig = { + serviceWorker: true, + scope: '/', + webManifest: { + name: 'One App Test', + display: 'big', + orientation: 'up', + direction: 'backwards', + }, + }; + expect(validatePWAConfig(validConfig)).toEqual({ ...validConfig, webManifest: { name: 'One App Test' } }); + expect(console.warn).toHaveBeenCalledTimes(3); + expect(console.error).not.toHaveBeenCalled(); + }); + + test('warns and ignores when invalid shapes for array are used', () => { + const validConfig = { + serviceWorker: true, + scope: '/', + webManifest: { + name: 'One App Test', + icons: [{ + size: '72x72', + }, { + purpose: 'none', + }], + screenshots: [{ + href: 'https://screenshots.example.com/screenshot/latest', + }], + related_applications: [{ + store: 'new pwa store', + }], + }, + }; + expect(validatePWAConfig(validConfig)).toEqual({ ...validConfig, webManifest: { name: 'One App Test' } }); + expect(console.warn).toHaveBeenCalledTimes(3); + expect(console.error).not.toHaveBeenCalled(); + }); + + test('includes a valid web manifest when passed in', () => { + const validConfig = { + serviceWorker: true, + scope: '/', + webManifest: { + lang: 'en-US', + dir: 'auto', + display: 'standalone', + orientation: 'portrait', + short_name: 'Test', + name: 'One App Test', + categories: ['testing', 'example'], + icons: [{ + src: 'https://example.com/pwa-icon.png', + type: 'img/png', + sizes: '72x72', + purpose: 'badge', + }], + screenshots: [{ + src: 'https://example.com/pwa-screenshot.png', + type: 'img/png', + sizes: '1024x768', + }], + related_applications: [{ + platform: 'new pwa store', + url: 'https://platform.example.com/pwa', + id: 'aiojfoahfaf', + }], + }, + }; + expect(validatePWAConfig(validConfig)).toEqual(validConfig); + expect(console.warn).not.toHaveBeenCalled(); + expect(console.error).not.toHaveBeenCalled(); + }); + }); }); diff --git a/__tests__/server/middleware/pwa/webManifest.spec.js b/__tests__/server/middleware/pwa/webManifest.spec.js new file mode 100644 index 000000000..42ad21e05 --- /dev/null +++ b/__tests__/server/middleware/pwa/webManifest.spec.js @@ -0,0 +1,52 @@ +/* + * 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 webManifestMiddleware from '../../../../src/server/middleware/pwa/webManifest'; +import { getWebAppManifestConfig } from '../../../../src/server/middleware/pwa/config'; + +jest.mock('../../../../src/server/middleware/pwa/config', () => ({ + getWebAppManifestConfig: jest.fn(() => ({ webManifestEnabled: false, webAppManifest: null })), +})); + +describe('webmanifest middleware', () => { + test('middleware factory returns function', () => { + expect.assertions(1); + expect(webManifestMiddleware()).toBeInstanceOf(Function); + }); + + test('middleware is disabled by default', () => { + expect.assertions(2); + const middleware = webManifestMiddleware(); + const next = jest.fn(); + expect(middleware(null, null, next)).toBeUndefined(); + expect(next).toHaveBeenCalledTimes(1); + }); + + test('middleware responds with manifest', () => { + expect.assertions(4); + const webManifest = { name: 'One App Test', short_name: 'one-app-test' }; + const middleware = webManifestMiddleware(); + const next = jest.fn(); + const send = jest.fn(); + const type = jest.fn(() => ({ send })); + getWebAppManifestConfig + .mockImplementationOnce(() => ({ webManifestEnabled: true, webManifest })); + expect(middleware(null, { type, send }, next)).toBeUndefined(); + expect(next).not.toHaveBeenCalled(); + expect(type).toHaveBeenCalledWith('application/manifest+json'); + expect(send).toHaveBeenCalledWith(webManifest); + }); +}); diff --git a/__tests__/server/middleware/sendHtml.spec.js b/__tests__/server/middleware/sendHtml.spec.js index 0c15b01b6..2082bdbca 100644 --- a/__tests__/server/middleware/sendHtml.spec.js +++ b/__tests__/server/middleware/sendHtml.spec.js @@ -86,7 +86,8 @@ jest.mock('../../../src/server/middleware/pwa', () => ({ getClientPWAConfig: jest.fn(() => ({ serviceWorker: false, serviceWorkerScope: null, - serviceWorkerScriptUrl: null, + serviceWorkerScriptUrl: false, + webManifestUrl: false, })), })); jest.mock('../../../src/universal/ducks/config'); @@ -415,18 +416,20 @@ describe('sendHtml', () => { it('includes __pwa_metadata__ with disabled values', () => { sendHtml(req, res); expect(res.send).toHaveBeenCalledTimes(1); - expect(/window\.__pwa_metadata__ = {"serviceWorker":false,"serviceWorkerScope":null,"serviceWorkerScriptUrl":null};/.test(res.send.mock.calls[0][0])).toBe(true); + expect(/window\.__pwa_metadata__ = {"serviceWorker":false,"serviceWorkerScope":null,"serviceWorkerScriptUrl":false};/.test(res.send.mock.calls[0][0])).toBe(true); }); it('includes __pwa_metadata__ with enabled values', () => { getClientPWAConfig.mockImplementationOnce(() => ({ serviceWorker: true, serviceWorkerScope: '/', - serviceWorkerScriptUrl: '/sw.js', + serviceWorkerScriptUrl: '/_/pwa/service-worker.js', + webManifestUrl: '/_/pwa/manifest.webmanifest', })); sendHtml(req, res); expect(res.send).toHaveBeenCalledTimes(1); - expect(/window\.__pwa_metadata__ = {"serviceWorker":true,"serviceWorkerScope":"\/","serviceWorkerScriptUrl":"\/sw\.js"};/.test(res.send.mock.calls[0][0])).toBe(true); + expect(/window\.__pwa_metadata__ = {"serviceWorker":true,"serviceWorkerScope":"\/","serviceWorkerScriptUrl":"\/_\/pwa\/service-worker\.js"};/.test(res.send.mock.calls[0][0])).toBe(true); + expect(//.test(res.send.mock.calls[0][0])).toBe(true); }); }); diff --git a/__tests__/server/ssrServer.spec.js b/__tests__/server/ssrServer.spec.js index 305c120d9..b9911fbf0 100644 --- a/__tests__/server/ssrServer.spec.js +++ b/__tests__/server/ssrServer.spec.js @@ -34,7 +34,16 @@ jest.mock('../../src/server/middleware/addFrameOptionsHeader'); jest.mock('../../src/server/middleware/forwardedHeaderParser'); jest.mock('../../src/server/utils/logging/serverMiddleware', () => (req, res, next) => setImmediate(next)); jest.mock('../../src/universal/index'); -jest.mock('../../src/server/middleware/pwa', () => ({ serviceWorkerMiddleware: jest.fn(() => (req, res, next) => next()) })); +jest.mock('../../src/server/middleware/pwa', () => { + const serviceWorker = jest.fn((req, res, next) => next()); + const webManifest = jest.fn((req, res, next) => next()); + return { + serviceWorker, + webManifest, + serviceWorkerMiddleware: () => serviceWorker, + webManifestMiddleware: () => webManifest, + }; +}); jest.mock('../../mocks/scenarios', () => ({ scenarios: true, }), { virtual: true }); @@ -99,6 +108,7 @@ describe('ssrServer', () => { let json; let forwardedHeaderParser; let serviceWorker; + let webManifest; function loadServer() { ({ json } = require('body-parser')); @@ -118,7 +128,7 @@ describe('ssrServer', () => { addFrameOptionsHeader = require('../../src/server/middleware/addFrameOptionsHeader').default; addCacheHeaders = require('../../src/server/middleware/addCacheHeaders').default; forwardedHeaderParser = require('../../src/server/middleware/forwardedHeaderParser').default; - ({ serviceWorkerMiddleware: serviceWorker } = require('../../src/server/middleware/pwa')); + ({ serviceWorker, webManifest } = require('../../src/server/middleware/pwa')); const server = require('../../src/server/ssrServer').default; return server; @@ -239,6 +249,15 @@ describe('ssrServer', () => { }); }); + it('should call web manifest middleware', (done) => { + request(loadServer()) + .get('/_/pwa/manifest.webmanifest') + .end(() => { + expect(webManifest).toBeCalled(); + done(); + }); + }); + it('should call errors logging', (done) => { request(loadServer()) .post('/_/report/errors') diff --git a/docs/api/modules/App-Configuration.md b/docs/api/modules/App-Configuration.md index 8a710d3ab..b04b637ec 100644 --- a/docs/api/modules/App-Configuration.md +++ b/docs/api/modules/App-Configuration.md @@ -216,6 +216,11 @@ For the variety of service workers available, we have control to set its `scope` with the desired pathname and assign what url base the service worker can oversee. +The `webManifest` key is used to set up a [Web App Manifest](https://developer.mozilla.org/en-US/docs/Web/Manifest) +as part of the PWA group of technologies. It allows `one-app` to be installed onto a device +with support for a more native experience using web technologies. The `webManifest` can also +be a `Function` and is passed the `clientConfig` as the only argument. + **Shape** ```js if (!global.BROWSER) { @@ -232,6 +237,33 @@ if (!global.BROWSER) { escapeHatch: false, // we can optionally define a scope to use with the service worker scope: '/', + // the web app manifest can be directly incorporated in the PWA config + webManifest: (clientConfig) => ({ + // the full name is the official name of a given PWA + name: 'My App Name', + // the short name is used by mobile devices to label your home screen icon + short_name: 'My App', + // the description is a good piece of meta-data to include for a short description + // which can be used with presenting your PWA + description: 'My PWA app.', + // relative to the root of the domain + start_url: '/home', + // when installing your PWA, standalone display will have a native feel + // and removes the browser bar for full screen + display: 'standalone', + // the background color + background_color: '#fff', + // the theme color is what covers native UI elements that host the PWA + theme_color: '#000', + // icons can perform many purposes, including the splash screen when a web app is loading + icons: [ + { + src: `${clientConfig.cdnUrl}/my-splash-icon.png`, + sizes: '48x48', + type: 'image/png', + }, + ], + }), }, }; } @@ -240,6 +272,8 @@ if (!global.BROWSER) { **📘 More Information** * Environment Variable: [`ONE_SERVICE_WORKER`](../server/Environment-Variables.md#one_service_worker) * Example: [Frank Lloyd Root's `pwa` config](../../../prod-sample/sample-modules/frank-lloyd-root/0.0.3/src/pwa.js) +* Service Worker: [MDN Documentation](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) +* Web App Manifest: [MDN Documentation](https://developer.mozilla.org/en-US/docs/Web/Manifest) ## `configureRequestLog` **Module Type** diff --git a/prod-sample/sample-modules/frank-lloyd-root/0.0.3/src/components/FrankLloydRoot.jsx b/prod-sample/sample-modules/frank-lloyd-root/0.0.3/src/components/FrankLloydRoot.jsx index ad146bfd9..4e9655fbe 100644 --- a/prod-sample/sample-modules/frank-lloyd-root/0.0.3/src/components/FrankLloydRoot.jsx +++ b/prod-sample/sample-modules/frank-lloyd-root/0.0.3/src/components/FrankLloydRoot.jsx @@ -25,7 +25,7 @@ import { connect } from 'react-redux'; import HelloWorldComponent from './HelloWorld'; export function FrankLloydRoot({ children, config }) { - const cdnUrl = config.get('cdnUrl'); + const cdnUrl = React.useMemo(() => config.get('cdnUrl'), [config]); return ( ({ + // like the service worker, we can set a scope which this manifest takes effect + scope: '/', + // when loading an installed PWA, we can set the starting url on entry/load + start_url: '/success', + name: 'Frank Lloyd Root', + short_name: 'frank lloyd root', + description: 'A Progressive Web App ready Holocron Module', + display: 'standalone', + background_color: '#FFF', + theme_color: '#FDB92D', + icons: [ + { + src: `${config.cdnUrl}modules/frank-lloyd-root/0.0.3/assets/pwa-icon-180px.png`, + type: 'image/png', + sizes: '180x180', + }, + { + src: `${config.cdnUrl}modules/frank-lloyd-root/0.0.3/assets/pwa-icon-192px.png`, + type: 'image/png', + sizes: '192x192', + }, + { + src: `${config.cdnUrl}modules/frank-lloyd-root/0.0.3/assets/pwa-icon-512px.png`, + type: 'image/png', + sizes: '512x512', + }, + ], +}); + export default { serviceWorker: true, scope: '/', + webManifest, }; diff --git a/src/server/middleware/pwa/config.js b/src/server/middleware/pwa/config.js index 74e3ddd5c..e080d4524 100644 --- a/src/server/middleware/pwa/config.js +++ b/src/server/middleware/pwa/config.js @@ -17,9 +17,12 @@ import fs from 'fs'; import path from 'path'; +import { getClientStateConfig } from '../../utils/stateConfig'; + import { validatePWAConfig } from './validation'; const defaultPWAConfig = { + webManifest: false, serviceWorker: false, serviceWorkerRecoveryMode: false, serviceWorkerType: null, @@ -27,6 +30,7 @@ const defaultPWAConfig = { serviceWorkerScript: null, }; +let webAppManifest = null; let pwaConfig = { ...defaultPWAConfig }; function resetPWAConfig() { @@ -72,17 +76,42 @@ function createServiceWorkerConfig(config) { }; } +function createWebManifestConfig(config, serviceWorkerConfig) { + const webManifest = !!(serviceWorkerConfig.serviceWorker && config.webManifest); + return { + webManifest, + webManifestObject: webManifest ? config.webManifest : null, + }; +} + +function validateConfig(config) { + if (!config) return {}; + + const object = { ...config }; + + if (typeof config.webManifest === 'function') object.webManifest = config.webManifest(getClientStateConfig()); + + return validatePWAConfig(object); +} + +export function getWebAppManifestConfig() { + return { webManifest: webAppManifest, webManifestEnabled: pwaConfig.webManifest }; +} + export function getServerPWAConfig() { return { ...pwaConfig }; } export function getClientPWAConfig() { - const { serviceWorker, serviceWorkerRecoveryMode, serviceWorkerScope } = pwaConfig; + const { + webManifest, serviceWorker, serviceWorkerRecoveryMode, serviceWorkerScope, + } = pwaConfig; return { serviceWorker, serviceWorkerRecoveryMode, serviceWorkerScope, serviceWorkerScriptUrl: serviceWorker && '/_/pwa/service-worker.js', + webManifestUrl: webManifest && '/_/pwa/manifest.webmanifest', }; } @@ -101,9 +130,17 @@ export function configurePWA(config) { resetPWAConfig(); } - const validatedConfig = config ? validatePWAConfig(config) : {}; + const validatedConfig = validateConfig(config); + + const serviceWorkerConfig = createServiceWorkerConfig(validatedConfig); + const { + webManifestObject, webManifest, + } = createWebManifestConfig(validatedConfig, serviceWorkerConfig); + + webAppManifest = webManifestObject ? Buffer.from(JSON.stringify(webManifestObject)) : null; return setPWAConfig({ - ...createServiceWorkerConfig(validatedConfig), + ...serviceWorkerConfig, + webManifest, }); } diff --git a/src/server/middleware/pwa/index.js b/src/server/middleware/pwa/index.js index 047dc534f..1c8aea9e9 100644 --- a/src/server/middleware/pwa/index.js +++ b/src/server/middleware/pwa/index.js @@ -16,3 +16,4 @@ export { configurePWA, getClientPWAConfig, getServerPWAConfig } from './config'; export { default as serviceWorkerMiddleware } from './service-worker'; +export { default as webManifestMiddleware } from './webManifest'; diff --git a/src/server/middleware/pwa/validation.js b/src/server/middleware/pwa/validation.js index 484d20295..29aff4bc3 100644 --- a/src/server/middleware/pwa/validation.js +++ b/src/server/middleware/pwa/validation.js @@ -14,24 +14,149 @@ * permissions and limitations under the License. */ -function isString(value) { - return typeof value === 'string'; +function isString(valueToTest) { + return typeof valueToTest === 'string'; } -function isBoolean(value) { - return typeof value === 'boolean'; +function isBoolean(valueToTest) { + return typeof valueToTest === 'boolean'; } -function isPlainObject(value) { - return !!value && typeof value === 'object' && Array.isArray(value) === false; +function isPlainObject(valueToTest) { + return !!valueToTest && typeof valueToTest === 'object' && Array.isArray(valueToTest) === false; } -const validKeys = new Map([ - ['serviceWorker', isBoolean], - ['recoveryMode', isBoolean], - ['escapeHatch', isBoolean], - ['scope', isString], -]); +function createIsRequired(isType) { + return function isRequired(valueToTest) { + return !!valueToTest && isType(valueToTest); + }; +} + +function createIsEnum(enumerableValues, isType) { + return function isEnum(valueToTest) { + return isType(valueToTest) && enumerableValues.includes(valueToTest); + }; +} + +function createIsArrayOf(isType) { + return function isArrayOf(valuesToTest) { + return Array.isArray(valuesToTest) && valuesToTest.map(isType).filter(Boolean); + }; +} + +function createIsShape(objectShape) { + return function isShape(objectValueToTest) { + return Object.keys(objectValueToTest) + .map((keyToTest) => { + if (keyToTest in objectShape === false) return false; + const testValueType = objectShape[keyToTest]; + const valueOfKey = objectValueToTest[keyToTest]; + return testValueType(valueOfKey) && [keyToTest, valueOfKey]; + }) + .filter(Boolean) + .reduce((map, [configKey, value]) => ({ ...map, [configKey]: value }), null); + }; +} + +function isWebManifest(manifestToValidate) { + // we can accept either of these values if the user wishes to opt out + if ([false, null].includes(manifestToValidate)) return null; + // if not a plain object at this point we mark the manifest as invalid + if (!isPlainObject(manifestToValidate)) { + return false; + } + + const webAppManifestKeys = new Map([ + ['background_color', isString], + ['categories', createIsArrayOf(isString)], + ['description', isString], + ['dir', createIsEnum([ + 'auto', + 'ltr', + 'rtl', + ], isString)], + ['display', createIsEnum([ + 'fullscreen', + 'standalone', + 'minimal-ui', + 'browser', + ], isString)], + ['iarc_rating_id', isBoolean], + ['icons', createIsArrayOf( + createIsShape({ + src: isString, + sizes: isString, + type: isString, + purpose: createIsEnum([ + 'any', + 'maskable', + 'badge', + ], isString), + }) + )], + ['lang', isString], + ['name', createIsRequired(isString)], + ['orientation', createIsEnum([ + 'any', + 'natural', + 'landscape', + 'landscape-primary', + 'landscape-secondary', + 'portrait', + 'portrait-primary', + 'portrait-secondary', + ], isString)], + ['prefer_related_applications', isBoolean], + ['related_applications', createIsArrayOf( + createIsShape({ + platform: isString, + url: isString, + id: isString, + }) + )], + ['scope', isString], + ['screenshots', createIsArrayOf( + createIsShape({ + src: isString, + sizes: isString, + type: isString, + }) + )], + ['short_name', isString], + ['start_url', isString], + ['theme_color', isString], + ]); + // we manually add required properties (eg name) + return [...new Set(Object.keys(manifestToValidate).concat('name'))] + .map((keyToTest) => { + if (!webAppManifestKeys.has(keyToTest)) { + // warn that it's not a supported key + console.warn(`The key "${keyToTest}" is not supported by the web app manifest - ignoring`); + return null; + } + const testValueType = webAppManifestKeys.get(keyToTest); + const valueOfKey = manifestToValidate[keyToTest]; + const testResult = testValueType(valueOfKey); + if (['icons', 'related_applications', 'screenshots'].includes(keyToTest)) { + if (testResult.length > 0) return [keyToTest, testResult]; + console.warn(`The key "${keyToTest}" did not have a valid values - ignoring`); + return null; + } + if (!testResult) { + // for all of our mandatory keys + if (!valueOfKey && ['name'].includes(keyToTest)) { + console.error(`The key "${keyToTest}" is required to be present, please set a value`); + } else { + // otherwise warn that the value used is incorrect + console.warn(`The key "${keyToTest}" does not have a valid value - ignoring`); + } + return null; + } + return [keyToTest, valueOfKey]; + }) + .filter(Boolean) + .reduce((map, [configKey, value]) => ({ ...map, [configKey]: value }), null); +} // eslint-disable-next-line import/prefer-default-export export function validatePWAConfig(configToValidate) { @@ -40,6 +165,14 @@ export function validatePWAConfig(configToValidate) { return null; } + const validKeys = new Map([ + ['serviceWorker', isBoolean], + ['recoveryMode', isBoolean], + ['escapeHatch', isBoolean], + ['scope', isString], + ['webManifest', isWebManifest], + ]); + return Object.keys(configToValidate) .map((configKeyToValidate) => { if (!validKeys.has(configKeyToValidate)) { @@ -47,18 +180,19 @@ export function validatePWAConfig(configToValidate) { return null; } + const configValueToValidate = configToValidate[configKeyToValidate]; const testValueType = validKeys.get(configKeyToValidate); - const configToValidateValue = configToValidate[configKeyToValidate]; + const testResults = testValueType(configValueToValidate); - if (!testValueType(configToValidateValue)) { + if (!testResults) { console.warn( - `invalid value type given for configuration key "${configKeyToValidate}" (expected "${testValueType.name.replace('is', '')}") - ignoring` + `Invalid value type given for configuration key "${configKeyToValidate}" (expected "${testValueType.name.replace('is', '')}") - ignoring` ); return null; } - return [configKeyToValidate, configToValidateValue]; + return [configKeyToValidate, configKeyToValidate === 'webManifest' ? testResults : configValueToValidate]; }) - .filter((value) => !!value) + .filter(Boolean) .reduce((map, [configKey, value]) => ({ ...map, [configKey]: value }), {}); } diff --git a/src/server/middleware/pwa/webManifest.js b/src/server/middleware/pwa/webManifest.js new file mode 100644 index 000000000..ea8c83f6a --- /dev/null +++ b/src/server/middleware/pwa/webManifest.js @@ -0,0 +1,27 @@ +/* + * 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 { getWebAppManifestConfig } from './config'; + +export default function webManifestMiddleware() { + return function webManifestMiddlewareHandler(req, res, next) { + const { webManifestEnabled, webManifest } = getWebAppManifestConfig(); + if (!webManifestEnabled) return next(); + return res + .type('application/manifest+json') + .send(webManifest); + }; +} diff --git a/src/server/middleware/sendHtml.js b/src/server/middleware/sendHtml.js index dc55bb1f6..404d2e78c 100644 --- a/src/server/middleware/sendHtml.js +++ b/src/server/middleware/sendHtml.js @@ -188,6 +188,7 @@ export function getHead({ helmetInfo, store, disableStyles, + webManifestUrl, }) { return ` @@ -195,6 +196,7 @@ export function getHead({ ${disableStyles ? '' : ` ${renderModuleStyles(store)} `} + ${webManifestUrl ? `` : ''} `; } @@ -287,7 +289,7 @@ export default function sendHtml(req, res) { } // replace server specific config with client specific config (api urls and such) const clientConfig = getClientStateConfig(); - const pwaMetadata = getClientPWAConfig(); + const { webManifestUrl, ...pwaMetadata } = getClientPWAConfig(); store.dispatch(setConfig(clientConfig)); const cdnUrl = clientConfig.cdnUrl || '/_/static/'; const clientInitialState = store.getState(); @@ -313,6 +315,7 @@ export default function sendHtml(req, res) { disableScripts, disableStyles, scriptNonce, + webManifestUrl, }; const bodySectionArgs = { diff --git a/src/server/ssrServer.js b/src/server/ssrServer.js index c4f9c0284..e8c9bbdc1 100644 --- a/src/server/ssrServer.js +++ b/src/server/ssrServer.js @@ -44,7 +44,7 @@ import checkStateForStatusCode from './middleware/checkStateForStatusCode'; import sendHtml, { renderStaticErrorPage } from './middleware/sendHtml'; import logging from './utils/logging/serverMiddleware'; import forwardedHeaderParser from './middleware/forwardedHeaderParser'; -import { serviceWorkerMiddleware } from './middleware/pwa'; +import { serviceWorkerMiddleware, webManifestMiddleware } from './middleware/pwa'; export function createApp({ enablePostToModuleRoutes = false } = {}) { const app = express(); @@ -59,6 +59,7 @@ export function createApp({ enablePostToModuleRoutes = false } = {}) { app.use('/_/static', express.static(path.join(__dirname, '../../build'), { maxage: '182d' })); app.get('/_/pwa/service-worker.js', serviceWorkerMiddleware()); app.get('*', addCacheHeaders); + app.get('/_/pwa/manifest.webmanifest', webManifestMiddleware()); app.disable('x-powered-by'); app.disable('e-tag');