feat(service-worker): include `CacheQueryOptions` options in ngsw-config (#34663)

Previously it was not possible to provide `CacheQueryOptions` ([MDN](https://developer.mozilla.org/en-US/docs/Web/API/Cache)) for querying the Cache.
This commit introduces a new parameter called `cacheQueryOptions` for `DataGroup` and `AssetGroup`.
Currently only `ignoreSearch` is supported as `ignoreVary` and `ignoreMethod` would require using
the complete Request object for matching which is not possible with the current implementation.

Closes #28443

PR Close #34663
This commit is contained in:
Maximilian Koeller 2020-04-29 16:07:34 +02:00 committed by Alex Rickabaugh
parent 49be32c931
commit dc9f4b994e
13 changed files with 198 additions and 23 deletions

View File

@ -74,6 +74,9 @@ interface AssetGroup {
files?: string[]; files?: string[];
urls?: string[]; urls?: string[];
}; };
cacheQueryOptions?: {
ignoreSearch?: boolean;
};
} }
``` ```
@ -110,6 +113,12 @@ This section describes the resources to cache, broken up into the following grou
* `urls` includes both URLs and URL patterns that will be matched at runtime. These resources are not fetched directly and do not have content hashes, but they will be cached according to their HTTP headers. This is most useful for CDNs such as the Google Fonts service.<br> * `urls` includes both URLs and URL patterns that will be matched at runtime. These resources are not fetched directly and do not have content hashes, but they will be cached according to their HTTP headers. This is most useful for CDNs such as the Google Fonts service.<br>
_(Negative glob patterns are not supported and `?` will be matched literally; i.e. it will not match any character other than `?`.)_ _(Negative glob patterns are not supported and `?` will be matched literally; i.e. it will not match any character other than `?`.)_
### `cacheQueryOptions`
These options are used to modify the matching behavior of requests. They are passed to the browsers `Cache#match` function. See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Cache/match) for details. Currently, only the following options are supported:
* `ignoreSearch`: Ignore query parameters. Defaults to `false`.
## `dataGroups` ## `dataGroups`
Unlike asset resources, data requests are not versioned along with the app. They're cached according to manually-configured policies that are more useful for situations such as API requests and other data dependencies. Unlike asset resources, data requests are not versioned along with the app. They're cached according to manually-configured policies that are more useful for situations such as API requests and other data dependencies.
@ -127,6 +136,9 @@ export interface DataGroup {
timeout?: string; timeout?: string;
strategy?: 'freshness' | 'performance'; strategy?: 'freshness' | 'performance';
}; };
cacheQueryOptions?: {
ignoreSearch?: boolean;
};
} }
``` ```
@ -181,6 +193,10 @@ The Angular service worker can use either of two caching strategies for data res
* `freshness` optimizes for currency of data, preferentially fetching requested data from the network. Only if the network times out, according to `timeout`, does the request fall back to the cache. This is useful for resources that change frequently; for example, account balances. * `freshness` optimizes for currency of data, preferentially fetching requested data from the network. Only if the network times out, according to `timeout`, does the request fall back to the cache. This is useful for resources that change frequently; for example, account balances.
### `cacheQueryOptions`
See [assetGroups](#assetgroups) for details.
## `navigationUrls` ## `navigationUrls`
This optional section enables you to specify a custom list of URLs that will be redirected to the index file. This optional section enables you to specify a custom list of URLs that will be redirected to the index file.

View File

@ -1,4 +1,5 @@
export declare interface AssetGroup { export declare interface AssetGroup {
cacheQueryOptions?: Pick<CacheQueryOptions, 'ignoreSearch'>;
installMode?: 'prefetch' | 'lazy'; installMode?: 'prefetch' | 'lazy';
name: string; name: string;
resources: { resources: {
@ -23,6 +24,7 @@ export declare interface DataGroup {
timeout?: Duration; timeout?: Duration;
strategy?: 'freshness' | 'performance'; strategy?: 'freshness' | 'performance';
}; };
cacheQueryOptions?: Pick<CacheQueryOptions, 'ignoreSearch'>;
name: string; name: string;
urls: Glob[]; urls: Glob[];
version?: number; version?: number;

View File

@ -60,6 +60,17 @@
} }
}, },
"additionalProperties": false "additionalProperties": false
},
"cacheQueryOptions" : {
"type": "object",
"description": "Provide options that are passed to Cache#match.",
"properties" : {
"ignoreSearch": {
"type": "boolean",
"description": "Whether to ignore the query string in the URL."
}
},
"additionalProperties": false
} }
}, },
"required": [ "required": [
@ -122,6 +133,17 @@
"maxAge" "maxAge"
], ],
"additionalProperties": false "additionalProperties": false
},
"cacheQueryOptions" : {
"type": "object",
"description": "Provide options that are passed to Cache#match.",
"properties" : {
"ignoreSearch": {
"type": "boolean",
"description": "Whether to ignore the query string in the URL."
}
},
"additionalProperties": false
} }
}, },
"required": [ "required": [

View File

@ -69,6 +69,7 @@ export class Generator {
name: group.name, name: group.name,
installMode: group.installMode || 'prefetch', installMode: group.installMode || 'prefetch',
updateMode: group.updateMode || group.installMode || 'prefetch', updateMode: group.updateMode || group.installMode || 'prefetch',
cacheQueryOptions: buildCacheQueryOptions(group.cacheQueryOptions),
urls: matchedFiles.map(url => joinUrls(this.baseHref, url)), urls: matchedFiles.map(url => joinUrls(this.baseHref, url)),
patterns: (group.resources.urls || []).map(url => urlToRegex(url, this.baseHref, true)), patterns: (group.resources.urls || []).map(url => urlToRegex(url, this.baseHref, true)),
}; };
@ -84,6 +85,7 @@ export class Generator {
maxSize: group.cacheConfig.maxSize, maxSize: group.cacheConfig.maxSize,
maxAge: parseDurationToMs(group.cacheConfig.maxAge), maxAge: parseDurationToMs(group.cacheConfig.maxAge),
timeoutMs: group.cacheConfig.timeout && parseDurationToMs(group.cacheConfig.timeout), timeoutMs: group.cacheConfig.timeout && parseDurationToMs(group.cacheConfig.timeout),
cacheQueryOptions: buildCacheQueryOptions(group.cacheQueryOptions),
version: group.version !== undefined ? group.version : 1, version: group.version !== undefined ? group.version : 1,
}; };
}); });
@ -149,3 +151,8 @@ function withOrderedKeys<T extends {[key: string]: any}>(unorderedObj: T): T {
Object.keys(unorderedObj).sort().forEach(key => orderedObj[key] = unorderedObj[key]); Object.keys(unorderedObj).sort().forEach(key => orderedObj[key] = unorderedObj[key]);
return orderedObj as T; return orderedObj as T;
} }
function buildCacheQueryOptions(inOptions?: Pick<CacheQueryOptions, 'ignoreSearch'>):
CacheQueryOptions|undefined {
return inOptions;
}

