feat: service worker with etag cache (#3897)

* fix: always-fresh service worker cache

* chore: clarify service-worker

* fix: cache in CacheStorage

* feat: set __isDocumentCached

* add back in manifest precaching

* add unit tests (incomplete)

* test: simplify test env

* test: add service-worker cypress test

* test: service-worker document handler

* fix: CachedDocument ctor

* fix: Readable for ReadableStream in jest

* build: clean up module loading

* fix: rename commands->ethereum

* build: simplify package.json deps

* build: clean up cypress usage

* build: clean up yarn.lock

* build: record cypress runs

* build: disable chromeWebSecurity in cypress tests

* build: rm babel

* build: disable sw in ci cypress

* build: nits

* build: update workbox version

* chore: fix merge

* test: cache

* test: cypress-ify the before hook

* test: clear sw before each test

* fix: cy then

* test: cypress shenanigans

* style: lint

* chore: rm todo

* test: fail fast for service worker with dev builds

* docs: update contributing to tests

* fix: clean up tests after merge

- Add fast fail in case of dev server, which lacks ServiceWorker

* fix: inject ethereum

* test: service worker

* test: increase sw timeout

* test: sw state

* test: run cypress in chrome

* feat: add on-demand caching to improve sw startup time

* test: test dynamically

* fix: simplify cached doc

* fix: optional sw

* fix: expose response on cached doc

* fix: stub out sw req

* fix: intercept

Co-authored-by: Christine Legge <christine.legge@uniswap.org>
This commit is contained in:
Zach Pomerantz 2022-06-14 15:40:52 -04:00 committed by GitHub
parent 7e709e10db
commit c16e49e774
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 493 additions and 81 deletions

@ -42,10 +42,8 @@ jobs:
CI: false # disables lint checks when building
- run: yarn serve &
env:
CI: false # disables lint checks when building
- run: yarn cypress run --record
- run: yarn cypress:run --record
env:
CYPRESS_INTEGRATION_TEST_PRIVATE_KEY: ${{ secrets.CYPRESS_INTEGRATION_TEST_PRIVATE_KEY }}
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}

@ -44,7 +44,7 @@ By default, this runs only unit tests that have been affected since the last com
yarn test --watchAll
```
## Running cypress integration tests
## Running integration tests (cypress)
Integration tests require a server to be running. In order to see your changes quickly, run `start` in its own tab/window:
@ -52,10 +52,16 @@ Integration tests require a server to be running. In order to see your changes q
yarn start
```
Integration tests are run using `cypress`. When developing locally, use `cypress open` for an interactive UI, and to inspect the rendered page:
Integration tests are run using `cypress`. When developing locally, use `cypress:open` for an interactive UI, and to inspect the rendered page:
```
yarn cypress open
yarn cypress:open
```
To run _all_ cypress integration tests _from the command line_:
```
yarn cypress:run
```
## Engineering standards

