Skip to content

Commit

Permalink
feat(service-worker): include CacheQueryOptions options in ngsw-config
Browse files Browse the repository at this point in the history
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 angular#28443
  • Loading branch information
spike-rabbit committed Apr 30, 2020
1 parent 1786586 commit 54cc856
Show file tree
Hide file tree
Showing 13 changed files with 198 additions and 23 deletions.
16 changes: 16 additions & 0 deletions aio/content/guide/service-worker-config.md
Expand Up @@ -74,6 +74,9 @@ interface AssetGroup {
files?: string[];
urls?: string[];
};
cacheQueryOptions?: {
ignoreSearch?: boolean;
};
}
```

Expand Down Expand Up @@ -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.
Expand All @@ -127,6 +136,9 @@ export interface DataGroup {
timeout?: string;
strategy?: 'freshness' | 'performance';
};
cacheQueryOptions?: {
ignoreSearch?: boolean;
};
}
```

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions goldens/public-api/service-worker/config/config.d.ts
@@ -1,4 +1,5 @@
export declare interface AssetGroup {
cacheQueryOptions?: Pick<CacheQueryOptions, 'ignoreSearch'>;
installMode?: 'prefetch' | 'lazy';
name: string;
resources: {
Expand All @@ -23,6 +24,7 @@ export declare interface DataGroup {
timeout?: Duration;
strategy?: 'freshness' | 'performance';
};
cacheQueryOptions?: Pick<CacheQueryOptions, 'ignoreSearch'>;
name: string;
urls: Glob[];
version?: number;
Expand Down
22 changes: 22 additions & 0 deletions packages/service-worker/config/schema.json
Expand Up @@ -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": [
Expand Down Expand Up @@ -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": [
Expand Down
7 changes: 7 additions & 0 deletions packages/service-worker/config/src/generator.ts
Expand Up @@ -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)),
};
Expand All @@ -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,
};
});
Expand Down Expand Up @@ -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;
}
2 changes: 2 additions & 0 deletions packages/service-worker/config/src/in.ts
Expand Up @@ -39,6 +39,7 @@ export interface AssetGroup {
installMode?: 'prefetch'|'lazy';
updateMode?: 'prefetch'|'lazy';
resources: {files?: Glob[]; urls?: Glob[];};
cacheQueryOptions?: Pick<CacheQueryOptions, 'ignoreSearch'>;
}

/**
Expand All @@ -55,4 +56,5 @@ export interface DataGroup {
timeout?: Duration;
strategy?: 'freshness' | 'performance';
};
cacheQueryOptions?: Pick<CacheQueryOptions, 'ignoreSearch'>;
}
74 changes: 74 additions & 0 deletions packages/service-worker/config/test/generator_spec.ts
Expand Up @@ -92,6 +92,7 @@ describe('Generator', () => {
'\\/some\\/url\\?with\\+escaped\\+chars',
'\\/test\\/relative\\/[^/]*\\.txt',
],
cacheQueryOptions: undefined,
}],
dataGroups: [{
name: 'other',
Expand All @@ -105,6 +106,7 @@ describe('Generator', () => {
maxAge: 259200000,
timeoutMs: 60000,
version: 1,
cacheQueryOptions: undefined,
}],
navigationUrls: [
{positive: true, regex: '^\\/included\\/absolute\\/.*$'},
Expand Down Expand Up @@ -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',
},
});
});
});
16 changes: 9 additions & 7 deletions packages/service-worker/worker/src/assets.ts
Expand Up @@ -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.
Expand All @@ -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;
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand Down
12 changes: 7 additions & 5 deletions packages/service-worker/worker/src/data.ts
Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
]);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/service-worker/worker/src/database.ts
Expand Up @@ -49,7 +49,7 @@ export interface Database {
/**
* Open a `Table`.
*/
open(table: string): Promise<Table>;
open(table: string, cacheQueryOptions?: CacheQueryOptions): Promise<Table>;
}

/**
Expand Down
15 changes: 9 additions & 6 deletions packages/service-worker/worker/src/db-cache.ts
Expand Up @@ -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)!;
Expand All @@ -45,22 +46,24 @@ 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[]> {
return this.cache.keys().then(requests => requests.map(req => req.url.substr(1)));
}

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));
}
Expand Down
2 changes: 2 additions & 0 deletions packages/service-worker/worker/src/manifest.ts
Expand Up @@ -27,6 +27,7 @@ export interface AssetGroupConfig {
updateMode: 'prefetch'|'lazy';
urls: string[];
patterns: string[];
cacheQueryOptions?: CacheQueryOptions;
}

export interface DataGroupConfig {
Expand All @@ -38,6 +39,7 @@ export interface DataGroupConfig {
timeoutMs?: number;
refreshAheadMs?: number;
maxAge: number;
cacheQueryOptions?: CacheQueryOptions;
}

export function hashManifest(manifest: Manifest): ManifestHash {
Expand Down

0 comments on commit 54cc856

Please sign in to comment.