diff --git a/packages/service-worker/config/src/generator.ts b/packages/service-worker/config/src/generator.ts index 3211aa45f4..21127d3ade 100644 --- a/packages/service-worker/config/src/generator.ts +++ b/packages/service-worker/config/src/generator.ts @@ -131,7 +131,10 @@ function matches(file: string, patterns: {positive: boolean, regex: RegExp}[]): function urlToRegex(url: string, baseHref: string, literalQuestionMark?: boolean): string { if (!url.startsWith('/') && url.indexOf('://') === -1) { - url = joinUrls(baseHref, url); + // Prefix relative URLs with `baseHref`. + // Strip a leading `.` from a relative `baseHref` (e.g. `./foo/`), since it would result in an + // incorrect regex (matching a literal `.`). + url = joinUrls(baseHref.replace(/^\.(?=\/)/, ''), url); } return globToRegex(url, literalQuestionMark); diff --git a/packages/service-worker/config/test/generator_spec.ts b/packages/service-worker/config/test/generator_spec.ts index 38feff36b1..c2600490e5 100644 --- a/packages/service-worker/config/test/generator_spec.ts +++ b/packages/service-worker/config/test/generator_spec.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {Generator} from '../src/generator'; +import {Generator, processNavigationUrls} from '../src/generator'; import {AssetGroup} from '../src/in'; import {MockFilesystem} from '../testing/mock'; @@ -255,4 +255,56 @@ describe('Generator', () => { }, }); }); + + describe('processNavigationUrls()', () => { + const customNavigationUrls = [ + 'https://host/positive/external/**', + '!https://host/negative/external/**', + '/positive/absolute/**', + '!/negative/absolute/**', + 'positive/relative/**', + '!negative/relative/**', + ]; + + it('uses the default `navigationUrls` if not provided', () => { + expect(processNavigationUrls('/')).toEqual([ + {positive: true, regex: '^\\/.*$'}, + {positive: false, regex: '^\\/(?:.+\\/)?[^/]*\\.[^/]*$'}, + {positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*$'}, + {positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*\\/.*$'}, + ]); + }); + + it('prepends `baseHref` to relative URL patterns only', () => { + expect(processNavigationUrls('/base/href/', customNavigationUrls)).toEqual([ + {positive: true, regex: '^https:\\/\\/host\\/positive\\/external\\/.*$'}, + {positive: false, regex: '^https:\\/\\/host\\/negative\\/external\\/.*$'}, + {positive: true, regex: '^\\/positive\\/absolute\\/.*$'}, + {positive: false, regex: '^\\/negative\\/absolute\\/.*$'}, + {positive: true, regex: '^\\/base\\/href\\/positive\\/relative\\/.*$'}, + {positive: false, regex: '^\\/base\\/href\\/negative\\/relative\\/.*$'}, + ]); + }); + + it('strips a leading single `.` from a relative `baseHref`', () => { + expect(processNavigationUrls('./relative/base/href/', customNavigationUrls)).toEqual([ + {positive: true, regex: '^https:\\/\\/host\\/positive\\/external\\/.*$'}, + {positive: false, regex: '^https:\\/\\/host\\/negative\\/external\\/.*$'}, + {positive: true, regex: '^\\/positive\\/absolute\\/.*$'}, + {positive: false, regex: '^\\/negative\\/absolute\\/.*$'}, + {positive: true, regex: '^\\/relative\\/base\\/href\\/positive\\/relative\\/.*$'}, + {positive: false, regex: '^\\/relative\\/base\\/href\\/negative\\/relative\\/.*$'}, + ]); + + // We can't correctly handle double dots in `baseHref`, so leave them as literal matches. + expect(processNavigationUrls('../double/dots/', customNavigationUrls)).toEqual([ + {positive: true, regex: '^https:\\/\\/host\\/positive\\/external\\/.*$'}, + {positive: false, regex: '^https:\\/\\/host\\/negative\\/external\\/.*$'}, + {positive: true, regex: '^\\/positive\\/absolute\\/.*$'}, + {positive: false, regex: '^\\/negative\\/absolute\\/.*$'}, + {positive: true, regex: '^\\.\\.\\/double\\/dots\\/positive\\/relative\\/.*$'}, + {positive: false, regex: '^\\.\\.\\/double\\/dots\\/negative\\/relative\\/.*$'}, + ]); + }); + }); }); diff --git a/packages/service-worker/worker/src/app-version.ts b/packages/service-worker/worker/src/app-version.ts index 14d1b2c5af..01924454aa 100644 --- a/packages/service-worker/worker/src/app-version.ts +++ b/packages/service-worker/worker/src/app-version.ts @@ -52,6 +52,12 @@ export class AppVersion implements UpdateSource { */ private navigationUrls: {include: RegExp[], exclude: RegExp[]}; + /** + * The normalized URL to the file that serves as the index page to satisfy navigation requests. + * Usually this is `/index.html`. + */ + private indexUrl = this.adapter.normalizeUrl(this.manifest.index); + /** * Tracks whether the manifest has encountered any inconsistencies. */ @@ -67,7 +73,7 @@ export class AppVersion implements UpdateSource { readonly manifestHash: string) { // The hashTable within the manifest is an Object - convert it to a Map for easier lookups. Object.keys(this.manifest.hashTable).forEach(url => { - this.hashTable.set(url, this.manifest.hashTable[url]); + this.hashTable.set(adapter.normalizeUrl(url), this.manifest.hashTable[url]); }); // Process each `AssetGroup` declared in the manifest. Each declared group gets an `AssetGroup` @@ -179,10 +185,10 @@ 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 (req.url !== this.manifest.index && this.isNavigationRequest(req)) { + if (this.adapter.normalizeUrl(req.url) !== this.indexUrl && 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 this.handleFetch(this.adapter.newRequest(this.indexUrl), context); } return null; diff --git a/packages/service-worker/worker/src/assets.ts b/packages/service-worker/worker/src/assets.ts index ecebcbaf16..4323c5400f 100644 --- a/packages/service-worker/worker/src/assets.ts +++ b/packages/service-worker/worker/src/assets.ts @@ -26,6 +26,11 @@ export abstract class AssetGroup { */ private inFlightRequests = new Map>(); + /** + * Normalized resource URLs. + */ + protected urls: string[] = []; + /** * Regular expression patterns. */ @@ -52,29 +57,33 @@ export abstract class AssetGroup { protected idle: IdleScheduler, protected config: AssetGroupConfig, protected hashes: Map, protected db: Database, protected prefix: string) { this.name = config.name; + + // Normalize the config's URLs to take the ServiceWorker's scope into account. + this.urls = config.urls.map(url => adapter.normalizeUrl(url)); + // Patterns in the config are regular expressions disguised as strings. Breathe life into them. - this.patterns = this.config.patterns.map(pattern => new RegExp(pattern)); + this.patterns = config.patterns.map(pattern => new RegExp(pattern)); // This is the primary cache, which holds all of the cached requests for this group. If a // resource // isn't in this cache, it hasn't been fetched yet. - this.cache = this.scope.caches.open(`${this.prefix}:${this.config.name}:cache`); + this.cache = scope.caches.open(`${this.prefix}:${config.name}:cache`); // 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.config.cacheQueryOptions); + this.metadata = this.db.open(`${this.prefix}:${config.name}:meta`, config.cacheQueryOptions); } async cacheStatus(url: string): Promise { const cache = await this.cache; const meta = await this.metadata; - const res = await cache.match(this.adapter.newRequest(url), this.config.cacheQueryOptions); + const req = this.adapter.newRequest(url); + const res = await cache.match(req, this.config.cacheQueryOptions); if (res === undefined) { return UpdateCacheStatus.NOT_CACHED; } try { - const data = await meta.read(url); + const data = await meta.read(req.url); if (!data.used) { return UpdateCacheStatus.CACHED_BUT_UNUSED; } @@ -105,7 +114,7 @@ export abstract class AssetGroup { // Either the request matches one of the known resource URLs, one of the patterns for // dynamically matched URLs, or neither. Determine which is the case for this request in // order to decide how to handle it. - if (this.config.urls.indexOf(url) !== -1 || this.patterns.some(pattern => pattern.test(url))) { + if (this.urls.indexOf(url) !== -1 || this.patterns.some(pattern => pattern.test(url))) { // This URL matches a known resource. Either it's been cached already or it's missing, in // which case it needs to be loaded from the network. @@ -235,7 +244,8 @@ export abstract class AssetGroup { const metaTable = await this.metadata; // Lookup the response in the cache. - const response = await cache.match(this.adapter.newRequest(url), this.config.cacheQueryOptions); + const request = this.adapter.newRequest(url); + const response = await cache.match(request, this.config.cacheQueryOptions); if (response === undefined) { // It's not found, return null. return null; @@ -244,7 +254,7 @@ export abstract class AssetGroup { // Next, lookup the cached metadata. let metadata: UrlMetadata|undefined = undefined; try { - metadata = await metaTable.read(url); + metadata = await metaTable.read(request.url); } catch { // Do nothing, not found. This shouldn't happen, but it can be handled. } @@ -258,9 +268,10 @@ export abstract class AssetGroup { */ async unhashedResources(): Promise { const cache = await this.cache; - // Start with the set of all cached URLs. + // Start with the set of all cached requests. return (await cache.keys()) - .map(request => request.url) + // Normalize their URLs. + .map(request => this.adapter.normalizeUrl(request.url)) // Exclude the URLs which have hashes. .filter(url => !this.hashes.has(url)); } @@ -307,7 +318,7 @@ export abstract class AssetGroup { // If the request is not hashed, update its metadata, especially the timestamp. This is // needed for future determination of whether this cached response is stale or not. - if (!this.hashes.has(req.url)) { + if (!this.hashes.has(this.adapter.normalizeUrl(req.url))) { // Metadata is tracked for requests that are unhashed. const meta: UrlMetadata = {ts: this.adapter.time, used}; const metaTable = await this.metadata; @@ -492,7 +503,7 @@ export class PrefetchAssetGroup extends AssetGroup { // Cache all known resources serially. As this reduce proceeds, each Promise waits // on the last before starting the fetch/cache operation for the next request. Any // errors cause fall-through to the final Promise which rejects. - await this.config.urls.reduce(async (previous: Promise, url: string) => { + await this.urls.reduce(async (previous: Promise, url: string) => { // Wait on all previous operations to complete. await previous; @@ -527,8 +538,8 @@ export class PrefetchAssetGroup extends AssetGroup { // First, narrow down the set of resources to those which are handled by this group. // Either it's a known URL, or it matches a given pattern. .filter( - url => this.config.urls.indexOf(url) !== -1 || - this.patterns.some(pattern => pattern.test(url))) + url => + this.urls.indexOf(url) !== -1 || this.patterns.some(pattern => pattern.test(url))) // Finally, process each resource in turn. .reduce(async (previous, url) => { await previous; @@ -552,7 +563,7 @@ export class PrefetchAssetGroup extends AssetGroup { // Write it into the cache. It may already be expired, but it can still serve // traffic until it's updated (stale-while-revalidate approach). await cache.put(req, res.response); - await metaTable.write(url, {...res.metadata, used: false} as UrlMetadata); + await metaTable.write(req.url, {...res.metadata, used: false} as UrlMetadata); }, Promise.resolve()); } } @@ -570,7 +581,7 @@ export class LazyAssetGroup extends AssetGroup { const cache = await this.cache; // Loop through the listed resources, caching any which are available. - await this.config.urls.reduce(async (previous: Promise, url: string) => { + await this.urls.reduce(async (previous: Promise, url: string) => { // Wait on all previous operations to complete. await previous; diff --git a/packages/service-worker/worker/test/happy_spec.ts b/packages/service-worker/worker/test/happy_spec.ts index 9be5bf14c6..7506d11ad6 100644 --- a/packages/service-worker/worker/test/happy_spec.ts +++ b/packages/service-worker/worker/test/happy_spec.ts @@ -13,7 +13,7 @@ import {AssetGroupConfig, DataGroupConfig, Manifest} from '../src/manifest'; import {sha1} from '../src/sha1'; import {clearAllCaches, MockCache} from '../testing/cache'; import {MockRequest, MockResponse} from '../testing/fetch'; -import {MockFileSystem, MockFileSystemBuilder, MockServerStateBuilder, tmpHashTableForFs} from '../testing/mock'; +import {MockFileSystem, MockFileSystemBuilder, MockServerState, MockServerStateBuilder, tmpHashTableForFs} from '../testing/mock'; import {SwTestHarness, SwTestHarnessBuilder} from '../testing/scope'; (function() { @@ -1306,6 +1306,162 @@ describe('Driver', () => { }); }); + describe('with relative base href', () => { + const createManifestWithRelativeBaseHref = (distDir: MockFileSystem): Manifest => ({ + configVersion: 1, + timestamp: 1234567890123, + index: './index.html', + assetGroups: [ + { + name: 'eager', + installMode: 'prefetch', + updateMode: 'prefetch', + urls: [ + './index.html', + './main.js', + './styles.css', + ], + patterns: [ + '/unhashed/.*', + ], + cacheQueryOptions: {ignoreVary: true}, + }, + { + name: 'lazy', + installMode: 'lazy', + updateMode: 'prefetch', + urls: [ + './changed/chunk-1.js', + './changed/chunk-2.js', + './unchanged/chunk-3.js', + './unchanged/chunk-4.js', + ], + patterns: [ + '/lazy/unhashed/.*', + ], + cacheQueryOptions: {ignoreVary: true}, + } + ], + navigationUrls: processNavigationUrls('./'), + hashTable: tmpHashTableForFs(distDir, {}, './'), + }); + + const createServerWithBaseHref = (distDir: MockFileSystem): MockServerState => + new MockServerStateBuilder() + .withRootDirectory('/base/href') + .withStaticFiles(distDir) + .withManifest(createManifestWithRelativeBaseHref(distDir)) + .build(); + + const initialDistDir = new MockFileSystemBuilder() + .addFile('/index.html', 'This is index.html') + .addFile('/main.js', 'This is main.js') + .addFile('/styles.css', 'This is styles.css') + .addFile('/changed/chunk-1.js', 'This is chunk-1.js') + .addFile('/changed/chunk-2.js', 'This is chunk-2.js') + .addFile('/unchanged/chunk-3.js', 'This is chunk-3.js') + .addFile('/unchanged/chunk-4.js', 'This is chunk-4.js') + .build(); + + const serverWithBaseHref = createServerWithBaseHref(initialDistDir); + + beforeEach(() => { + serverWithBaseHref.reset(); + + scope = new SwTestHarnessBuilder('http://localhost/base/href/') + .withServerState(serverWithBaseHref) + .build(); + driver = new Driver(scope, scope, new CacheDatabase(scope, scope)); + }); + + it('initializes prefetched content correctly, after a request kicks it off', async () => { + expect(await makeRequest(scope, '/base/href/index.html')).toBe('This is index.html'); + await driver.initialized; + serverWithBaseHref.assertSawRequestFor('/base/href/ngsw.json'); + serverWithBaseHref.assertSawRequestFor('/base/href/index.html'); + serverWithBaseHref.assertSawRequestFor('/base/href/main.js'); + serverWithBaseHref.assertSawRequestFor('/base/href/styles.css'); + serverWithBaseHref.assertNoOtherRequests(); + + expect(await makeRequest(scope, '/base/href/main.js')).toBe('This is main.js'); + expect(await makeRequest(scope, '/base/href/styles.css')).toBe('This is styles.css'); + serverWithBaseHref.assertNoOtherRequests(); + }); + + it('prefetches updates to lazy cache when set', async () => { + // Helper + const request = (url: string) => makeRequest(scope, url); + + expect(await request('/base/href/index.html')).toBe('This is index.html'); + await driver.initialized; + + // Fetch some files from the `lazy` asset group. + expect(await request('/base/href/changed/chunk-1.js')).toBe('This is chunk-1.js'); + expect(await request('/base/href/unchanged/chunk-3.js')).toBe('This is chunk-3.js'); + + // Install update. + const updatedDistDir = initialDistDir.extend() + .addFile('/changed/chunk-1.js', 'This is chunk-1.js v2') + .addFile('/changed/chunk-2.js', 'This is chunk-2.js v2') + .build(); + const updatedServer = createServerWithBaseHref(updatedDistDir); + + scope.updateServerState(updatedServer); + expect(await driver.checkForUpdate()).toBe(true); + + // Previously requested and changed: Fetch from network. + updatedServer.assertSawRequestFor('/base/href/changed/chunk-1.js'); + // Never requested and changed: Don't fetch. + updatedServer.assertNoRequestFor('/base/href/changed/chunk-2.js'); + // Previously requested and unchanged: Fetch from cache. + updatedServer.assertNoRequestFor('/base/href/unchanged/chunk-3.js'); + // Never requested and unchanged: Don't fetch. + updatedServer.assertNoRequestFor('/base/href/unchanged/chunk-4.js'); + + updatedServer.clearRequests(); + + // Update client. + await driver.updateClient(await scope.clients.get('default')); + + // Already cached. + expect(await request('/base/href/changed/chunk-1.js')).toBe('This is chunk-1.js v2'); + updatedServer.assertNoOtherRequests(); + + // Not cached: Fetch from network. + expect(await request('/base/href/changed/chunk-2.js')).toBe('This is chunk-2.js v2'); + updatedServer.assertSawRequestFor('/base/href/changed/chunk-2.js'); + + // Already cached (copied from old cache). + expect(await request('/base/href/unchanged/chunk-3.js')).toBe('This is chunk-3.js'); + updatedServer.assertNoOtherRequests(); + + // Not cached: Fetch from network. + expect(await request('/base/href/unchanged/chunk-4.js')).toBe('This is chunk-4.js'); + updatedServer.assertSawRequestFor('/base/href/unchanged/chunk-4.js'); + + updatedServer.assertNoOtherRequests(); + }); + + describe('routing', () => { + beforeEach(async () => { + expect(await makeRequest(scope, '/base/href/index.html')).toBe('This is index.html'); + await driver.initialized; + serverWithBaseHref.clearRequests(); + }); + + it('redirects to index on a route-like request', async () => { + expect(await makeNavigationRequest(scope, '/base/href/baz')).toBe('This is index.html'); + serverWithBaseHref.assertNoOtherRequests(); + }); + + it('redirects to index on a request to the scope URL', async () => { + expect(await makeNavigationRequest(scope, 'http://localhost/base/href/')) + .toBe('This is index.html'); + serverWithBaseHref.assertNoOtherRequests(); + }); + }); + }); + describe('cleanupOldSwCaches()', () => { it('should delete the correct caches', async () => { const oldSwCacheNames = [