@ -6,6 +6,14 @@ export default defineConfig({
defaultCommandTimeout: 10000,
chromeWebSecurity: false,
e2e: {
setupNodeEvents(on, config) {
return {
...config,
// Only enable Chrome.
// Electron (the default) has issues injecting window.ethereum before pageload, so it is not viable.
browsers: config.browsers.filter(({ name }) => name === 'chrome'),
}
},
baseUrl: 'http://localhost:3000',
specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}',
},

@ -0,0 +1,84 @@
import assert = require('assert')
describe('Service Worker', () => {
before(() => {
// Fail fast if there is no Service Worker on this build.
cy.request({ url: '/service-worker.js', headers: { 'Service-Worker': 'script' } }).then((response) => {
const isValid = isValidServiceWorker(response)
if (!isValid) {
throw new Error(
'\n' +
'Service Worker tests must be run on a production-like build\n' +
'To test, build with `yarn build:e2e` and serve with `yarn serve`'
)
}
})
function isValidServiceWorker(response: Cypress.Response<any>) {
const contentType = response.headers['content-type']
return !(response.status === 404 || (contentType != null && contentType.indexOf('javascript') === -1))
}
})
function unregister() {
return cy.log('unregister service worker').then(async () => {
const cacheKeys = await window.caches.keys()
const cacheKey = cacheKeys.find((key) => key.match(/precache/))
if (cacheKey) {
await window.caches.delete(cacheKey)
}
const sw = await window.navigator.serviceWorker.getRegistration(Cypress.config().baseUrl ?? undefined)
await sw?.unregister()
})
}
before(unregister)
after(unregister)
beforeEach(() => {
cy.intercept({ hostname: 'www.google-analytics.com' }, (req) => {
const body = req.body.toString()
if (req.query['ep.event_category'] === 'Service Worker' || body.includes('Service%20Worker')) {
if (req.query['en'] === 'Not Installed' || body.includes('Not%20Installed')) {
req.alias = 'NotInstalled'
} else if (req.query['en'] === 'Cache Hit' || body.includes('Cache%20Hit')) {
req.alias = 'CacheHit'
} else if (req.query['en'] === 'Cache Miss' || body.includes('Cache%20Miss')) {
req.alias = 'CacheMiss'
}
}
})
})
it('installs a ServiceWorker', () => {
cy.visit('/', { serviceWorker: true })
.get('#swap-page')
.wait('@NotInstalled', { timeout: 20000 })
.window({ timeout: 20000 })
.and((win) => {
expect(win.navigator.serviceWorker.controller?.state).to.equal('activated')
})
})
it('records a cache hit', () => {
cy.visit('/', { serviceWorker: true }).get('#swap-page').wait('@CacheHit', { timeout: 20000 })
})
it('records a cache miss', () => {
cy.then(async () => {
const cacheKeys = await window.caches.keys()
const cacheKey = cacheKeys.find((key) => key.match(/precache/))
assert(cacheKey)
const cache = await window.caches.open(cacheKey)
const keys = await cache.keys()
const key = keys.find((key) => key.url.match(/index/))
assert(key)
await cache.put(key, new Response())
})
.visit('/', { serviceWorker: true })
.get('#swap-page')
.wait('@CacheMiss', { timeout: 20000 })
})
})

@ -6,7 +6,41 @@
// ***********************************************************
// Import commands.ts using ES2015 syntax:
import './ethereum'
import { injected } from './ethereum'
import assert = require('assert')
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface ApplicationWindow {
ethereum: typeof injected
}
interface VisitOptions {
serviceWorker?: true
}
}
}
// sets up the injected provider to be a mock ethereum provider with the given mnemonic/index
// eslint-disable-next-line no-undef
Cypress.Commands.overwrite(
'visit',
(original, url: string | Partial<Cypress.VisitOptions>, options?: Partial<Cypress.VisitOptions>) => {
assert(typeof url === 'string')
cy.intercept('/service-worker.js', options?.serviceWorker ? undefined : { statusCode: 404 }).then(() => {
original({
...options,
url: (url.startsWith('/') && url.length > 2 && !url.startsWith('/#') ? `/#${url}` : url) + '?chain=rinkeby',
onBeforeLoad(win) {
options?.onBeforeLoad?.(win)
win.localStorage.clear()
win.ethereum = injected
},
})
})
}
)
beforeEach(() => {
// Infura security policies are based on Origin headers.

@ -5,7 +5,6 @@
import { Eip1193Bridge } from '@ethersproject/experimental/lib/eip1193-bridge'
import { JsonRpcProvider } from '@ethersproject/providers'
import { Wallet } from '@ethersproject/wallet'
import assert = require('assert')
// todo: figure out how env vars actually work in CI
// const TEST_PRIVATE_KEY = Cypress.env('INTEGRATION_TEST_PRIVATE_KEY')
@ -21,7 +20,7 @@ export const TEST_ADDRESS_NEVER_USE_SHORTENED = `${TEST_ADDRESS_NEVER_USE.substr
const provider = new JsonRpcProvider('https://rinkeby.infura.io/v3/4bf032f2d38a4ed6bb975b80d6340847', 4)
const signer = new Wallet(TEST_PRIVATE_KEY, provider)
const injected = new (class extends Eip1193Bridge {
export const injected = new (class extends Eip1193Bridge {
chainId = 4
async sendAsync(...args: any[]) {
@ -73,21 +72,3 @@ const injected = new (class extends Eip1193Bridge {
}
}
})(signer, provider)
// sets up the injected provider to be a mock ethereum provider with the given mnemonic/index
// eslint-disable-next-line no-undef
Cypress.Commands.overwrite(
'visit',
(original, url: string | Partial<Cypress.VisitOptions>, options?: Partial<Cypress.VisitOptions>) => {
assert(typeof url === 'string')
return original({
...options,
url: (url.startsWith('/') && url.length > 2 && !url.startsWith('/#') ? `/#${url}` : url) + '?chain=rinkeby',
onBeforeLoad(win: Cypress.AUTWindow & { ethereum?: Eip1193Bridge }) {
options?.onBeforeLoad?.(win)
win.localStorage.clear()
win.ethereum = injected
},
})
}
)

