diff --git a/aio/content/guide/service-worker-config.md b/aio/content/guide/service-worker-config.md index fcba425b7ca722..8f7beaeba6941d 100644 --- a/aio/content/guide/service-worker-config.md +++ b/aio/content/guide/service-worker-config.md @@ -18,10 +18,11 @@ ngsw-config dist src/ngsw-config.json /base/href The configuration file uses the JSON format. All file paths must begin with `/`, which is the deployment directory—usually `dist` in CLI projects. -Patterns use a limited glob format: +{@a glob-patterns} +Unless otherwise noted, patterns use a limited glob format: * `**` matches 0 or more path segments. -* `*` matches exactly one path segment or filename segment. +* `*` matches 0 or more characters excluding `/`. * The `!` prefix marks the pattern as being negative, meaning that only files that don't match the pattern will be included. Example patterns: @@ -37,6 +38,7 @@ Each section of the configuration file is described below. This section enables you to pass any data you want that describes this particular version of the app. The `SwUpdate` service includes that data in the update notifications. Many apps use this section to provide additional information for the display of UI popups, notifying users of the available update. +{@a index-file} ## `index` Specifies the file that serves as the index page to satisfy navigation requests. Usually this is `/index.html`. @@ -102,7 +104,8 @@ This section describes the resources to cache, broken up into three groups. * `versionedFiles` is like `files` but should be used for build artifacts that already include a hash in the filename, which is used for cache busting. The Angular service worker can optimize some aspects of its operation if it can assume file contents are immutable. -* `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. +* `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.
+ _(Negative glob patterns are not supported.)_ ## `dataGroups` @@ -128,7 +131,8 @@ export interface DataGroup { Similar to `assetGroups`, every data group has a `name` which uniquely identifies it. ### `urls` -A list of URL patterns. URLs that match these patterns will be cached according to this data group's policy. +A list of URL patterns. URLs that match these patterns will be cached according to this data group's policy.
+ _(Negative glob patterns are not supported.)_ ### `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. @@ -164,3 +168,39 @@ The Angular service worker can use either of two caching strategies for data res * `performance`, the default, optimizes for responses that are as fast as possible. If a resource exists in the cache, the cached version is used. This allows for some staleness, depending on the `maxAge`, in exchange for better performance. This is suitable for resources that don't change often; for example, user avatar images. * `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. + +## `navigationUrls` + +This optional section enables you to specify a custom list of URLs that will be redirected to the index file. + +### Handling navigation requests + +The ServiceWorker will redirect navigation requests that don't match any `asset` or `data` group to the specified [index file](#index-file). A request is considered to be a navigation request if: + +1. Its [mode](https://developer.mozilla.org/en-US/docs/Web/API/Request/mode) is `navigation`. +2. It accepts a `text/html` response (as determined by the value of the `Accept` header). +3. Its URL matches certain criteria (see below). + +By default, these criteria are: + +1. The URL must not contain a file extension (i.e. a `.`) in the last path segment. +2. The URL must not contain `__`. + +### Matching navigation request URLs + +While these default criteria are fine in most cases, it is sometimes desirable to configure different rules. For example, you may want to ignore specific routes (that are not part of the Angular app) and pass them through to the server. + +This field contains an array of URLs and [glob-like](#glob-patterns) URL patterns that will be matched at runtime. It can contain both negative patterns (i.e. patterns starting with `!`) and non-negative patterns and URLs. + +Only requests whose URLs match _any_ of the non-negative URLs/patterns and _none_ of the negative ones will be considered navigation requests. The URL query will be ignored when matching. + +If the field is omitted, it defaults to: + +```ts +[ + '/**', // Include all URLs. + '!/**/*.*', // Exclude URLs to files. + '!/**/*__*', // Exclude URLs containing `__` in the last segment. + '!/**/*__*/**', // Exclude URLs containing `__` in any other segment. +] +``` diff --git a/packages/service-worker/config/src/generator.ts b/packages/service-worker/config/src/generator.ts index f3061a1dfc9efb..8aa2cf0f1d83db 100644 --- a/packages/service-worker/config/src/generator.ts +++ b/packages/service-worker/config/src/generator.ts @@ -11,6 +11,13 @@ import {Filesystem} from './filesystem'; import {globToRegex} from './glob'; import {Config} from './in'; +const DEFAULT_NAVIGATION_URLS = [ + '/**', // Include all URLs. + '!/**/*.*', // Exclude URLs to files (containing a file extension in the last segment). + '!/**/*__*', // Exclude URLs containing `__` in the last segment. + '!/**/*__*/**', // Exclude URLs containing `__` in any other segment. +]; + /** * Consumes service worker configuration files and processes them into control files. * @@ -23,10 +30,11 @@ export class Generator { const hashTable = {}; return { configVersion: 1, - index: joinUrls(this.baseHref, config.index), appData: config.appData, + index: joinUrls(this.baseHref, config.index), assetGroups: await this.processAssetGroups(config, hashTable), dataGroups: this.processDataGroups(config), hashTable, + navigationUrls: processNavigationUrls(this.baseHref, config.navigationUrls), }; } @@ -80,6 +88,14 @@ export class Generator { } } +export function processNavigationUrls(baseHref: string, urls = DEFAULT_NAVIGATION_URLS): {positive: boolean, regex: string}[] { + return urls.map(url => { + const positive = !url.startsWith('!'); + url = positive ? url : url.substr(1); + return {positive, regex: `^${urlToRegex(url, baseHref)}$`}; + }); +} + function globListToMatcher(globs: string[]): (file: string) => boolean { const patterns = globs.map(pattern => { if (pattern.startsWith('!')) { diff --git a/packages/service-worker/config/src/in.ts b/packages/service-worker/config/src/in.ts index 4dbe9310504593..bf3e27a6c3c20f 100644 --- a/packages/service-worker/config/src/in.ts +++ b/packages/service-worker/config/src/in.ts @@ -26,6 +26,7 @@ export interface Config { index: string; assetGroups?: AssetGroup[]; dataGroups?: DataGroup[]; + navigationUrls?: string[]; } /** @@ -52,4 +53,4 @@ export interface DataGroup { cacheConfig: { maxSize: number; maxAge: Duration; timeout?: Duration; strategy?: 'freshness' | 'performance'; }; -} \ No newline at end of file +} diff --git a/packages/service-worker/config/test/generator_spec.ts b/packages/service-worker/config/test/generator_spec.ts index 3510baf7f3baf9..75047119bf1a6f 100644 --- a/packages/service-worker/config/test/generator_spec.ts +++ b/packages/service-worker/config/test/generator_spec.ts @@ -20,10 +20,10 @@ import {MockFilesystem} from '../testing/mock'; }); const gen = new Generator(fs, '/test'); const res = gen.process({ - index: '/index.html', appData: { test: true, }, + index: '/index.html', assetGroups: [{ name: 'test', resources: { @@ -52,40 +52,56 @@ import {MockFilesystem} from '../testing/mock'; maxAge: '3d', timeout: '1m', } - }] + }], + navigationUrls: [ + '/included/absolute/**', + '!/excluded/absolute/**', + '/included/some/url?with+escaped+chars', + '!excluded/relative/*.txt', + 'http://example.com/included', + '!http://example.com/excluded', + ], }); res.then(config => { expect(config).toEqual({ - 'configVersion': 1, - 'index': '/test/index.html', - 'appData': { - 'test': true, + configVersion: 1, + appData: { + test: true, }, - 'assetGroups': [{ - 'name': 'test', - 'installMode': 'prefetch', - 'updateMode': 'prefetch', - 'urls': [ + index: '/test/index.html', + assetGroups: [{ + name: 'test', + installMode: 'prefetch', + updateMode: 'prefetch', + urls: [ '/test/index.html', '/test/foo/test.html', '/test/test.txt', ], - 'patterns': [ + patterns: [ '\\/absolute\\/.*', '\\/some\\/url\\?with\\+escaped\\+chars', '\\/test\\/relative\\/[^\\/]+\\.txt', ] }], - 'dataGroups': [{ - 'name': 'other', - 'patterns': ['\\/api\\/.*', '\\/test\\/relapi\\/.*'], - 'strategy': 'performance', - 'maxSize': 100, - 'maxAge': 259200000, - 'timeoutMs': 60000, - 'version': 1, + dataGroups: [{ + name: 'other', + patterns: ['\\/api\\/.*', '\\/test\\/relapi\\/.*'], + strategy: 'performance', + maxSize: 100, + maxAge: 259200000, + timeoutMs: 60000, + version: 1, }], - 'hashTable': { + navigationUrls: [ + {positive: true, regex: '^\\/included\\/absolute\\/.*$'}, + {positive: false, regex: '^\\/excluded\\/absolute\\/.*$'}, + {positive: true, regex: '^\\/included\\/some\\/url\\?with\\+escaped\\+chars$'}, + {positive: false, regex: '^\\/test\\/excluded\\/relative\\/[^\\/]+\\.txt$'}, + {positive: true, regex: '^http:\\/\\/example\\.com\\/included$'}, + {positive: false, regex: '^http:\\/\\/example\\.com\\/excluded$'}, + ], + hashTable: { '/test/test.txt': '18f6f8eb7b1c23d2bb61bff028b83d867a9e4643', '/test/index.html': 'a54d88e06612d820bc3be72877c74f257b561b19', '/test/foo/test.html': '18f6f8eb7b1c23d2bb61bff028b83d867a9e4643' @@ -95,5 +111,32 @@ import {MockFilesystem} from '../testing/mock'; }) .catch(err => done.fail(err)); }); + + it('uses default `navigationUrls` if not provided', (done: DoneFn) => { + const fs = new MockFilesystem({ + '/index.html': 'This is a test', + }); + const gen = new Generator(fs, '/test'); + const res = gen.process({ + index: '/index.html', + }); + res.then(config => { + expect(config).toEqual({ + configVersion: 1, + appData: undefined, + index: '/test/index.html', + assetGroups: [], + dataGroups: [], + navigationUrls: [ + {positive: true, regex: '^\\/.*$'}, + {positive: false, regex: '^\\/(?:.+\\/)?[^\\/]+\\.[^\\/]+$'}, + {positive: false, regex: '^\\/(?:.+\\/)?[^\\/]+__[^\\/]+\\/.*$'}, + ], + hashTable: {} + }); + done(); + }) + .catch(err => done.fail(err)); + }); }); } diff --git a/packages/service-worker/test/integration_spec.ts b/packages/service-worker/test/integration_spec.ts index a7a52b420a917b..f4ff957fa49ce0 100644 --- a/packages/service-worker/test/integration_spec.ts +++ b/packages/service-worker/test/integration_spec.ts @@ -41,6 +41,7 @@ const manifest: Manifest = { urls: ['/only.txt'], patterns: [], }], + navigationUrls: [], hashTable: tmpHashTableForFs(dist), }; @@ -55,6 +56,7 @@ const manifestUpdate: Manifest = { urls: ['/only.txt'], patterns: [], }], + navigationUrls: [], hashTable: tmpHashTableForFs(distUpdate), }; diff --git a/packages/service-worker/worker/src/app-version.ts b/packages/service-worker/worker/src/app-version.ts index 9fbbcd6e5e6741..e766dcc8e39792 100644 --- a/packages/service-worker/worker/src/app-version.ts +++ b/packages/service-worker/worker/src/app-version.ts @@ -13,7 +13,6 @@ import {DataGroup} from './data'; import {Database} from './database'; import {IdleScheduler} from './idle'; import {Manifest} from './manifest'; -import {isNavigationRequest} from './util'; /** @@ -40,6 +39,12 @@ export class AppVersion implements UpdateSource { */ private dataGroups: DataGroup[]; + /** + * Requests to URLs that match any of the `include` RegExps and none of the `exclude` RegExps + * are considered navigation requests and handled accordingly. + */ + private navigationUrls: {include: RegExp[], exclude: RegExp[]}; + /** * Tracks whether the manifest has encountered any inconsistencies. */ @@ -79,6 +84,14 @@ export class AppVersion implements UpdateSource { config => new DataGroup( this.scope, this.adapter, config, this.database, `ngsw:${config.version}:data`)); + + // Create `include`/`exclude` RegExps for the `navigationUrls` declared in the manifest. + const includeUrls = manifest.navigationUrls.filter(spec => spec.positive); + const excludeUrls = manifest.navigationUrls.filter(spec => !spec.positive); + this.navigationUrls = { + include: includeUrls.map(spec => new RegExp(spec.regex)), + exclude: excludeUrls.map(spec => new RegExp(spec.regex)), + }; } /** @@ -151,15 +164,36 @@ export class AppVersion implements UpdateSource { // Next, check if this is a navigation request for a route. Detect circular // navigations by checking if the request URL is the same as the index URL. - if (isNavigationRequest(req, this.scope.registration.scope, this.adapter) && - req.url !== this.manifest.index) { + if (req.url !== this.manifest.index && this.isNavigationRequest(req)) { // This was a navigation request. Re-enter `handleFetch` with a request for // the URL. return this.handleFetch(this.adapter.newRequest(this.manifest.index), context); } + return null; } + /** + * Determine whether the request is a navigation request. + * Takes into account: Request mode, `Accept` header, `navigationUrls` patterns. + */ + isNavigationRequest(req: Request): boolean { + if (req.mode !== 'navigate') { + return false; + } + + if (!this.acceptsTextHtml(req)) { + return false; + } + + const urlPrefix = this.scope.registration.scope.replace(/\/$/, ''); + const url = req.url.startsWith(urlPrefix) ? req.url.substr(urlPrefix.length) : req.url; + const urlWithoutQueryOrHash = url.replace(/[?#].*$/, ''); + + return this.navigationUrls.include.some(regex => regex.test(urlWithoutQueryOrHash)) && + !this.navigationUrls.exclude.some(regex => regex.test(urlWithoutQueryOrHash)); + } + /** * Check this version for a given resource with a particular hash. */ @@ -239,4 +273,16 @@ export class AppVersion implements UpdateSource { * Get the opaque application data which was provided with the manifest. */ get appData(): Object|null { return this.manifest.appData || null; } + + /** + * Check whether a request accepts `text/html` (based on the `Accept` header). + */ + private acceptsTextHtml(req: Request): boolean { + const accept = req.headers.get('Accept'); + if (accept === null) { + return false; + } + const values = accept.split(','); + return values.some(value => value.trim().toLowerCase() === 'text/html'); + } } diff --git a/packages/service-worker/worker/src/driver.ts b/packages/service-worker/worker/src/driver.ts index 4ff6184bb106c4..ab5545486f239c 100644 --- a/packages/service-worker/worker/src/driver.ts +++ b/packages/service-worker/worker/src/driver.ts @@ -15,7 +15,6 @@ import {SwCriticalError} from './error'; import {IdleScheduler} from './idle'; import {Manifest, ManifestHash, hashManifest} from './manifest'; import {MsgAny, isMsgActivateUpdate, isMsgCheckForUpdates} from './msg'; -import {isNavigationRequest} from './util'; type ClientId = string; @@ -551,13 +550,14 @@ export class Driver implements Debuggable, UpdateSource { // Check if there is an assigned client id. if (this.clientVersionMap.has(clientId)) { // There is an assignment for this client already. - let hash = this.clientVersionMap.get(clientId) !; + const hash = this.clientVersionMap.get(clientId) !; + let appVersion = this.lookupVersionByHash(hash, 'assignVersion'); // Ordinarily, this client would be served from its assigned version. But, if this // request is a navigation request, this client can be updated to the latest // version immediately. if (this.state === DriverReadyState.NORMAL && hash !== this.latestHash && - isNavigationRequest(event.request, this.scope.registration.scope, this.adapter)) { + appVersion.isNavigationRequest(event.request)) { // Update this client to the latest version immediately. if (this.latestHash === null) { throw new Error(`Invariant violated (assignVersion): latestHash was null`); @@ -566,11 +566,11 @@ export class Driver implements Debuggable, UpdateSource { const client = await this.scope.clients.get(clientId); await this.updateClient(client); - hash = this.latestHash; + appVersion = this.lookupVersionByHash(this.latestHash, 'assignVersion'); } // TODO: make sure the version is valid. - return this.lookupVersionByHash(hash, 'assignVersion'); + return appVersion; } else { // This is the first time this client ID has been seen. Whether the SW is in a // state to handle new clients depends on the current readiness state, so check diff --git a/packages/service-worker/worker/src/manifest.ts b/packages/service-worker/worker/src/manifest.ts index f65a893073f374..7800afa4885e63 100644 --- a/packages/service-worker/worker/src/manifest.ts +++ b/packages/service-worker/worker/src/manifest.ts @@ -16,6 +16,7 @@ export interface Manifest { index: string; assetGroups?: AssetGroupConfig[]; dataGroups?: DataGroupConfig[]; + navigationUrls: {positive: boolean, regex: string}[]; hashTable: {[url: string]: string}; } @@ -40,4 +41,4 @@ export interface DataGroupConfig { export function hashManifest(manifest: Manifest): ManifestHash { return sha1(JSON.stringify(manifest)); -} \ No newline at end of file +} diff --git a/packages/service-worker/worker/src/util.ts b/packages/service-worker/worker/src/util.ts deleted file mode 100644 index bc2488d5d1c9e0..00000000000000 --- a/packages/service-worker/worker/src/util.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import {Adapter} from './adapter'; - -export function isNavigationRequest(req: Request, relativeTo: string, adapter: Adapter): boolean { - if (req.mode !== 'navigate') { - return false; - } - if (req.url.indexOf('__') !== -1) { - return false; - } - if (hasFileExtension(req.url, relativeTo, adapter)) { - return false; - } - if (!acceptsTextHtml(req)) { - return false; - } - return true; -} - -function hasFileExtension(url: string, relativeTo: string, adapter: Adapter): boolean { - const path = adapter.parseUrl(url, relativeTo).path; - const lastSegment = path.split('/').pop() !; - return lastSegment.indexOf('.') !== -1; -} - -function acceptsTextHtml(req: Request): boolean { - const accept = req.headers.get('Accept'); - if (accept === null) { - return false; - } - const values = accept.split(','); - return values.some(value => value.trim().toLowerCase() === 'text/html'); -} diff --git a/packages/service-worker/worker/test/data_spec.ts b/packages/service-worker/worker/test/data_spec.ts index 7d1f00b8948d7a..cdfe16b7e21e52 100644 --- a/packages/service-worker/worker/test/data_spec.ts +++ b/packages/service-worker/worker/test/data_spec.ts @@ -82,6 +82,7 @@ const manifest: Manifest = { version: 1, }, ], + navigationUrls: [], hashTable: tmpHashTableForFs(dist), }; @@ -268,4 +269,4 @@ function makePendingRequest(scope: SwTestHarness, url: string, clientId?: string })(), done ]; - } \ No newline at end of file + } diff --git a/packages/service-worker/worker/test/happy_spec.ts b/packages/service-worker/worker/test/happy_spec.ts index caacc9a839dd39..6b152715e62f00 100644 --- a/packages/service-worker/worker/test/happy_spec.ts +++ b/packages/service-worker/worker/test/happy_spec.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {processNavigationUrls} from '../../config/src/generator'; import {CacheDatabase} from '../src/db-cache'; import {Driver, DriverReadyState} from '../src/driver'; import {Manifest} from '../src/manifest'; @@ -34,6 +35,8 @@ const distUpdate = .addFile('/qux.txt', 'this is qux v2') .addFile('/quux.txt', 'this is quux v2') .addUnhashedFile('/unhashed/a.txt', 'this is unhashed v2', {'Cache-Control': 'max-age=10'}) + .addUnhashedFile('/ignored/file1', 'this is not handled by the SW') + .addUnhashedFile('/ignored/dir/file2', 'this is not handled by the SW either') .build(); const brokenFs = new MockFileSystemBuilder().addFile('/foo.txt', 'this is foo').build(); @@ -51,6 +54,7 @@ const brokenManifest: Manifest = { patterns: [], }], dataGroups: [], + navigationUrls: processNavigationUrls(''), hashTable: tmpHashTableForFs(brokenFs, {'/foo.txt': true}), }; @@ -92,6 +96,7 @@ const manifest: Manifest = { patterns: [], } ], + navigationUrls: processNavigationUrls(''), hashTable: tmpHashTableForFs(dist), }; @@ -133,6 +138,12 @@ const manifestUpdate: Manifest = { patterns: [], } ], + navigationUrls: processNavigationUrls('', [ + '/**/file1', + '/**/file2', + '!/ignored/file1', + '!/ignored/dir/**', + ]), hashTable: tmpHashTableForFs(distUpdate), }; @@ -394,7 +405,7 @@ const manifestUpdateHash = sha1(JSON.stringify(manifestUpdate)); expect(await driver.checkForUpdate()).toEqual(true); serverUpdate.clearRequests(); - expect(await makeRequest(scope, '/baz', 'default', { + expect(await makeRequest(scope, '/file1', 'default', { headers: { 'Accept': 'text/plain, text/html, text/css', }, @@ -626,6 +637,12 @@ const manifestUpdateHash = sha1(JSON.stringify(manifestUpdate)); }); describe('routing', () => { + const navRequest = (url: string, init = {}) => makeRequest(scope, url, undefined, { + headers: {Accept: 'text/plain, text/html, text/css'}, + mode: 'navigate', + ...init, + }); + async_beforeEach(async() => { expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); await driver.initialized; @@ -633,52 +650,94 @@ const manifestUpdateHash = sha1(JSON.stringify(manifestUpdate)); }); async_it('redirects to index on a route-like request', async() => { - expect(await makeRequest(scope, '/baz', 'default', { - headers: { - 'Accept': 'text/plain, text/html, text/css', - }, - mode: 'navigate', - })).toEqual('this is foo'); + expect(await navRequest('/baz')).toEqual('this is foo'); server.assertNoOtherRequests(); }); async_it('redirects to index on a request to the origin URL request', async() => { - expect(await makeRequest(scope, 'http://example.com', 'default', { - headers: { - 'Accept': 'text/plain, text/html, text/css', - }, - mode: 'navigate', - })).toEqual('this is foo'); + expect(await navRequest('http://localhost/')).toEqual('this is foo'); server.assertNoOtherRequests(); }); async_it('does not redirect to index on a non-navigation request', async() => { - expect(await makeRequest(scope, '/baz', 'default', { - headers: { - 'Accept': 'text/plain, text/html, text/css', - }, - })).toBeNull(); + expect(await navRequest('/baz', {mode: undefined})).toBeNull(); + server.assertSawRequestFor('/baz'); + }); + + async_it('does not redirect to index on a request that does not expect HTML', async() => { + expect(await navRequest('/baz', {headers: {}})).toBeNull(); server.assertSawRequestFor('/baz'); + + expect(await navRequest('/qux', {headers: {'Accept': 'text/plain'}})).toBeNull(); + server.assertSawRequestFor('/qux'); }); async_it('does not redirect to index on a request with an extension', async() => { - expect(await makeRequest(scope, '/baz.html', 'default', { - headers: { - 'Accept': 'text/plain, text/html, text/css', - }, - mode: 'navigate', - })).toBeNull(); + expect(await navRequest('/baz.html')).toBeNull(); server.assertSawRequestFor('/baz.html'); + + // Only considers the last path segment when checking for a file extension. + expect(await navRequest('/baz.html/qux')).toBe('this is foo'); + server.assertNoOtherRequests(); }); - async_it('does not redirect to index on a request that does not expect HTML', async() => { - expect(await makeRequest(scope, '/baz', 'default', { - headers: { - 'Accept': 'text/plain, text/css', - }, - mode: 'navigate', - })).toBeNull(); - server.assertSawRequestFor('/baz'); + async_it('does not redirect to index if the URL contains `__`', async() => { + expect(await navRequest('/baz/x__x')).toBeNull(); + server.assertSawRequestFor('/baz/x__x'); + + expect(await navRequest('/baz/x__x/qux')).toBeNull(); + server.assertSawRequestFor('/baz/x__x/qux'); + }); + + describe('(with custom `navigationUrls`)', () => { + async_beforeEach(async() => { + scope.updateServerState(serverUpdate); + await driver.checkForUpdate(); + serverUpdate.clearRequests(); + }); + + async_it('redirects to index on a request that matches any positive pattern', async() => { + expect(await navRequest('/foo/file0')).toBeNull(); + serverUpdate.assertSawRequestFor('/foo/file0'); + + expect(await navRequest('/foo/file1')).toBe('this is foo v2'); + serverUpdate.assertNoOtherRequests(); + + expect(await navRequest('/bar/file2')).toBe('this is foo v2'); + serverUpdate.assertNoOtherRequests(); + }); + + async_it( + 'does not redirect to index on a request that matches any negative pattern', + async() => { + expect(await navRequest('/ignored/file1')).toBe('this is not handled by the SW'); + serverUpdate.assertSawRequestFor('/ignored/file1'); + + expect(await navRequest('/ignored/dir/file2')) + .toBe('this is not handled by the SW either'); + serverUpdate.assertSawRequestFor('/ignored/dir/file2'); + + expect(await navRequest('/ignored/directory/file2')).toBe('this is foo v2'); + serverUpdate.assertNoOtherRequests(); + }); + + async_it('strips URL query before checking `navigationUrls`', async() => { + expect(await navRequest('/foo/file1?query=/a/b')).toBe('this is foo v2'); + serverUpdate.assertNoOtherRequests(); + + expect(await navRequest('/ignored/file1?query=/a/b')).toBe('this is not handled by the SW'); + serverUpdate.assertSawRequestFor('/ignored/file1'); + + expect(await navRequest('/ignored/dir/file2?query=/a/b')) + .toBe('this is not handled by the SW either'); + serverUpdate.assertSawRequestFor('/ignored/dir/file2'); + }); + + async_it('strips registration scope before checking `navigationUrls`', async() => { + expect(await navRequest('http://localhost/ignored/file1')) + .toBe('this is not handled by the SW'); + serverUpdate.assertSawRequestFor('/ignored/file1'); + }); }); }); diff --git a/packages/service-worker/worker/testing/mock.ts b/packages/service-worker/worker/testing/mock.ts index 208e75f9adbf50..25075319a0cc33 100644 --- a/packages/service-worker/worker/testing/mock.ts +++ b/packages/service-worker/worker/testing/mock.ts @@ -196,6 +196,7 @@ export function tmpManifestSingleAssetGroup(fs: MockFileSystem): Manifest { patterns: [], }, ], + navigationUrls: [], hashTable, }; } diff --git a/packages/service-worker/worker/testing/scope.ts b/packages/service-worker/worker/testing/scope.ts index 6fa37de72ab43e..0c2972d928f4c5 100644 --- a/packages/service-worker/worker/testing/scope.ts +++ b/packages/service-worker/worker/testing/scope.ts @@ -287,7 +287,10 @@ export class ConfigBuilder { const hashTable = {}; return { configVersion: 1, - index: '/index.html', assetGroups, hashTable, + index: '/index.html', + assetGroups, + navigationUrls: [], + hashTable, }; } } diff --git a/tools/public_api_guard/service-worker/config.d.ts b/tools/public_api_guard/service-worker/config.d.ts index b82be6638bca9a..f1f664af3417ef 100644 --- a/tools/public_api_guard/service-worker/config.d.ts +++ b/tools/public_api_guard/service-worker/config.d.ts @@ -16,6 +16,7 @@ export interface Config { assetGroups?: AssetGroup[]; dataGroups?: DataGroup[]; index: string; + navigationUrls?: string[]; } /** @experimental */