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:
parent
7e709e10db
commit
c16e49e774
4
.github/workflows/integration-tests.yaml
vendored
4
.github/workflows/integration-tests.yaml
vendored
@ -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}',
|
||||
},
|
||||
|
84
cypress/e2e/service-worker.test.ts
Normal file
84
cypress/e2e/service-worker.test.ts
Normal file
@ -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'
|
||||
|
156
src/serviceWorker/document.test.ts
Normal file
156
src/serviceWorker/document.test.ts
Normal file
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
112
src/serviceWorker/document.ts
Normal file
112
src/serviceWorker/document.ts
Normal file
@ -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)
|
||||
}
|
||||
}
|
49
src/serviceWorker/index.ts
Normal file
49
src/serviceWorker/index.ts
Normal file
@ -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)
|
19
src/serviceWorker/utils.ts
Normal file
19
src/serviceWorker/utils.ts
Normal file
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user