refactor(service-worker): move asset URL normalization to Adapter (#37922)

This is in preparation of enabling the ServiceWorker to handle
relative paths in `ngsw.json` (as discussed in #25055), which will
require normalizing URLs in other parts of the ServiceWorker.

PR Close #37922
This commit is contained in:
George Kalpakas 2020-07-06 16:55:36 +03:00 committed by atscott
parent 2156beed0c
commit d380e93b82
4 changed files with 54 additions and 41 deletions

View File

@ -12,5 +12,5 @@ import {Driver} from './src/driver';
const scope = self as any as ServiceWorkerGlobalScope; 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)); const driver = new Driver(scope, adapter, new CacheDatabase(scope, adapter));

View File

@ -14,12 +14,18 @@
*/ */
export class Adapter { export class Adapter {
readonly cacheNamePrefix: string; readonly cacheNamePrefix: string;
private readonly origin: string;
constructor(scope: ServiceWorkerGlobalScope) { constructor(protected readonly scopeUrl: string) {
// Suffixing `ngsw` with the baseHref to avoid clash of cache names const parsedScopeUrl = this.parseUrl(this.scopeUrl);
// for SWs with different scopes on the same domain.
const baseHref = this.parseUrl(scope.registration.scope).path; // Determine the origin from the registration scope. This is used to differentiate between
this.cacheNamePrefix = 'ngsw:' + baseHref; // relative and absolute URLs.
this.origin = parsedScopeUrl.origin;
// 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;
} }
/** /**
@ -58,7 +64,31 @@ 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): string {
// Check the URL's origin against the ServiceWorker's.
const parsed = this.parseUrl(url, this.scopeUrl);
if (parsed.origin === this.origin) {
// The URL is relative to the SW's origin: Return the path only.
return parsed.path;
} else {
// The URL is not relative to the SW's origin: Return the full URL.
return url;
}
}
/**
* Parse a URL into its different parts, such as `origin`, `path` and `search`.
*/ */
parseUrl(url: string, relativeTo?: string): {origin: string, path: string, search: string} { parseUrl(url: string, relativeTo?: string): {origin: string, path: string, search: string} {
// Workaround a Safari bug, see // Workaround a Safari bug, see

View File

@ -47,9 +47,6 @@ export abstract class AssetGroup {
*/ */
protected metadata: Promise<Table>; protected metadata: Promise<Table>;
private origin: string;
constructor( constructor(
protected scope: ServiceWorkerGlobalScope, protected adapter: Adapter, protected scope: ServiceWorkerGlobalScope, protected adapter: Adapter,
protected idle: IdleScheduler, protected config: AssetGroupConfig, protected idle: IdleScheduler, protected config: AssetGroupConfig,
@ -67,10 +64,6 @@ export abstract class AssetGroup {
// the timestamp of when it was added to the cache. // the timestamp of when it was added to the cache.
this.metadata = this.metadata =
this.db.open(`${this.prefix}:${this.config.name}:meta`, this.config.cacheQueryOptions); this.db.open(`${this.prefix}:${this.config.name}:meta`, this.config.cacheQueryOptions);
// Determine the origin from the registration scope. This is used to differentiate between
// relative and absolute URLs.
this.origin = this.adapter.parseUrl(this.scope.registration.scope).origin;
} }
async cacheStatus(url: string): Promise<UpdateCacheStatus> { async cacheStatus(url: string): Promise<UpdateCacheStatus> {
@ -108,7 +101,7 @@ export abstract class AssetGroup {
* Process a request for a given resource and return it, or return null if it's not available. * Process a request for a given resource and return it, or return null if it's not available.
*/ */
async handleFetch(req: Request, ctx: Context): Promise<Response|null> { async handleFetch(req: Request, ctx: Context): Promise<Response|null> {
const url = this.getConfigUrl(req.url); const url = this.adapter.normalizeUrl(req.url);
// Either the request matches one of the known resource URLs, one of the patterns for // 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 // dynamically matched URLs, or neither. Determine which is the case for this request in
// order to decide how to handle it. // order to decide how to handle it.
@ -156,18 +149,6 @@ export abstract class AssetGroup {
} }
} }
private getConfigUrl(url: string): string {
// If the URL is relative to the SW's own origin, then only consider the path relative to
// the domain root. Determine this by checking the URL's origin against the SW's.
const parsed = this.adapter.parseUrl(url, this.scope.registration.scope);
if (parsed.origin === this.origin) {
// The URL is relative to the SW's origin domain.
return parsed.path;
} else {
return url;
}
}
/** /**
* Some resources are cached without a hash, meaning that their expiration is controlled * Some resources are cached without a hash, meaning that their expiration is controlled
* by HTTP caching headers. Check whether the given request/response pair is still valid * by HTTP caching headers. Check whether the given request/response pair is still valid
@ -373,7 +354,7 @@ export abstract class AssetGroup {
* Load a particular asset from the network, accounting for hash validation. * Load a particular asset from the network, accounting for hash validation.
*/ */
protected async cacheBustedFetchFromNetwork(req: Request): Promise<Response> { protected async cacheBustedFetchFromNetwork(req: Request): Promise<Response> {
const url = this.getConfigUrl(req.url); const url = this.adapter.normalizeUrl(req.url);
// If a hash is available for this resource, then compare the fetched version with the // If a hash is available for this resource, then compare the fetched version with the
// canonical hash. Otherwise, the network version will have to be trusted. // canonical hash. Otherwise, the network version will have to be trusted.
@ -456,7 +437,7 @@ export abstract class AssetGroup {
*/ */
protected async maybeUpdate(updateFrom: UpdateSource, req: Request, cache: Cache): protected async maybeUpdate(updateFrom: UpdateSource, req: Request, cache: Cache):
Promise<boolean> { Promise<boolean> {
const url = this.getConfigUrl(req.url); const url = this.adapter.normalizeUrl(req.url);
const meta = await this.metadata; const meta = await this.metadata;
// Check if this resource is hashed and already exists in the cache of a prior version. // Check if this resource is hashed and already exists in the cache of a prior version.
if (this.hashes.has(url)) { if (this.hashes.has(url)) {
@ -546,7 +527,7 @@ export class PrefetchAssetGroup extends AssetGroup {
// First, narrow down the set of resources to those which are handled by this group. // 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. // Either it's a known URL, or it matches a given pattern.
.filter( .filter(
url => this.config.urls.some(cacheUrl => cacheUrl === url) || url => this.config.urls.indexOf(url) !== -1 ||
this.patterns.some(pattern => pattern.test(url))) this.patterns.some(pattern => pattern.test(url)))
// Finally, process each resource in turn. // Finally, process each resource in turn.
.reduce(async (previous, url) => { .reduce(async (previous, url) => {

View File

@ -81,8 +81,7 @@ export class MockClients implements Clients {
async claim(): Promise<any> {} async claim(): Promise<any> {}
} }
export class SwTestHarness implements ServiceWorkerGlobalScope, Adapter, Context { export class SwTestHarness extends Adapter implements ServiceWorkerGlobalScope, Context {
readonly cacheNamePrefix: string;
readonly clients = new MockClients(); readonly clients = new MockClients();
private eventHandlers = new Map<string, Function>(); private eventHandlers = new Map<string, Function>();
private skippedWaiting = true; private skippedWaiting = true;
@ -98,7 +97,7 @@ export class SwTestHarness implements ServiceWorkerGlobalScope, Adapter, Context
this.selfMessageQueue.push(msg); this.selfMessageQueue.push(msg);
}, },
}, },
scope: this.origin, scope: this.scopeUrl,
showNotification: showNotification:
(title: string, options: Object) => { (title: string, options: Object) => {
this.notifications.push({title, options}); this.notifications.push({title, options});
@ -125,7 +124,11 @@ export class SwTestHarness implements ServiceWorkerGlobalScope, Adapter, Context
return url && (typeof url.parse === 'function') && (typeof url.resolve === 'function'); return url && (typeof url.parse === 'function') && (typeof url.resolve === 'function');
} }
time: number; get time() {
return this.mockTime;
}
private mockTime = Date.now();
private timers: { private timers: {
at: number, at: number,
@ -135,10 +138,8 @@ export class SwTestHarness implements ServiceWorkerGlobalScope, Adapter, Context
}[] = []; }[] = [];
constructor( constructor(
private server: MockServerState, readonly caches: MockCacheStorage, private origin: string) { private server: MockServerState, readonly caches: MockCacheStorage, scopeUrl: string) {
const baseHref = this.parseUrl(origin).path; super(scopeUrl);
this.cacheNamePrefix = 'ngsw:' + baseHref;
this.time = Date.now();
} }
async resolveSelfMessages(): Promise<void> { async resolveSelfMessages(): Promise<void> {
@ -170,6 +171,7 @@ export class SwTestHarness implements ServiceWorkerGlobalScope, Adapter, Context
} }
return skippedWaiting; return skippedWaiting;
} }
updateServerState(server?: MockServerState): void { updateServerState(server?: MockServerState): void {
this.server = server || EMPTY_SERVER_STATE; this.server = server || EMPTY_SERVER_STATE;
} }
@ -281,7 +283,7 @@ export class SwTestHarness implements ServiceWorkerGlobalScope, Adapter, Context
timeout(ms: number): Promise<void> { timeout(ms: number): Promise<void> {
const promise = new Promise<void>(resolve => { const promise = new Promise<void>(resolve => {
this.timers.push({ this.timers.push({
at: this.time + ms, at: this.mockTime + ms,
duration: ms, duration: ms,
fn: resolve, fn: resolve,
fired: false, fired: false,
@ -296,9 +298,9 @@ export class SwTestHarness implements ServiceWorkerGlobalScope, Adapter, Context
} }
advance(by: number): void { advance(by: number): void {
this.time += by; this.mockTime += by;
this.timers.filter(timer => !timer.fired) this.timers.filter(timer => !timer.fired)
.filter(timer => timer.at <= this.time) .filter(timer => timer.at <= this.mockTime)
.forEach(timer => { .forEach(timer => {
timer.fired = true; timer.fired = true;
timer.fn(); timer.fn();