Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/service-worker/config/src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
54 changes: 53 additions & 1 deletion packages/service-worker/config/test/generator_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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\\/.*$'},
]);
});
});
});
2 changes: 1 addition & 1 deletion packages/service-worker/worker/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@ import {Driver} from './src/driver';

const scope = self as any as ServiceWorkerGlobalScope;

const adapter = new Adapter(scope);
const adapter = new Adapter(scope.registration.scope);
const driver = new Driver(scope, adapter, new CacheDatabase(scope, adapter));
39 changes: 33 additions & 6 deletions packages/service-worker/worker/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
* found in the LICENSE file at https://angular.io/license
*/

import {NormalizedUrl} from './api';


/**
* Adapts the service worker to its runtime environment.
*
Expand All @@ -14,12 +17,18 @@
*/
export class Adapter {
readonly cacheNamePrefix: string;
private readonly origin: string;

constructor(protected readonly scopeUrl: string) {
const parsedScopeUrl = this.parseUrl(this.scopeUrl);

// Determine the origin from the registration scope. This is used to differentiate between
// relative and absolute URLs.
this.origin = parsedScopeUrl.origin;

constructor(scope: ServiceWorkerGlobalScope) {
// Suffixing `ngsw` with the baseHref to avoid clash of cache names
// for SWs with different scopes on the same domain.
const baseHref = this.parseUrl(scope.registration.scope).path;
this.cacheNamePrefix = 'ngsw:' + baseHref;
// Suffixing `ngsw` with the baseHref to avoid clash of cache names for SWs with different
// scopes on the same domain.
this.cacheNamePrefix = 'ngsw:' + parsedScopeUrl.path;
}

/**
Expand Down Expand Up @@ -58,7 +67,25 @@ export class Adapter {
}

/**
* Extract the pathname of a URL.
* Get a normalized representation of a URL such as those found in the ServiceWorker's `ngsw.json`
* configuration.
*
* More specifically:
* 1. Resolve the URL relative to the ServiceWorker's scope.
* 2. If the URL is relative to the ServiceWorker's own origin, then only return the path part.
* Otherwise, return the full URL.
*
* @param url The raw request URL.
* @return A normalized representation of the URL.
*/
normalizeUrl(url: string): NormalizedUrl {
// Check the URL's origin against the ServiceWorker's.
const parsed = this.parseUrl(url, this.scopeUrl);
return (parsed.origin === this.origin ? parsed.path : url) as NormalizedUrl;
}

/**
* Parse a URL into its different parts, such as `origin`, `path` and `search`.
*/
parseUrl(url: string, relativeTo?: string): {origin: string, path: string, search: string} {
// Workaround a Safari bug, see
Expand Down
18 changes: 15 additions & 3 deletions packages/service-worker/worker/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ export enum UpdateCacheStatus {
CACHED,
}

/**
* A `string` representing a URL that has been normalized relative to an origin (usually that of the
* ServiceWorker).
*
* If the URL is relative to the origin, then it is representated by the path part only. Otherwise,
* the full URL is used.
*
* NOTE: A `string` is not assignable to a `NormalizedUrl`, but a `NormalizedUrl` is assignable to a
* `string`.
*/
export type NormalizedUrl = string&{_brand: 'normalizedUrl'};

/**
* A source for old versions of URL contents and other resources.
*
Expand All @@ -27,7 +39,7 @@ export interface UpdateSource {
* If an old version of the resource doesn't exist, or exists but does
* not match the hash given, this returns null.
*/
lookupResourceWithHash(url: string, hash: string): Promise<Response|null>;
lookupResourceWithHash(url: NormalizedUrl, hash: string): Promise<Response|null>;

/**
* Lookup an older version of a resource for which the hash is not known.
Expand All @@ -37,15 +49,15 @@ export interface UpdateSource {
* `Response`, but the cache metadata needed to re-cache the resource in
* a newer `AppVersion`.
*/
lookupResourceWithoutHash(url: string): Promise<CacheState|null>;
lookupResourceWithoutHash(url: NormalizedUrl): Promise<CacheState|null>;

/**
* List the URLs of all of the resources which were previously cached.
*
* This allows for the discovery of resources which are not listed in the
* manifest but which were picked up because they matched URL patterns.
*/
previouslyCachedResources(): Promise<string[]>;
previouslyCachedResources(): Promise<NormalizedUrl[]>;

/**
* Check whether a particular resource exists in the most recent cache.
Expand Down
31 changes: 18 additions & 13 deletions packages/service-worker/worker/src/app-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import {Adapter, Context} from './adapter';
import {CacheState, UpdateCacheStatus, UpdateSource} from './api';
import {CacheState, NormalizedUrl, UpdateCacheStatus, UpdateSource} from './api';
import {AssetGroup, LazyAssetGroup, PrefetchAssetGroup} from './assets';
import {DataGroup} from './data';
import {Database} from './database';
Expand All @@ -31,10 +31,9 @@ const BACKWARDS_COMPATIBILITY_NAVIGATION_URLS = [
*/
export class AppVersion implements UpdateSource {
/**
* A Map of absolute URL paths (/foo.txt) to the known hash of their
* contents (if available).
* A Map of absolute URL paths (`/foo.txt`) to the known hash of their contents (if available).
*/
private hashTable = new Map<string, string>();
private hashTable = new Map<NormalizedUrl, string>();

/**
* All of the asset groups active in this version of the app.
Expand All @@ -52,6 +51,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.
*/
Expand All @@ -67,7 +72,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`
Expand Down Expand Up @@ -179,10 +184,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;
Expand Down Expand Up @@ -212,7 +217,7 @@ export class AppVersion implements UpdateSource {
/**
* Check this version for a given resource with a particular hash.
*/
async lookupResourceWithHash(url: string, hash: string): Promise<Response|null> {
async lookupResourceWithHash(url: NormalizedUrl, hash: string): Promise<Response|null> {
// Verify that this version has the requested resource cached. If not,
// there's no point in trying.
if (!this.hashTable.has(url)) {
Expand All @@ -232,7 +237,7 @@ export class AppVersion implements UpdateSource {
/**
* Check this version for a given resource regardless of its hash.
*/
lookupResourceWithoutHash(url: string): Promise<CacheState|null> {
lookupResourceWithoutHash(url: NormalizedUrl): Promise<CacheState|null> {
// Limit the search to asset groups, and only scan the cache, don't
// load resources from the network.
return this.assetGroups.reduce(async (potentialResponse, group) => {
Expand All @@ -250,10 +255,10 @@ export class AppVersion implements UpdateSource {
/**
* List all unhashed resources from all asset groups.
*/
previouslyCachedResources(): Promise<string[]> {
return this.assetGroups.reduce(async (resources, group) => {
return (await resources).concat(await group.unhashedResources());
}, Promise.resolve<string[]>([]));
previouslyCachedResources(): Promise<NormalizedUrl[]> {
return this.assetGroups.reduce(
async (resources, group) => (await resources).concat(await group.unhashedResources()),
Promise.resolve<NormalizedUrl[]>([]));
}

async recentCacheStatus(url: string): Promise<UpdateCacheStatus> {
Expand Down
Loading