feat(service-worker): add support for `?` in SW config globbing (#24105)

The globbing is used in the following sections:
- `assetGroups` > `resources` > `files`/`versionedFiles`
- `assetGroups` > `resources` > `urls`
- `dataGroups` > `urls`
- `navigationUrls`

Query params are ignored for `files`/`versionedFiles` and
`navigationUrls`, but they are still taken into account for
`assetGroups`/`dataGroups` `urls`. To avoid a breaking change, `?` is
matched literally for these patterns.

PR Close #24105
This commit is contained in:
George Kalpakas 2018-05-24 17:51:45 +03:00 committed by Miško Hevery
parent 94076c934c
commit 250527ca68
4 changed files with 46 additions and 21 deletions

View File

@ -23,6 +23,7 @@ Unless otherwise noted, patterns use a limited glob format:
* `**` matches 0 or more path segments. * `**` matches 0 or more path segments.
* `*` matches 0 or more characters excluding `/`. * `*` matches 0 or more characters excluding `/`.
* `?` matches exactly one character excluding `/`.
* The `!` prefix marks the pattern as being negative, meaning that only files that don't match the pattern will be included. * The `!` prefix marks the pattern as being negative, meaning that only files that don't match the pattern will be included.
Example patterns: Example patterns:
@ -106,7 +107,7 @@ This section describes the resources to cache, broken up into three groups.
* `versionedFiles` has been deprecated. As of v6 `versionedFiles` and `files` options have the same behavior. Use `files` instead. * `versionedFiles` has been deprecated. As of v6 `versionedFiles` and `files` options have the same behavior. Use `files` instead.
* `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.)_ _(Negative glob patterns are not supported and `?` will be matched literally; i.e. it will not match any character other than `?`.)_
## `dataGroups` ## `dataGroups`
@ -133,7 +134,7 @@ Similar to `assetGroups`, every data group has a `name` which uniquely identifie
### `urls` ### `urls`
A list of URL patterns. URLs that match these patterns will be cached according to this data group's policy.<br> A list of URL patterns. URLs that match these patterns will be cached according to this data group's policy.<br>
_(Negative glob patterns are not supported.)_ _(Negative glob patterns are not supported and `?` will be matched literally; i.e. it will not match any character other than `?`.)_
### `version` ### `version`
Occasionally APIs change formats in a way that is not backward-compatible. A new version of the app may not be compatible with the old API format and thus may not be compatible with existing cached resources from that API. Occasionally APIs change formats in a way that is not backward-compatible. A new version of the app may not be compatible with the old API format and thus may not be compatible with existing cached resources from that API.

View File

@ -75,7 +75,7 @@ export class Generator {
installMode: group.installMode || 'prefetch', installMode: group.installMode || 'prefetch',
updateMode: group.updateMode || group.installMode || 'prefetch', updateMode: group.updateMode || group.installMode || 'prefetch',
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)), patterns: (group.resources.urls || []).map(url => urlToRegex(url, this.baseHref, true)),
}; };
})); }));
} }
@ -84,7 +84,7 @@ export class Generator {
return (config.dataGroups || []).map(group => { return (config.dataGroups || []).map(group => {
return { return {
name: group.name, name: group.name,
patterns: group.urls.map(url => urlToRegex(url, this.baseHref)), patterns: group.urls.map(url => urlToRegex(url, this.baseHref, true)),
strategy: group.cacheConfig.strategy || 'performance', strategy: group.cacheConfig.strategy || 'performance',
maxSize: group.cacheConfig.maxSize, maxSize: group.cacheConfig.maxSize,
maxAge: parseDurationToMs(group.cacheConfig.maxAge), maxAge: parseDurationToMs(group.cacheConfig.maxAge),
@ -132,12 +132,12 @@ function matches(file: string, patterns: {positive: boolean, regex: RegExp}[]):
return res; return res;
} }
function urlToRegex(url: string, baseHref: string): string { function urlToRegex(url: string, baseHref: string, literalQuestionMark?: boolean): string {
if (!url.startsWith('/') && url.indexOf('://') === -1) { if (!url.startsWith('/') && url.indexOf('://') === -1) {
url = joinUrls(baseHref, url); url = joinUrls(baseHref, url);
} }
return globToRegex(url); return globToRegex(url, literalQuestionMark);
} }
function joinUrls(a: string, b: string): string { function joinUrls(a: string, b: string): string {

View File

@ -6,17 +6,26 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
const WILD_SINGLE = '[^\\/]*'; const QUESTION_MARK = '[^/]';
const WILD_SINGLE = '[^/]*';
const WILD_OPEN = '(?:.+\\/)?'; const WILD_OPEN = '(?:.+\\/)?';
const TO_ESCAPE = [ const TO_ESCAPE_BASE = [
{replace: /\./g, with: '\\.'}, {replace: /\./g, with: '\\.'},
{replace: /\?/g, with: '\\?'},
{replace: /\+/g, with: '\\+'}, {replace: /\+/g, with: '\\+'},
{replace: /\*/g, with: WILD_SINGLE}, {replace: /\*/g, with: WILD_SINGLE},
]; ];
const TO_ESCAPE_WILDCARD_QM = [
...TO_ESCAPE_BASE,
{replace: /\?/g, with: QUESTION_MARK},
];
const TO_ESCAPE_LITERAL_QM = [
...TO_ESCAPE_BASE,
{replace: /\?/g, with: '\\?'},
];
export function globToRegex(glob: string): string { export function globToRegex(glob: string, literalQuestionMark = false): string {
const toEscape = literalQuestionMark ? TO_ESCAPE_LITERAL_QM : TO_ESCAPE_WILDCARD_QM;
const segments = glob.split('/').reverse(); const segments = glob.split('/').reverse();
let regex: string = ''; let regex: string = '';
while (segments.length > 0) { while (segments.length > 0) {
@ -28,7 +37,7 @@ export function globToRegex(glob: string): string {
regex += '.*'; regex += '.*';
} }
} else { } else {
const processed = TO_ESCAPE.reduce( const processed = toEscape.reduce(
(segment, escape) => segment.replace(escape.replace, escape.with), segment); (segment, escape) => segment.replace(escape.replace, escape.with), segment);
regex += processed; regex += processed;
if (segments.length > 0) { if (segments.length > 0) {

View File

@ -14,6 +14,9 @@ import {MockFilesystem} from '../testing/mock';
it('generates a correct config', done => { it('generates a correct config', done => {
const fs = new MockFilesystem({ const fs = new MockFilesystem({
'/index.html': 'This is a test', '/index.html': 'This is a test',
'/main.css': 'This is a CSS file',
'/main.js': 'This is a JS file',
'/main.ts': 'This is a TS file',
'/test.txt': 'Another test', '/test.txt': 'Another test',
'/foo/test.html': 'Another test', '/foo/test.html': 'Another test',
'/ignored/x.html': 'should be ignored', '/ignored/x.html': 'should be ignored',
@ -28,8 +31,9 @@ import {MockFilesystem} from '../testing/mock';
name: 'test', name: 'test',
resources: { resources: {
files: [ files: [
'/**/*.html', '!/ignored/**', '/**/*.html',
// '/*.html', '/**/*.?s',
'!/ignored/**',
], ],
versionedFiles: [ versionedFiles: [
'/**/*.txt', '/**/*.txt',
@ -46,6 +50,7 @@ import {MockFilesystem} from '../testing/mock';
urls: [ urls: [
'/api/**', '/api/**',
'relapi/**', 'relapi/**',
'https://example.com/**/*?with+escaped+chars',
], ],
cacheConfig: { cacheConfig: {
maxSize: 100, maxSize: 100,
@ -56,8 +61,9 @@ import {MockFilesystem} from '../testing/mock';
navigationUrls: [ navigationUrls: [
'/included/absolute/**', '/included/absolute/**',
'!/excluded/absolute/**', '!/excluded/absolute/**',
'/included/some/url?with+escaped+chars', '/included/some/url/with+escaped+chars',
'!excluded/relative/*.txt', '!excluded/relative/*.txt',
'!/api/?*',
'http://example.com/included', 'http://example.com/included',
'!http://example.com/excluded', '!http://example.com/excluded',
], ],
@ -76,17 +82,23 @@ import {MockFilesystem} from '../testing/mock';
urls: [ urls: [
'/test/foo/test.html', '/test/foo/test.html',
'/test/index.html', '/test/index.html',
'/test/main.js',
'/test/main.ts',
'/test/test.txt', '/test/test.txt',
], ],
patterns: [ patterns: [
'\\/absolute\\/.*', '\\/absolute\\/.*',
'\\/some\\/url\\?with\\+escaped\\+chars', '\\/some\\/url\\?with\\+escaped\\+chars',
'\\/test\\/relative\\/[^\\/]*\\.txt', '\\/test\\/relative\\/[^/]*\\.txt',
] ]
}], }],
dataGroups: [{ dataGroups: [{
name: 'other', name: 'other',
patterns: ['\\/api\\/.*', '\\/test\\/relapi\\/.*'], patterns: [
'\\/api\\/.*',
'\\/test\\/relapi\\/.*',
'https:\\/\\/example\\.com\\/(?:.+\\/)?[^/]*\\?with\\+escaped\\+chars',
],
strategy: 'performance', strategy: 'performance',
maxSize: 100, maxSize: 100,
maxAge: 259200000, maxAge: 259200000,
@ -96,14 +108,17 @@ import {MockFilesystem} from '../testing/mock';
navigationUrls: [ navigationUrls: [
{positive: true, regex: '^\\/included\\/absolute\\/.*$'}, {positive: true, regex: '^\\/included\\/absolute\\/.*$'},
{positive: false, regex: '^\\/excluded\\/absolute\\/.*$'}, {positive: false, regex: '^\\/excluded\\/absolute\\/.*$'},
{positive: true, regex: '^\\/included\\/some\\/url\\?with\\+escaped\\+chars$'}, {positive: true, regex: '^\\/included\\/some\\/url\\/with\\+escaped\\+chars$'},
{positive: false, regex: '^\\/test\\/excluded\\/relative\\/[^\\/]*\\.txt$'}, {positive: false, regex: '^\\/test\\/excluded\\/relative\\/[^/]*\\.txt$'},
{positive: false, regex: '^\\/api\\/[^/][^/]*$'},
{positive: true, regex: '^http:\\/\\/example\\.com\\/included$'}, {positive: true, regex: '^http:\\/\\/example\\.com\\/included$'},
{positive: false, regex: '^http:\\/\\/example\\.com\\/excluded$'}, {positive: false, regex: '^http:\\/\\/example\\.com\\/excluded$'},
], ],
hashTable: { hashTable: {
'/test/foo/test.html': '18f6f8eb7b1c23d2bb61bff028b83d867a9e4643', '/test/foo/test.html': '18f6f8eb7b1c23d2bb61bff028b83d867a9e4643',
'/test/index.html': 'a54d88e06612d820bc3be72877c74f257b561b19', '/test/index.html': 'a54d88e06612d820bc3be72877c74f257b561b19',
'/test/main.js': '41347a66676cdc0516934c76d9d13010df420f2c',
'/test/main.ts': '7d333e31f0bfc4f8152732bb211a93629484c035',
'/test/test.txt': '18f6f8eb7b1c23d2bb61bff028b83d867a9e4643' '/test/test.txt': '18f6f8eb7b1c23d2bb61bff028b83d867a9e4643'
} }
}); });
@ -129,9 +144,9 @@ import {MockFilesystem} from '../testing/mock';
dataGroups: [], dataGroups: [],
navigationUrls: [ navigationUrls: [
{positive: true, regex: '^\\/.*$'}, {positive: true, regex: '^\\/.*$'},
{positive: false, regex: '^\\/(?:.+\\/)?[^\\/]*\\.[^\\/]*$'}, {positive: false, regex: '^\\/(?:.+\\/)?[^/]*\\.[^/]*$'},
{positive: false, regex: '^\\/(?:.+\\/)?[^\\/]*__[^\\/]*$'}, {positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*$'},
{positive: false, regex: '^\\/(?:.+\\/)?[^\\/]*__[^\\/]*\\/.*$'}, {positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*\\/.*$'},
], ],
hashTable: {} hashTable: {}
}); });