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[];
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>
_(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`
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;
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.
### `cacheQueryOptions`
See [assetGroups](#assetgroups) for details.
## `navigationUrls`
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 {
cacheQueryOptions?: Pick<CacheQueryOptions, 'ignoreSearch'>;
installMode?: 'prefetch' | 'lazy';
name: string;
resources: {
@ -23,6 +24,7 @@ export declare interface DataGroup {
timeout?: Duration;
strategy?: 'freshness' | 'performance';
};
cacheQueryOptions?: Pick<CacheQueryOptions, 'ignoreSearch'>;
name: string;
urls: Glob[];
version?: number;

View File

@ -60,6 +60,17 @@
}
},
"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": [
@ -122,6 +133,17 @@
"maxAge"
],
"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": [

View File

@ -69,6 +69,7 @@ export class Generator {
name: group.name,
installMode: group.installMode || 'prefetch',
updateMode: group.updateMode || group.installMode || 'prefetch',
cacheQueryOptions: buildCacheQueryOptions(group.cacheQueryOptions),
urls: matchedFiles.map(url => joinUrls(this.baseHref, url)),
patterns: (group.resources.urls || []).map(url => urlToRegex(url, this.baseHref, true)),
};
@ -84,6 +85,7 @@ export class Generator {
maxSize: group.cacheConfig.maxSize,
maxAge: parseDurationToMs(group.cacheConfig.maxAge),
timeoutMs: group.cacheConfig.timeout && parseDurationToMs(group.cacheConfig.timeout),
cacheQueryOptions: buildCacheQueryOptions(group.cacheQueryOptions),
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]);
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';
updateMode?: 'prefetch'|'lazy';
resources: {files?: Glob[]; urls?: Glob[];};
cacheQueryOptions?: Pick<CacheQueryOptions, 'ignoreSearch'>;
}
/**
@ -55,4 +56,5 @@ export interface DataGroup {
timeout?: Duration;
strategy?: 'freshness' | 'performance';
};
cacheQueryOptions?: Pick<CacheQueryOptions, 'ignoreSearch'>;
}

View File

@ -92,6 +92,7 @@ describe('Generator', () => {
'\\/some\\/url\\?with\\+escaped\\+chars',
'\\/test\\/relative\\/[^/]*\\.txt',
],
cacheQueryOptions: undefined,
}],
dataGroups: [{
name: 'other',
@ -105,6 +106,7 @@ describe('Generator', () => {
maxAge: 259200000,
timeoutMs: 60000,
version: 1,
cacheQueryOptions: undefined,
}],
navigationUrls: [
{positive: true, regex: '^\\/included\\/absolute\\/.*$'},
@ -181,4 +183,76 @@ describe('Generator', () => {
'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
// 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
// relative and absolute URLs.
@ -75,7 +76,7 @@ export abstract class AssetGroup {
async cacheStatus(url: string): Promise<UpdateCacheStatus> {
const cache = await this.cache;
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) {
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
// operation.
const cachedResponse = await cache.match(req);
const cachedResponse = await cache.match(req, this.config.cacheQueryOptions);
if (cachedResponse !== undefined) {
// 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.
@ -253,7 +254,7 @@ export abstract class AssetGroup {
const metaTable = await this.metadata;
// 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) {
// It's not found, return null.
return null;
@ -518,7 +519,7 @@ export class PrefetchAssetGroup extends AssetGroup {
const req = this.adapter.newRequest(url);
// 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 (alreadyCached) {
@ -554,7 +555,8 @@ export class PrefetchAssetGroup extends AssetGroup {
// It's possible that the resource in question is already cached. If so,
// continue to the next one.
const alreadyCached = (await cache.match(req) !== undefined);
const alreadyCached =
(await cache.match(req, this.config.cacheQueryOptions) !== undefined);
if (alreadyCached) {
return;
}
@ -595,7 +597,7 @@ export class LazyAssetGroup extends AssetGroup {
const req = this.adapter.newRequest(url);
// 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 (alreadyCached) {

View File

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

View File

@ -49,7 +49,7 @@ export interface Database {
/**
* 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:`)));
}
open(name: string): Promise<Table> {
open(name: string, cacheQueryOptions?: CacheQueryOptions): Promise<Table> {
if (!this.tables.has(name)) {
const table = this.scope.caches.open(`${this.adapter.cacheNamePrefix}:db:${name}`)
.then(cache => new CacheTable(name, cache, this.adapter));
const table =
this.scope.caches.open(`${this.adapter.cacheNamePrefix}:db:${name}`)
.then(cache => new CacheTable(name, cache, this.adapter, cacheQueryOptions));
this.tables.set(name, table);
}
return this.tables.get(name)!;
@ -45,14 +46,16 @@ export class CacheDatabase implements Database {
* A `Table` backed by a `Cache`.
*/
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 {
return this.adapter.newRequest('/' + key);
}
'delete'(key: string): Promise<boolean> {
return this.cache.delete(this.request(key));
return this.cache.delete(this.request(key), this.cacheQueryOptions);
}
keys(): Promise<string[]> {
@ -60,7 +63,7 @@ export class CacheTable implements Table {
}
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) {
return Promise.reject(new NotFound(this.table, key));
}

View File

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

View File

@ -9,6 +9,7 @@
import {CacheDatabase} from '../src/db-cache';
import {Driver} from '../src/driver';
import {Manifest} from '../src/manifest';
import {MockCache} from '../testing/cache';
import {MockRequest} from '../testing/fetch';
import {MockFileSystemBuilder, MockServerStateBuilder, tmpHashTableForFs} from '../testing/mock';
import {SwTestHarness, SwTestHarnessBuilder} from '../testing/scope';
@ -66,6 +67,7 @@ const manifest: Manifest = {
timeoutMs: 1000,
maxAge: 5000,
version: 1,
cacheQueryOptions: {ignoreSearch: true},
},
{
name: 'testRefresh',
@ -191,6 +193,27 @@ describe('data cache', () => {
await driver.updateClient(await scope.clients.get('default'));
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', () => {

View File

@ -103,11 +103,18 @@ export class MockCache {
throw 'Not implemented';
}
async 'delete'(request: RequestInfo): Promise<boolean> {
const url = (typeof request === 'string' ? request : request.url);
async 'delete'(request: RequestInfo, options?: CacheQueryOptions): Promise<boolean> {
let url = (typeof request === 'string' ? request : request.url);
if (this.cache.has(url)) {
this.cache.delete(url);
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;
}
@ -126,6 +133,13 @@ export class MockCache {
}
// TODO: cleanup typings. Typescript doesn't know this can resolve to undefined.
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) {
res = res.clone();
}
@ -137,8 +151,9 @@ export class MockCache {
return Array.from(this.cache.values());
}
const url = (typeof request === 'string' ? request : request.url);
if (this.cache.has(url)) {
return [this.cache.get(url)!];
const res = await this.match(url, options);
if (res) {
return [res];
} else {
return [];
}
@ -174,6 +189,11 @@ export class MockCache {
});
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,