View File

@ -39,6 +39,7 @@ export interface AssetGroup {
installMode?: 'prefetch'|'lazy'; installMode?: 'prefetch'|'lazy';
updateMode?: 'prefetch'|'lazy'; updateMode?: 'prefetch'|'lazy';
resources: {files?: Glob[]; urls?: Glob[];}; resources: {files?: Glob[]; urls?: Glob[];};
cacheQueryOptions?: Pick<CacheQueryOptions, 'ignoreSearch'>;
} }
/** /**
@ -55,4 +56,5 @@ export interface DataGroup {
timeout?: Duration; timeout?: Duration;
strategy?: 'freshness' | 'performance'; strategy?: 'freshness' | 'performance';
}; };
cacheQueryOptions?: Pick<CacheQueryOptions, 'ignoreSearch'>;
} }

View File

@ -92,6 +92,7 @@ describe('Generator', () => {
'\\/some\\/url\\?with\\+escaped\\+chars', '\\/some\\/url\\?with\\+escaped\\+chars',
'\\/test\\/relative\\/[^/]*\\.txt', '\\/test\\/relative\\/[^/]*\\.txt',
], ],
cacheQueryOptions: undefined,
}], }],
dataGroups: [{ dataGroups: [{
name: 'other', name: 'other',
@ -105,6 +106,7 @@ describe('Generator', () => {
maxAge: 259200000, maxAge: 259200000,
timeoutMs: 60000, timeoutMs: 60000,
version: 1, version: 1,
cacheQueryOptions: undefined,
}], }],
navigationUrls: [ navigationUrls: [
{positive: true, regex: '^\\/included\\/absolute\\/.*$'}, {positive: true, regex: '^\\/included\\/absolute\\/.*$'},
@ -181,4 +183,76 @@ describe('Generator', () => {
'which is no longer supported. Use \'files\' instead.')); 'which is no longer supported. Use \'files\' instead.'));
} }
}); });
it('generates a correct config with cacheQueryOptions', async () => {
const fs = new MockFilesystem({
'/index.html': 'This is a test',
'/main.js': 'This is a JS file',
});
const gen = new Generator(fs, '/');
const config = await gen.process({
index: '/index.html',
assetGroups: [{
name: 'test',
resources: {
files: [
'/**/*.html',
'/**/*.?s',
]
},
cacheQueryOptions: {ignoreSearch: true},
}],
dataGroups: [{
name: 'other',
urls: ['/api/**'],
cacheConfig: {
maxAge: '3d',
maxSize: 100,
strategy: 'performance',
timeout: '1m',
},
cacheQueryOptions: {ignoreSearch: false},
}]
});
expect(config).toEqual({
configVersion: 1,
appData: undefined,
timestamp: 1234567890123,
index: '/index.html',
assetGroups: [{
name: 'test',
installMode: 'prefetch',
updateMode: 'prefetch',
urls: [
'/index.html',
'/main.js',
],
patterns: [],
cacheQueryOptions: {ignoreSearch: true}
}],
dataGroups: [{
name: 'other',
patterns: [
'\\/api\\/.*',
],
strategy: 'performance',
maxSize: 100,
maxAge: 259200000,
timeoutMs: 60000,
version: 1,
cacheQueryOptions: {ignoreSearch: false}
}],
navigationUrls: [
{positive: true, regex: '^\\/.*$'},
{positive: false, regex: '^\\/(?:.+\\/)?[^/]*\\.[^/]*$'},
{positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*$'},
{positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*\\/.*$'},
],
hashTable: {
'/index.html': 'a54d88e06612d820bc3be72877c74f257b561b19',
'/main.js': '41347a66676cdc0516934c76d9d13010df420f2c',
},
});
});
}); });

