fix(service-worker): correctly handle relative base href (#37922)
In some cases, it is useful to use a relative base href in the app (e.g. when an app has to be accessible on different URLs, such as on an intranet and the internet - see #25055 for a related discussion). Previously, the Angular ServiceWorker was not able to handle relative base hrefs (for example when building the with `--base-href=./`). This commit fixes this by normalizing all URLs from the ServiceWorker configuration wrt the ServiceWorker's scope. Fixes #25055 PR Close #37922
This commit is contained in:
parent
667aba7508
commit
d19ef6534f
|
@ -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);
|
||||
|
|
|
@ -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\\/.*$'},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -26,6 +26,11 @@ export abstract class AssetGroup {
|
|||
*/
|
||||
private inFlightRequests = new Map<string, Promise<Response>>();
|
||||
|
||||
/**
|
||||
* 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<string, string>, 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<UpdateCacheStatus> {
|
||||
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<UrlMetadata>(url);
|
||||
const data = await meta.read<UrlMetadata>(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<UrlMetadata>(url);
|
||||
metadata = await metaTable.read<UrlMetadata>(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<string[]> {
|
||||
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<void>, url: string) => {
|
||||
await this.urls.reduce(async (previous: Promise<void>, 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<void>, url: string) => {
|
||||
await this.urls.reduce(async (previous: Promise<void>, url: string) => {
|
||||
// Wait on all previous operations to complete.
|
||||
await previous;
|
||||
|
||||
|
|
|
@ -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 = [
|
||||
|
|
Loading…
Reference in New Issue