diff --git a/packages/workbox-precaching/src/PrecacheController.ts b/packages/workbox-precaching/src/PrecacheController.ts index bb7f97e59..8e9d468c2 100644 --- a/packages/workbox-precaching/src/PrecacheController.ts +++ b/packages/workbox-precaching/src/PrecacheController.ts @@ -32,10 +32,38 @@ declare global { } } -interface PrecacheControllerOptions { +function chunk(array: T[], chunkSize = 1) { + const chunks: T[][] = []; + const tmp = [...array]; + if (chunkSize <= 0) { + return chunks; + } + while (tmp.length) chunks.push(tmp.splice(0, chunkSize)); + return chunks; +} + +export interface PrecacheControllerOptions { + /** The cache to use for precaching. */ cacheName?: string; + + /** + * Plugins to use when precaching as well + * as responding to fetch events for precached assets. + */ plugins?: WorkboxPlugin[]; + + /** + * Whether to attempt to + * get the response from the network if there's a precache miss. + * @default true + */ fallbackToNetwork?: boolean; + + /** + * The maximum number of concurrent prefetch requests to make. + * @default 1 + */ + concurrentRequests?: number; } /** @@ -45,6 +73,16 @@ interface PrecacheControllerOptions { */ class PrecacheController { private _installAndActiveListenersAdded?: boolean; + + /** + * The number of requests to patch concurrently. By default, concurrent request batching should + * be disabled. + * @link https://github.com/GoogleChrome/workbox/issues/2528 + * @link https://github.com/GoogleChrome/workbox/issues/2880 + * @default 1 + */ + private readonly _concurrentRequests: number; + private readonly _strategy: Strategy; private readonly _urlsToCacheKeys: Map = new Map(); private readonly _urlsToCacheModes: Map< @@ -61,17 +99,13 @@ class PrecacheController { /** * Create a new PrecacheController. * - * @param {Object} [options] - * @param {string} [options.cacheName] The cache to use for precaching. - * @param {string} [options.plugins] Plugins to use when precaching as well - * as responding to fetch events for precached assets. - * @param {boolean} [options.fallbackToNetwork=true] Whether to attempt to - * get the response from the network if there's a precache miss. + * @param {PrecacheControllerOptions} [options={}] Optional precache controller configurations */ constructor({ cacheName, plugins = [], fallbackToNetwork = true, + concurrentRequests = 1, }: PrecacheControllerOptions = {}) { this._strategy = new PrecacheStrategy({ cacheName: cacheNames.getPrecacheName(cacheName), @@ -81,6 +115,8 @@ class PrecacheController { ], fallbackToNetwork, }); + this._concurrentRequests = + concurrentRequests && concurrentRequests > 0 ? concurrentRequests : 1; // Bind the install and activate methods to the instance. this.install = this.install.bind(this); @@ -203,25 +239,34 @@ class PrecacheController { const installReportPlugin = new PrecacheInstallReportPlugin(); this.strategy.plugins.push(installReportPlugin); - // Cache entries one at a time. - // See https://github.com/GoogleChrome/workbox/issues/2528 - for (const [url, cacheKey] of this._urlsToCacheKeys) { - const integrity = this._cacheKeysToIntegrities.get(cacheKey); - const cacheMode = this._urlsToCacheModes.get(url); - - const request = new Request(url, { - integrity, - cache: cacheMode, - credentials: 'same-origin', - }); - - await Promise.all( - this.strategy.handleAll({ - params: {cacheKey}, - request, - event, - }), + const chunkedUrlsToCacheKeys = chunk( + Array.from(this._urlsToCacheKeys), + this._concurrentRequests, + ); + + for (const urlsToCacheKeysChunk of chunkedUrlsToCacheKeys) { + const batchedRequests = urlsToCacheKeysChunk.map( + async ([url, cacheKey]) => { + const integrity = this._cacheKeysToIntegrities.get(cacheKey); + const cacheMode = this._urlsToCacheModes.get(url); + + const request = new Request(url, { + integrity, + cache: cacheMode, + credentials: 'same-origin', + }); + + return Promise.all( + this.strategy.handleAll({ + params: {cacheKey}, + request, + event, + }), + ); + }, ); + + await Promise.all(batchedRequests); } const {updatedURLs, notUpdatedURLs} = installReportPlugin; diff --git a/packages/workbox-precaching/src/precache.ts b/packages/workbox-precaching/src/precache.ts index 45c8d7e82..95ba869ee 100644 --- a/packages/workbox-precaching/src/precache.ts +++ b/packages/workbox-precaching/src/precache.ts @@ -9,6 +9,7 @@ import {getOrCreatePrecacheController} from './utils/getOrCreatePrecacheController.js'; import {PrecacheEntry} from './_types.js'; import './_version.js'; +import {PrecacheControllerOptions} from './PrecacheController.js'; /** * Adds items to the precache list, removing any duplicates and @@ -29,8 +30,11 @@ import './_version.js'; * * @memberof workbox-precaching */ -function precache(entries: Array): void { - const precacheController = getOrCreatePrecacheController(); +function precache( + entries: Array, + options?: PrecacheControllerOptions, +): void { + const precacheController = getOrCreatePrecacheController(options); precacheController.precache(entries); } diff --git a/packages/workbox-precaching/src/precacheAndRoute.ts b/packages/workbox-precaching/src/precacheAndRoute.ts index 7167a01cd..2cbb4fdeb 100644 --- a/packages/workbox-precaching/src/precacheAndRoute.ts +++ b/packages/workbox-precaching/src/precacheAndRoute.ts @@ -10,6 +10,7 @@ import {addRoute} from './addRoute.js'; import {precache} from './precache.js'; import {PrecacheRouteOptions, PrecacheEntry} from './_types.js'; import './_version.js'; +import {PrecacheControllerOptions} from './PrecacheController.js'; /** * This method will add entries to the precache list and add a route to @@ -27,10 +28,11 @@ import './_version.js'; */ function precacheAndRoute( entries: Array, - options?: PrecacheRouteOptions, + routeOptions?: PrecacheRouteOptions, + controllerOptions?: PrecacheControllerOptions, ): void { - precache(entries); - addRoute(options); + precache(entries, controllerOptions); + addRoute(routeOptions); } export {precacheAndRoute}; diff --git a/packages/workbox-precaching/src/utils/getOrCreatePrecacheController.ts b/packages/workbox-precaching/src/utils/getOrCreatePrecacheController.ts index 429650d44..7c5a8abb6 100644 --- a/packages/workbox-precaching/src/utils/getOrCreatePrecacheController.ts +++ b/packages/workbox-precaching/src/utils/getOrCreatePrecacheController.ts @@ -6,7 +6,10 @@ https://opensource.org/licenses/MIT. */ -import {PrecacheController} from '../PrecacheController.js'; +import { + PrecacheController, + PrecacheControllerOptions, +} from '../PrecacheController.js'; import '../_version.js'; let precacheController: PrecacheController | undefined; @@ -15,9 +18,11 @@ let precacheController: PrecacheController | undefined; * @return {PrecacheController} * @private */ -export const getOrCreatePrecacheController = (): PrecacheController => { +export const getOrCreatePrecacheController = ( + options?: PrecacheControllerOptions, +): PrecacheController => { if (!precacheController) { - precacheController = new PrecacheController(); + precacheController = new PrecacheController(options); } return precacheController; }; diff --git a/test/workbox-precaching/sw/test-PrecacheController.mjs b/test/workbox-precaching/sw/test-PrecacheController.mjs index bd52b19c7..6278e679f 100644 --- a/test/workbox-precaching/sw/test-PrecacheController.mjs +++ b/test/workbox-precaching/sw/test-PrecacheController.mjs @@ -100,6 +100,12 @@ describe(`PrecacheController`, function () { expect(self.fetch.callCount).to.equal(0); }); + + it(`should construct with valid 'concurrentRequest' amount`, async function () { + expect(() => { + new PrecacheController({concurrentRequests: 5}); + }).to.not.throw(); + }); }); describe(`addToCacheList()`, function () { @@ -803,6 +809,228 @@ describe(`PrecacheController`, function () { expect(event.waitUntil.callCount).to.equal(1); }); + + describe(`concurrent requests`, function () { + it(`should not batch requests by default`, async function () { + self.fetch.restore(); + + const precacheController = new PrecacheController(); + const cacheList = [ + '/index.1234.html', + {url: '/example.1234.css'}, + {url: '/scripts/index.js', revision: '1234'}, + {url: '/scripts/stress.js?test=search&foo=bar', revision: '1234'}, + ]; + precacheController.addToCacheList(cacheList); + const event = new ExtendableEvent('install'); + spyOnEvent(event); + let resolveFetch; + const fetchLock = new Promise((resolve) => (resolveFetch = resolve)); + const firstFetchMade = new Promise((resolve) => { + sandbox.stub(self, 'fetch').callsFake(async () => { + resolve(); + await fetchLock; + return new Response('stub'); + }); + }); + + const installPromise = precacheController.install(event); + // Wait for the first fetch to be made + await firstFetchMade; + // Wait an additional 500 seconds (could be a bit flakey. However, worst case, this test is a false negative) + await new Promise((resolve) => setTimeout(resolve, 500)); + + expect(self.fetch.callCount).to.equal(1); + + resolveFetch(); + + await installPromise; + + expect(self.fetch.callCount).to.equal(4); + + const cache = await caches.open(cacheNames.getPrecacheName()); + const keys = await cache.keys(); + expect(keys.length).to.equal(cacheList.length); + + const expectedCacheKeys = [ + `${location.origin}/index.1234.html`, + `${location.origin}/example.1234.css`, + `${location.origin}/scripts/index.js?__WB_REVISION__=1234`, + `${location.origin}/scripts/stress.js?test=search&foo=bar&__WB_REVISION__=1234`, + ]; + for (const key of expectedCacheKeys) { + const cachedResponse = await cache.match(key); + expect(cachedResponse, `${key} is missing from the cache`).to.exist; + } + }); + + const invalidConcurrentRequestSizes = [0, -24]; + + invalidConcurrentRequestSizes.forEach((invalidConcurrentReqCount) => { + it(`should not batch requests for invalid request count of ${invalidConcurrentReqCount}`, async function () { + self.fetch.restore(); + + const precacheController = new PrecacheController(); + const cacheList = [ + '/index.1234.html', + {url: '/example.1234.css'}, + {url: '/scripts/index.js', revision: '1234'}, + {url: '/scripts/stress.js?test=search&foo=bar', revision: '1234'}, + ]; + precacheController.addToCacheList(cacheList); + const event = new ExtendableEvent('install'); + spyOnEvent(event); + let resolveFetch; + const fetchLock = new Promise((resolve) => (resolveFetch = resolve)); + const firstFetchMade = new Promise((resolve) => { + sandbox.stub(self, 'fetch').callsFake(async () => { + resolve(); + await fetchLock; + return new Response('stub'); + }); + }); + + const installPromise = precacheController.install(event); + // Wait for the first fetch to be made + await firstFetchMade; + // Wait an additional 500 seconds (could be a bit flakey. However, worst case, this test is a false negative) + await new Promise((resolve) => setTimeout(resolve, 500)); + + expect(self.fetch.callCount).to.equal(1); + + resolveFetch(); + + await installPromise; + + expect(self.fetch.callCount).to.equal(4); + + const cache = await caches.open(cacheNames.getPrecacheName()); + const keys = await cache.keys(); + expect(keys.length).to.equal(cacheList.length); + + const expectedCacheKeys = [ + `${location.origin}/index.1234.html`, + `${location.origin}/example.1234.css`, + `${location.origin}/scripts/index.js?__WB_REVISION__=1234`, + `${location.origin}/scripts/stress.js?test=search&foo=bar&__WB_REVISION__=1234`, + ]; + for (const key of expectedCacheKeys) { + const cachedResponse = await cache.match(key); + expect(cachedResponse, `${key} is missing from the cache`).to.exist; + } + }); + }); + + it(`should batch requests with request count indivisible by batch size`, async function () { + self.fetch.restore(); + + const precacheController = new PrecacheController({ + concurrentRequests: 3, + }); + const cacheList = [ + '/index.1234.html', + {url: '/example.1234.css'}, + {url: '/scripts/index.js', revision: '1234'}, + {url: '/scripts/stress.js?test=search&foo=bar', revision: '1234'}, + ]; + precacheController.addToCacheList(cacheList); + const event = new ExtendableEvent('install'); + spyOnEvent(event); + let resolveFetch; + const fetchLock = new Promise((resolve) => (resolveFetch = resolve)); + const firstFetchMade = new Promise((resolve) => { + sandbox.stub(self, 'fetch').callsFake(async () => { + resolve(); + await fetchLock; + return new Response('stub'); + }); + }); + const installPromise = precacheController.install(event); + // Wait for the first fetch to be made + await firstFetchMade; + // Wait an additional 500 seconds (could be a bit flakey. However, worst case, this test is a false negative) + await new Promise((resolve) => setTimeout(resolve, 500)); + + expect(self.fetch.callCount).to.equal(3); + + // Resolve the first fetch. This should trigger the second batch of requests + resolveFetch(); + + await installPromise; + + expect(self.fetch.callCount).to.equal(4); + + const cache = await caches.open(cacheNames.getPrecacheName()); + const keys = await cache.keys(); + expect(keys.length).to.equal(cacheList.length); + + const expectedCacheKeys = [ + `${location.origin}/index.1234.html`, + `${location.origin}/example.1234.css`, + `${location.origin}/scripts/index.js?__WB_REVISION__=1234`, + `${location.origin}/scripts/stress.js?test=search&foo=bar&__WB_REVISION__=1234`, + ]; + for (const key of expectedCacheKeys) { + const cachedResponse = await cache.match(key); + expect(cachedResponse, `${key} is missing from the cache`).to.exist; + } + }); + + it(`should batch requests with request count divisible by batch size`, async function () { + self.fetch.restore(); + + const precacheController = new PrecacheController({ + concurrentRequests: 2, + }); + const cacheList = [ + '/index.1234.html', + {url: '/example.1234.css'}, + {url: '/scripts/index.js', revision: '1234'}, + {url: '/scripts/stress.js?test=search&foo=bar', revision: '1234'}, + ]; + precacheController.addToCacheList(cacheList); + const event = new ExtendableEvent('install'); + spyOnEvent(event); + let resolveFetch; + const fetchLock = new Promise((resolve) => (resolveFetch = resolve)); + const firstFetchMade = new Promise((resolve) => { + sandbox.stub(self, 'fetch').callsFake(async () => { + resolve(); + await fetchLock; + return new Response('stub'); + }); + }); + const installPromise = precacheController.install(event); + // Wait for the first fetch to be made + await firstFetchMade; + // Wait an additional 500 seconds (could be a bit flakey. However, worst case, this test is a false negative) + await new Promise((resolve) => setTimeout(resolve, 500)); + + expect(self.fetch.callCount).to.equal(2); + + // Resolve the first fetch. This should trigger the second batch of requests + resolveFetch(); + + await installPromise; + + expect(self.fetch.callCount).to.equal(4); + + const cache = await caches.open(cacheNames.getPrecacheName()); + const keys = await cache.keys(); + expect(keys.length).to.equal(cacheList.length); + + const expectedCacheKeys = [ + `${location.origin}/index.1234.html`, + `${location.origin}/example.1234.css`, + `${location.origin}/scripts/index.js?__WB_REVISION__=1234`, + `${location.origin}/scripts/stress.js?test=search&foo=bar&__WB_REVISION__=1234`, + ]; + for (const key of expectedCacheKeys) { + const cachedResponse = await cache.match(key); + expect(cachedResponse, `${key} is missing from the cache`).to.exist; + } + }); + }); }); describe(`activate()`, function () {