@ -18,7 +18,8 @@
"build": "react-scripts build",
"serve": "serve build -l 3000",
"test": "react-scripts test --coverage",
"cypress": "cypress"
"cypress:open": "cypress open --browser chrome --e2e",
"cypress:run": "cypress run --browser chrome --e2e"
},
"jest": {
"collectCoverageFrom": [
@ -196,6 +197,7 @@
"web3-react-walletlink-connector": "npm:@web3-react/walletlink-connector@^6.2.13",
"wicg-inert": "^3.1.1",
"workbox-core": "^6.1.0",
"workbox-navigation-preload": "^6.1.0",
"workbox-precaching": "^6.1.0",
"workbox-routing": "^6.1.0"
},

@ -53,6 +53,11 @@ if (typeof GOOGLE_ANALYTICS_ID === 'string') {
googleAnalytics.initialize('test', { gtagOptions: { debug_mode: true } })
}
const installed = Boolean(window.navigator.serviceWorker?.controller)
const hit = Boolean((window as any).__isDocumentCached)
const action = installed ? (hit ? 'Cache hit' : 'Cache miss') : 'Not installed'
sendEvent({ category: 'Service Worker', action, nonInteraction: true })
function reportWebVitals({ name, delta, id }: Metric) {
sendTiming('Web Vitals', name, Math.round(name === 'CLS' ? delta * 1000 : delta), id)
}

@ -1,51 +1 @@
/// <reference lib="webworker" />
/* eslint-disable no-restricted-globals */
import { clientsClaim } from 'workbox-core'
import { createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching'
import { registerRoute } from 'workbox-routing'
declare const self: ServiceWorkerGlobalScope
clientsClaim()
// Precache the relevant assets generated by the build process.
const manifest = self.__WB_MANIFEST.filter((entry) => {
const url = typeof entry === 'string' ? entry : entry.url
// If this is a language file, skip. They are compiled elsewhere.
if (url.endsWith('.po')) {
return false
}
// If this isn't a var woff2 font, skip. Modern browsers only need var fonts.
if (url.endsWith('.woff') || (url.endsWith('.woff2') && !url.includes('.var'))) {
return false
}
return true
})
precacheAndRoute(manifest)
// Set up App Shell-style routing, so that navigation requests are fulfilled
// immediately with a local index.html shell. See
// https://developers.google.com/web/fundamentals/architecture/app-shell
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$')
registerRoute(({ request, url }: { request: Request; url: URL }) => {
// If this isn't app.uniswap.org, skip. IPFS gateways may not have domain
// separation, so they cannot use App Shell-style routing.
if (url.hostname !== 'app.uniswap.org') {
return false
}
// If this isn't a navigation, skip.
if (request.mode !== 'navigate') {
return false
}
// If this looks like a URL for a resource, skip.
if (url.pathname.match(fileExtensionRegexp)) {
return false
}
return true
}, createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html'))
import './serviceWorker'

@ -0,0 +1,156 @@
import { RouteHandlerCallbackOptions, RouteMatchCallbackOptions } from 'workbox-core'
import { matchPrecache as matchPrecacheMock } from 'workbox-precaching'
import { CachedDocument, DOCUMENT, handleDocument, matchDocument } from './document'
jest.mock('workbox-navigation-preload', () => ({ enable: jest.fn() }))
jest.mock('workbox-precaching', () => ({ matchPrecache: jest.fn() }))
jest.mock('workbox-routing', () => ({ Route: class {} }))
describe('document', () => {
describe('matchDocument', () => {
const TEST_DOCUMENTS = [
[{ request: {}, url: { hostname: 'example.com', pathname: '' } }, false],
[{ request: { mode: 'navigate' }, url: { hostname: 'example.com', pathname: '' } }, false],
[{ request: {}, url: { hostname: 'app.uniswap.org', pathname: '' } }, false],
[{ request: { mode: 'navigate' }, url: { hostname: 'app.uniswap.org', pathname: '' } }, true],
[{ request: { mode: 'navigate' }, url: { hostname: 'app.uniswap.org', pathname: '/#/swap' } }, true],
[{ request: { mode: 'navigate' }, url: { hostname: 'app.uniswap.org', pathname: '/asset.gif' } }, false],
[{ request: {}, url: { hostname: 'localhost', pathname: '' } }, false],
[{ request: { mode: 'navigate' }, url: { hostname: 'localhost', pathname: '' } }, true],
[{ request: { mode: 'navigate' }, url: { hostname: 'localhost', pathname: '/#/swap' } }, true],
[{ request: { mode: 'navigate' }, url: { hostname: 'localhost', pathname: '/asset.gif' } }, false],
] as [RouteMatchCallbackOptions, boolean][]
it.each(TEST_DOCUMENTS)('%j', (document: RouteMatchCallbackOptions, expected: boolean) => {
jest.spyOn(window, 'location', 'get').mockReturnValue({ hostname: document.url.hostname } as Location)
expect(matchDocument(document)).toBe(expected)
})
})
describe('handleDocument', () => {
let fetch: jest.SpyInstance
let matchPrecache: jest.SpyInstance
let options: RouteHandlerCallbackOptions
beforeAll(() => {
fetch = jest.spyOn(window, 'fetch')
matchPrecache = matchPrecacheMock as unknown as jest.SpyInstance
})
beforeEach(() => {
fetch.mockReset()
options = {
event: new Event('fetch') as ExtendableEvent,
request: new Request('http://example.com'),
url: new URL('http://example.com'),
}
})
describe('when offline', () => {
let onLine: jest.SpyInstance
beforeAll(() => {
onLine = jest.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
})
afterAll(() => onLine.mockRestore())
it('returns a fetched document', async () => {
const fetched = new Response()
fetch.mockResolvedValueOnce(fetched)
const response = await handleDocument(options)
expect(fetch).toHaveBeenCalledWith(options.request)
expect(response).toBe(fetched)
})
it('returns a clone of offlineDocument with an offlineDocument', async () => {
const offlineDocument = new Response()
const offlineClone = offlineDocument.clone()
jest.spyOn(offlineDocument, 'clone').mockReturnValueOnce(offlineClone)
const response = await handleDocument.call({ offlineDocument }, options)
expect(fetch).not.toHaveBeenCalled()
expect(response).toBe(offlineClone)
})
})
describe('with a thrown fetch', () => {
it('returns a cached response', async () => {
const cached = new Response()
matchPrecache.mockResolvedValueOnce(cached)
fetch.mockRejectedValueOnce(new Error())
const { response } = (await handleDocument(options)) as CachedDocument
expect(response).toBe(cached)
})
it('rethrows with no cached response', async () => {
const error = new Error()
fetch.mockRejectedValueOnce(error)
await expect(handleDocument(options)).rejects.toThrow(error)
})
})
describe.each([
['preloadResponse', true],
['fetched document', false],
])('with a %s', (responseType, withPreloadResponse) => {
let fetched: Response
const FETCHED_ETAGS = 'fetched'
beforeEach(() => {
fetched = new Response(null, { headers: { etag: FETCHED_ETAGS } })
if (withPreloadResponse) {
;(options.event as { preloadResponse?: Promise<Response> }).preloadResponse = Promise.resolve(fetched)
} else {
fetch.mockReturnValueOnce(fetched)
}
})
afterEach(() => {
if (withPreloadResponse) {
expect(fetch).not.toHaveBeenCalled()
} else {
expect(fetch).toHaveBeenCalledWith(DOCUMENT, expect.anything())
}
})
describe('with a cached response', () => {
let cached: Response
beforeEach(() => {
cached = new Response('<html>cached</html>', { headers: { etag: 'cached' } })
matchPrecache.mockResolvedValueOnce(cached)
})
describe('with matched etags', () => {
beforeEach(() => {
cached.headers.set('etag', FETCHED_ETAGS)
})
if (!withPreloadResponse) {
it('aborts the fetched response', async () => {
await handleDocument(options)
const abortSignal = fetch.mock.calls[0][1].signal
expect(abortSignal.aborted).toBeTruthy()
})
}
it('returns the cached response', async () => {
const { response } = (await handleDocument(options)) as CachedDocument
expect(response).toBe(cached)
})
})
it(`returns the ${responseType} with mismatched etags`, async () => {
const response = await handleDocument(options)
expect(response).toBe(fetched)
})
})
it(`returns the ${responseType} with no cached response`, async () => {
const response = await handleDocument(options)
expect(response).toBe(fetched)
})
})
})
})

@ -0,0 +1,112 @@
import { RouteHandlerCallbackOptions, RouteMatchCallbackOptions } from 'workbox-core'
import * as navigationPreload from 'workbox-navigation-preload'
import { matchPrecache } from 'workbox-precaching'
import { Route } from 'workbox-routing'
import { isLocalhost } from './utils'
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$')
export const DOCUMENT = process.env.PUBLIC_URL + '/index.html'
/**
* Matches with App Shell-style routing, so that navigation requests are fulfilled with an index.html shell.
* See https://developers.google.com/web/fundamentals/architecture/app-shell
*/
export function matchDocument({ request, url }: RouteMatchCallbackOptions) {
// If this isn't a navigation, skip.
if (request.mode !== 'navigate') {
return false
}
// If this looks like a resource (ie has a file extension), skip.
if (url.pathname.match(fileExtensionRegexp)) {
return false
}
// If this isn't app.uniswap.org (or a local build), skip.
// IPFS gateways may not have domain separation, so they cannot use document caching.
if (url.hostname !== 'app.uniswap.org' && !isLocalhost()) {
return false
}
return true
}
type HandlerContext = {
offlineDocument?: Response
} | void
/**
* The returned document should always be fresh, so this handler uses a custom strategy:
*
* - Always fetches the document (using navigationPreload, if supported).
* - When available, compares the etag headers of the fetched and cached documents:
* - If matching (fresh) or missing (offline), returns the cached document.
* - If not matching (stale), returns the fetched document.
*
* This ensures that the user will always see the latest document. It requires a network fetch to check the cached
* document's freshness, but does not require a full fetch in most cases, so it still saves time. This is identical to
* the browser's builtin etag strategy, reimplemented in the ServiceWorker.
*
* In addition, this handler may serve an offline document if there is no internet connection.
*/
export async function handleDocument(this: HandlerContext, { event, request }: RouteHandlerCallbackOptions) {
// If we are offline, serve the offline document.
if ('onLine' in navigator && !navigator.onLine) return this?.offlineDocument?.clone() || fetch(request)
// Always use index.html, as its already been matched for App Shell-style routing (@see {@link matchDocument}).
const cachedResponse = await matchPrecache(DOCUMENT)
const { preloadResponse } = event as unknown as { preloadResponse: Promise<Response | undefined> }
// Responses will throw if offline, but if cached the cached response should still be returned.
const controller = new AbortController()
let response
try {
response = (await preloadResponse) || (await fetch(DOCUMENT, { signal: controller.signal }))
if (!cachedResponse) {
return response
}
} catch (e) {
if (!cachedResponse) throw e
return CachedDocument.from(cachedResponse)
}
// The etag header can be queried before the entire response body has streamed, so it is still a
// performant cache key.
const etag = response?.headers.get('etag')
const cachedEtag = cachedResponse?.headers.get('etag')
if (etag && etag === cachedEtag) {
// If the cache is still fresh, cancel the pending response. The preloadResponse is cancelled
// automatically by returning before it is settled; cancelling the preloadResponse will log
// an error to the console, but it can be ignored - it *should* be cancelled.
controller.abort()
return CachedDocument.from(cachedResponse)
}
return response
}
export class DocumentRoute extends Route {
constructor(offlineDocument?: Response) {
navigationPreload.enable()
super(matchDocument, handleDocument.bind({ offlineDocument }), 'GET')
}
}
/**
* A cache-specific version of the document.
* This document sets the local `__isDocumentCached` variable to true.
*/
export class CachedDocument extends Response {
static async from(response: Response) {
const text = await response.text()
// Injects a marker into the document so that client code knows it was served from cache.
// The marker should be injected immediately in the <head> so it is available to client code.
return new CachedDocument(text.replace('<head>', '<head><script>window.__isDocumentCached=true</script>'), response)
}
private constructor(text: string, public response: Response) {
super(text, response)
}
}

@ -0,0 +1,49 @@
import 'workbox-precaching' // defines __WB_MANIFEST
import { clientsClaim } from 'workbox-core'
import { ExpirationPlugin } from 'workbox-expiration'
import { precacheAndRoute } from 'workbox-precaching'
import { PrecacheEntry } from 'workbox-precaching/_types'
import { registerRoute, Route } from 'workbox-routing'
import { CacheFirst } from 'workbox-strategies'
import { DocumentRoute } from './document'
import { toURL } from './utils'
declare const self: ServiceWorkerGlobalScope
clientsClaim()
self.skipWaiting()
// Registers the document route for the precached document.
// This must be done before setting up workbox-precaching, so that it takes precedence.
registerRoute(new DocumentRoute())
// Splits entries into assets, which are loaded on-demand; and entries, which are precached.
// Effectively, this precaches the document, and caches all other assets on-demand.
const { assets, entries } = self.__WB_MANIFEST.reduce<{ assets: string[]; entries: PrecacheEntry[] }>(
({ assets, entries }, entry) => {
if (typeof entry === 'string') {
return { entries, assets: [...assets, entry] }
} else if (entry.revision) {
return { entries: [...entries, entry], assets }
} else {
return { entries, assets: [...assets, toURL(entry)] }
}
},
{ assets: [], entries: [] }
)
// Registers the assets' routes for on-demand caching.
registerRoute(
new Route(
({ url }) => assets.includes('.' + url.pathname),
new CacheFirst({
cacheName: 'assets',
plugins: [new ExpirationPlugin({ maxEntries: 16 })],
})
)
)
// Precaches entries and registers a default route to serve them.
precacheAndRoute(entries)

@ -0,0 +1,19 @@
/// <reference lib="webworker" />
import { PrecacheEntry } from 'workbox-precaching/_types'
declare const self: ServiceWorkerGlobalScope
export function isLocalhost() {
return Boolean(
self.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
self.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
self.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)
)
}
export function toURL(entry: string | PrecacheEntry): string {
return typeof entry === 'string' ? entry : entry.url
}

@ -1,9 +1,10 @@
// jest custom assertions
import '@testing-library/jest-dom'
import '@testing-library/jest-dom' // jest custom assertions
import { Readable } from 'stream'
import { TextDecoder, TextEncoder } from 'util'
if (typeof global.TextEncoder === 'undefined') {
global.ReadableStream = Readable as unknown as typeof globalThis.ReadableStream
global.TextEncoder = TextEncoder
global.TextDecoder = TextDecoder as typeof global.TextDecoder
}

@ -18975,6 +18975,13 @@ workbox-navigation-preload@^5.1.4:
dependencies:
workbox-core "^5.1.4"
workbox-navigation-preload@^6.1.0:
version "6.1.5"
resolved "https://registry.yarnpkg.com/workbox-navigation-preload/-/workbox-navigation-preload-6.1.5.tgz#47a0d3a6d2e74bd3a52b58b72ca337cb5b654310"
integrity sha512-hDbNcWlffv0uvS21jCAC/mYk7NzaGRSWOQXv1p7bj2aONAX5l699D2ZK4D27G8TO0BaLHUmW/1A5CZcsvweQdg==
dependencies:
workbox-core "^6.1.5"
workbox-precaching@^5.1.4:
version "5.1.4"
resolved "https://registry.yarnpkg.com/workbox-precaching/-/workbox-precaching-5.1.4.tgz#874f7ebdd750dd3e04249efae9a1b3f48285fe6b"