View File

@ -65,7 +65,8 @@ export abstract class AssetGroup {
// This is the metadata table, which holds specific information for each cached URL, such as // This is the metadata table, which holds specific information for each cached URL, such as
// the timestamp of when it was added to the cache. // the timestamp of when it was added to the cache.
this.metadata = this.db.open(`${this.prefix}:${this.config.name}:meta`); this.metadata =
this.db.open(`${this.prefix}:${this.config.name}:meta`, this.config.cacheQueryOptions);
// Determine the origin from the registration scope. This is used to differentiate between // Determine the origin from the registration scope. This is used to differentiate between
// relative and absolute URLs. // relative and absolute URLs.
@ -75,7 +76,7 @@ export abstract class AssetGroup {
async cacheStatus(url: string): Promise<UpdateCacheStatus> { async cacheStatus(url: string): Promise<UpdateCacheStatus> {
const cache = await this.cache; const cache = await this.cache;
const meta = await this.metadata; const meta = await this.metadata;
const res = await cache.match(this.adapter.newRequest(url)); const res = await cache.match(this.adapter.newRequest(url), this.config.cacheQueryOptions);
if (res === undefined) { if (res === undefined) {
return UpdateCacheStatus.NOT_CACHED; return UpdateCacheStatus.NOT_CACHED;
} }
@ -120,7 +121,7 @@ export abstract class AssetGroup {
// Look for a cached response. If one exists, it can be used to resolve the fetch // Look for a cached response. If one exists, it can be used to resolve the fetch
// operation. // operation.
const cachedResponse = await cache.match(req); const cachedResponse = await cache.match(req, this.config.cacheQueryOptions);
if (cachedResponse !== undefined) { if (cachedResponse !== undefined) {
// A response has already been cached (which presumably matches the hash for this // A response has already been cached (which presumably matches the hash for this
// resource). Check whether it's safe to serve this resource from cache. // resource). Check whether it's safe to serve this resource from cache.
@ -253,7 +254,7 @@ export abstract class AssetGroup {
const metaTable = await this.metadata; const metaTable = await this.metadata;
// Lookup the response in the cache. // Lookup the response in the cache.
const response = await cache.match(this.adapter.newRequest(url)); const response = await cache.match(this.adapter.newRequest(url), this.config.cacheQueryOptions);
if (response === undefined) { if (response === undefined) {
// It's not found, return null. // It's not found, return null.
return null; return null;
@ -518,7 +519,7 @@ export class PrefetchAssetGroup extends AssetGroup {
const req = this.adapter.newRequest(url); const req = this.adapter.newRequest(url);
// First, check the cache to see if there is already a copy of this resource. // First, check the cache to see if there is already a copy of this resource.
const alreadyCached = (await cache.match(req)) !== undefined; const alreadyCached = (await cache.match(req, this.config.cacheQueryOptions)) !== undefined;
// If the resource is in the cache already, it can be skipped. // If the resource is in the cache already, it can be skipped.
if (alreadyCached) { if (alreadyCached) {
@ -554,7 +555,8 @@ export class PrefetchAssetGroup extends AssetGroup {
// It's possible that the resource in question is already cached. If so, // It's possible that the resource in question is already cached. If so,
// continue to the next one. // continue to the next one.
const alreadyCached = (await cache.match(req) !== undefined); const alreadyCached =
(await cache.match(req, this.config.cacheQueryOptions) !== undefined);
if (alreadyCached) { if (alreadyCached) {
return; return;
} }
@ -595,7 +597,7 @@ export class LazyAssetGroup extends AssetGroup {
const req = this.adapter.newRequest(url); const req = this.adapter.newRequest(url);
// First, check the cache to see if there is already a copy of this resource. // First, check the cache to see if there is already a copy of this resource.
const alreadyCached = (await cache.match(req)) !== undefined; const alreadyCached = (await cache.match(req, this.config.cacheQueryOptions)) !== undefined;
// If the resource is in the cache already, it can be skipped. // If the resource is in the cache already, it can be skipped.
if (alreadyCached) { if (alreadyCached) {

View File

@ -250,8 +250,10 @@ export class DataGroup {
private prefix: string) { private prefix: string) {
this.patterns = this.config.patterns.map(pattern => new RegExp(pattern)); this.patterns = this.config.patterns.map(pattern => new RegExp(pattern));
this.cache = this.scope.caches.open(`${this.prefix}:dynamic:${this.config.name}:cache`); this.cache = this.scope.caches.open(`${this.prefix}:dynamic:${this.config.name}:cache`);
this.lruTable = this.db.open(`${this.prefix}:dynamic:${this.config.name}:lru`); this.lruTable = this.db.open(
this.ageTable = this.db.open(`${this.prefix}:dynamic:${this.config.name}:age`); `${this.prefix}:dynamic:${this.config.name}:lru`, this.config.cacheQueryOptions);
this.ageTable = this.db.open(
`${this.prefix}:dynamic:${this.config.name}:age`, this.config.cacheQueryOptions);
} }
/** /**
@ -472,7 +474,7 @@ export class DataGroup {
Promise<{res: Response, age: number}|null> { Promise<{res: Response, age: number}|null> {
// Look for a response in the cache. If one exists, return it. // Look for a response in the cache. If one exists, return it.
const cache = await this.cache; const cache = await this.cache;
let res = await cache.match(req); let res = await cache.match(req, this.config.cacheQueryOptions);
if (res !== undefined) { if (res !== undefined) {
// A response was found in the cache, but its age is not yet known. Look it up. // A response was found in the cache, but its age is not yet known. Look it up.
try { try {
@ -564,8 +566,8 @@ export class DataGroup {
private async clearCacheForUrl(url: string): Promise<void> { private async clearCacheForUrl(url: string): Promise<void> {
const [cache, ageTable] = await Promise.all([this.cache, this.ageTable]); const [cache, ageTable] = await Promise.all([this.cache, this.ageTable]);
await Promise.all([ await Promise.all([
cache.delete(this.adapter.newRequest(url, {method: 'GET'})), cache.delete(this.adapter.newRequest(url, {method: 'GET'}), this.config.cacheQueryOptions),
cache.delete(this.adapter.newRequest(url, {method: 'HEAD'})), cache.delete(this.adapter.newRequest(url, {method: 'HEAD'}), this.config.cacheQueryOptions),
ageTable.delete(url), ageTable.delete(url),
]); ]);
} }

View File

@ -49,7 +49,7 @@ export interface Database {
/** /**
* Open a `Table`. * Open a `Table`.
*/ */
open(table: string): Promise<Table>; open(table: string, cacheQueryOptions?: CacheQueryOptions): Promise<Table>;
} }
/** /**

View File

@ -31,10 +31,11 @@ export class CacheDatabase implements Database {
keys => keys.filter(key => key.startsWith(`${this.adapter.cacheNamePrefix}:db:`))); keys => keys.filter(key => key.startsWith(`${this.adapter.cacheNamePrefix}:db:`)));
} }
open(name: string): Promise<Table> { open(name: string, cacheQueryOptions?: CacheQueryOptions): Promise<Table> {
if (!this.tables.has(name)) { if (!this.tables.has(name)) {
const table = this.scope.caches.open(`${this.adapter.cacheNamePrefix}:db:${name}`) const table =
.then(cache => new CacheTable(name, cache, this.adapter)); this.scope.caches.open(`${this.adapter.cacheNamePrefix}:db:${name}`)
.then(cache => new CacheTable(name, cache, this.adapter, cacheQueryOptions));
this.tables.set(name, table); this.tables.set(name, table);
} }
return this.tables.get(name)!; return this.tables.get(name)!;
@ -45,14 +46,16 @@ export class CacheDatabase implements Database {
* A `Table` backed by a `Cache`. * A `Table` backed by a `Cache`.
*/ */
export class CacheTable implements Table { export class CacheTable implements Table {
constructor(readonly table: string, private cache: Cache, private adapter: Adapter) {} constructor(
readonly table: string, private cache: Cache, private adapter: Adapter,
private cacheQueryOptions?: CacheQueryOptions) {}
private request(key: string): Request { private request(key: string): Request {
return this.adapter.newRequest('/' + key); return this.adapter.newRequest('/' + key);
} }
'delete'(key: string): Promise<boolean> { 'delete'(key: string): Promise<boolean> {
return this.cache.delete(this.request(key)); return this.cache.delete(this.request(key), this.cacheQueryOptions);
} }
keys(): Promise<string[]> { keys(): Promise<string[]> {
@ -60,7 +63,7 @@ export class CacheTable implements Table {
} }
read(key: string): Promise<any> { read(key: string): Promise<any> {
return this.cache.match(this.request(key)).then(res => { return this.cache.match(this.request(key), this.cacheQueryOptions).then(res => {
if (res === undefined) { if (res === undefined) {
return Promise.reject(new NotFound(this.table, key)); return Promise.reject(new NotFound(this.table, key));
} }

View File

@ -27,6 +27,7 @@ export interface AssetGroupConfig {
updateMode: 'prefetch'|'lazy'; updateMode: 'prefetch'|'lazy';
urls: string[]; urls: string[];
patterns: string[]; patterns: string[];
cacheQueryOptions?: CacheQueryOptions;
} }
export interface DataGroupConfig { export interface DataGroupConfig {
@ -38,6 +39,7 @@ export interface DataGroupConfig {
timeoutMs?: number; timeoutMs?: number;
refreshAheadMs?: number; refreshAheadMs?: number;
maxAge: number; maxAge: number;
cacheQueryOptions?: CacheQueryOptions;
} }
export function hashManifest(manifest: Manifest): ManifestHash { export function hashManifest(manifest: Manifest): ManifestHash {

View File

@ -9,6 +9,7 @@
import {CacheDatabase} from '../src/db-cache'; import {CacheDatabase} from '../src/db-cache';
import {Driver} from '../src/driver'; import {Driver} from '../src/driver';
import {Manifest} from '../src/manifest'; import {Manifest} from '../src/manifest';
import {MockCache} from '../testing/cache';
import {MockRequest} from '../testing/fetch'; import {MockRequest} from '../testing/fetch';
import {MockFileSystemBuilder, MockServerStateBuilder, tmpHashTableForFs} from '../testing/mock'; import {MockFileSystemBuilder, MockServerStateBuilder, tmpHashTableForFs} from '../testing/mock';
import {SwTestHarness, SwTestHarnessBuilder} from '../testing/scope'; import {SwTestHarness, SwTestHarnessBuilder} from '../testing/scope';
@ -66,6 +67,7 @@ const manifest: Manifest = {
timeoutMs: 1000, timeoutMs: 1000,
maxAge: 5000, maxAge: 5000,
version: 1, version: 1,
cacheQueryOptions: {ignoreSearch: true},
}, },
{ {
name: 'testRefresh', name: 'testRefresh',
@ -191,6 +193,27 @@ describe('data cache', () => {
await driver.updateClient(await scope.clients.get('default')); await driver.updateClient(await scope.clients.get('default'));
expect(await makeRequest(scope, '/api/test')).toEqual('version 2'); expect(await makeRequest(scope, '/api/test')).toEqual('version 2');
}); });
it('CacheQueryOptions are passed through', async () => {
await driver.initialized;
const matchSpy = spyOn(MockCache.prototype, 'match').and.callThrough();
// the first request fetches the resource from the server
await makeRequest(scope, '/api/a');
// the second one will be loaded from the cache
await makeRequest(scope, '/api/a');
expect(matchSpy).toHaveBeenCalledWith(new MockRequest('/api/a'), {ignoreSearch: true});
});
it('still matches if search differs but ignoreSearch is enabled', async () => {
await driver.initialized;
const matchSpy = spyOn(MockCache.prototype, 'match').and.callThrough();
// the first request fetches the resource from the server
await makeRequest(scope, '/api/a?v=1');
// the second one will be loaded from the cache
server.clearRequests();
await makeRequest(scope, '/api/a?v=2');
server.assertNoOtherRequests();
});
}); });
describe('in freshness mode', () => { describe('in freshness mode', () => {

View File

@ -103,11 +103,18 @@ export class MockCache {
throw 'Not implemented'; throw 'Not implemented';
} }
async 'delete'(request: RequestInfo): Promise<boolean> { async 'delete'(request: RequestInfo, options?: CacheQueryOptions): Promise<boolean> {
const url = (typeof request === 'string' ? request : request.url); let url = (typeof request === 'string' ? request : request.url);
if (this.cache.has(url)) { if (this.cache.has(url)) {
this.cache.delete(url); this.cache.delete(url);
return true; return true;
} else if (options?.ignoreSearch) {
url = this.stripQueryAndHash(url);
const cachedUrl = [...this.cache.keys()].find(key => url === this.stripQueryAndHash(key));
if (cachedUrl) {
this.cache.delete(cachedUrl);
return true;
}
} }
return false; return false;
} }
@ -126,6 +133,13 @@ export class MockCache {
} }
// TODO: cleanup typings. Typescript doesn't know this can resolve to undefined. // TODO: cleanup typings. Typescript doesn't know this can resolve to undefined.
let res = this.cache.get(url); let res = this.cache.get(url);
if (!res && options?.ignoreSearch) {
// check if cache has url by ignoring search
url = this.stripQueryAndHash(url);
const matchingReq = [...this.cache.keys()].find(key => url === this.stripQueryAndHash(key));
if (matchingReq !== undefined) res = this.cache.get(matchingReq);
}
if (res !== undefined) { if (res !== undefined) {
res = res.clone(); res = res.clone();
} }
@ -137,8 +151,9 @@ export class MockCache {
return Array.from(this.cache.values()); return Array.from(this.cache.values());
} }
const url = (typeof request === 'string' ? request : request.url); const url = (typeof request === 'string' ? request : request.url);
if (this.cache.has(url)) { const res = await this.match(url, options);
return [this.cache.get(url)!]; if (res) {
return [res];
} else { } else {
return []; return [];
} }
@ -174,6 +189,11 @@ export class MockCache {
}); });
return dehydrated; return dehydrated;
} }
/** remove the query/hash part from a url*/
private stripQueryAndHash(url: string): string {
return url.replace(/[?#].*/, '');
}
} }
// This can be used to simulate a situation (bug?), where the user clears the caches from DevTools, // This can be used to simulate a situation (bug?), where the user clears the caches from DevTools,