diff --git a/packages/service-worker/cli/filesystem.ts b/packages/service-worker/cli/filesystem.ts index eebca21884..51d910afe5 100644 --- a/packages/service-worker/cli/filesystem.ts +++ b/packages/service-worker/cli/filesystem.ts @@ -8,6 +8,8 @@ import {Filesystem} from '@angular/service-worker/config'; +import {sha1Binary} from './sha1'; + const fs = require('fs'); const path = require('path'); @@ -33,6 +35,12 @@ export class NodeFilesystem implements Filesystem { return fs.readFileSync(file).toString(); } + async hash(_path: string): Promise { + const file = this.canonical(_path); + const contents: Buffer = fs.readFileSync(file); + return sha1Binary(contents as any as ArrayBuffer); + } + async write(_path: string, contents: string): Promise { const file = this.canonical(_path); fs.writeFileSync(file, contents); diff --git a/packages/service-worker/config/src/sha1.ts b/packages/service-worker/cli/sha1.ts similarity index 85% rename from packages/service-worker/config/src/sha1.ts rename to packages/service-worker/cli/sha1.ts index 48a2f90cf6..a01f0e744e 100644 --- a/packages/service-worker/config/src/sha1.ts +++ b/packages/service-worker/cli/sha1.ts @@ -16,11 +16,19 @@ * * Borrowed from @angular/compiler/src/i18n/digest.ts */ + export function sha1(str: string): string { const utf8 = str; const words32 = stringToWords32(utf8, Endian.Big); - const len = utf8.length * 8; + return _sha1(words32, utf8.length * 8); +} +export function sha1Binary(buffer: ArrayBuffer): string { + const words32 = arrayBufferToWords32(buffer, Endian.Big); + return _sha1(words32, buffer.byteLength * 8); +} + +function _sha1(words32: number[], len: number): string { const w = new Array(80); let [a, b, c, d, e]: number[] = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0]; @@ -114,11 +122,24 @@ function stringToWords32(str: string, endian: Endian): number[] { return words32; } -function byteAt(str: string, index: number): number { - return index >= str.length ? 0 : str.charCodeAt(index) & 0xff; +function arrayBufferToWords32(buffer: ArrayBuffer, endian: Endian): number[] { + const words32 = Array((buffer.byteLength + 3) >>> 2); + const view = new Uint8Array(buffer); + for (let i = 0; i < words32.length; i++) { + words32[i] = wordAt(view, i * 4, endian); + } + return words32; } -function wordAt(str: string, index: number, endian: Endian): number { +function byteAt(str: string | Uint8Array, index: number): number { + if (typeof str === 'string') { + return index >= str.length ? 0 : str.charCodeAt(index) & 0xff; + } else { + return index >= str.byteLength ? 0 : str[index] & 0xff; + } +} + +function wordAt(str: string | Uint8Array, index: number, endian: Endian): number { let word = 0; if (endian === Endian.Big) { for (let i = 0; i < 4; i++) { diff --git a/packages/service-worker/config/src/filesystem.ts b/packages/service-worker/config/src/filesystem.ts index 87ee90d39c..7138d2ef03 100644 --- a/packages/service-worker/config/src/filesystem.ts +++ b/packages/service-worker/config/src/filesystem.ts @@ -15,5 +15,6 @@ export interface Filesystem { list(dir: string): Promise; read(file: string): Promise; + hash(file: string): Promise; write(file: string, contents: string): Promise; } \ No newline at end of file diff --git a/packages/service-worker/config/src/generator.ts b/packages/service-worker/config/src/generator.ts index 6ce2be10d9..cabd67f491 100644 --- a/packages/service-worker/config/src/generator.ts +++ b/packages/service-worker/config/src/generator.ts @@ -10,7 +10,6 @@ import {parseDurationToMs} from './duration'; import {Filesystem} from './filesystem'; import {globToRegex} from './glob'; import {Config} from './in'; -import {sha1} from './sha1'; /** * Consumes service worker configuration files and processes them into control files. @@ -49,7 +48,7 @@ export class Generator { // Add the hashes. await plainFiles.reduce(async(previous, file) => { await previous; - const hash = sha1(await this.fs.read(file)); + const hash = await this.fs.hash(file); hashTable[joinUrls(this.baseHref, file)] = hash; }, Promise.resolve()); diff --git a/packages/service-worker/config/src/glob.ts b/packages/service-worker/config/src/glob.ts index 512e6af93f..802189d09e 100644 --- a/packages/service-worker/config/src/glob.ts +++ b/packages/service-worker/config/src/glob.ts @@ -9,6 +9,13 @@ const WILD_SINGLE = '[^\\/]+'; const WILD_OPEN = '(?:.+\\/)?'; +const TO_ESCAPE = [ + {replace: /\./g, with: '\\.'}, + {replace: /\?/g, with: '\\?'}, + {replace: /\+/g, with: '\\+'}, + {replace: /\*/g, with: WILD_SINGLE}, +]; + export function globToRegex(glob: string): string { const segments = glob.split('/').reverse(); let regex: string = ''; @@ -20,9 +27,9 @@ export function globToRegex(glob: string): string { } else { regex += '.*'; } - continue; } else { - const processed = segment.replace(/\./g, '\\.').replace(/\*/g, WILD_SINGLE); + const processed = TO_ESCAPE.reduce( + (segment, escape) => segment.replace(escape.replace, escape.with), segment); regex += processed; if (segments.length > 0) { regex += '\\/'; diff --git a/packages/service-worker/config/test/generator_spec.ts b/packages/service-worker/config/test/generator_spec.ts index 7c590ed9be..b03dd62cda 100644 --- a/packages/service-worker/config/test/generator_spec.ts +++ b/packages/service-worker/config/test/generator_spec.ts @@ -33,6 +33,7 @@ export function main() { versionedFiles: [], urls: [ '/absolute/**', + '/some/url?with+escaped+chars', 'relative/*.txt', ] } @@ -62,7 +63,11 @@ export function main() { 'installMode': 'prefetch', 'updateMode': 'prefetch', 'urls': ['/test/index.html', '/test/foo/test.html'], - 'patterns': ['\\/absolute\\/.*', '\\/test\\/relative\\/[^\\/]+\\.txt'] + 'patterns': [ + '\\/absolute\\/.*', + '\\/some\\/url\\?with\\+escaped\\+chars', + '\\/test\\/relative\\/[^\\/]+\\.txt', + ] }], 'dataGroups': [{ 'name': 'other', diff --git a/packages/service-worker/config/testing/mock.ts b/packages/service-worker/config/testing/mock.ts index 1718ac05df..fe1af98965 100644 --- a/packages/service-worker/config/testing/mock.ts +++ b/packages/service-worker/config/testing/mock.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {sha1} from '../../cli/sha1'; import {Filesystem} from '../src/filesystem'; export class MockFilesystem implements Filesystem { @@ -21,5 +22,7 @@ export class MockFilesystem implements Filesystem { async read(path: string): Promise { return this.files.get(path) !; } + async hash(path: string): Promise { return sha1(this.files.get(path) !); } + async write(path: string, contents: string): Promise { this.files.set(path, contents); } } \ No newline at end of file diff --git a/packages/service-worker/worker/src/adapter.ts b/packages/service-worker/worker/src/adapter.ts index f873fbe744..dfd30be040 100644 --- a/packages/service-worker/worker/src/adapter.ts +++ b/packages/service-worker/worker/src/adapter.ts @@ -43,9 +43,9 @@ export class Adapter { /** * Extract the pathname of a URL. */ - getPath(url: string): string { - const parsed = new URL(url); - return parsed.pathname; + parseUrl(url: string, relativeTo: string): {origin: string, path: string} { + const parsed = new URL(url, relativeTo); + return {origin: parsed.origin, path: parsed.pathname}; } /** diff --git a/packages/service-worker/worker/src/api.ts b/packages/service-worker/worker/src/api.ts index ff8ba7b7be..9ed5157725 100644 --- a/packages/service-worker/worker/src/api.ts +++ b/packages/service-worker/worker/src/api.ts @@ -82,6 +82,8 @@ export interface CacheState { metadata?: UrlMetadata; } +export interface DebugLogger { log(value: string|Error, context?: string): void; } + export interface DebugState { state: string; why: string; diff --git a/packages/service-worker/worker/src/app-version.ts b/packages/service-worker/worker/src/app-version.ts index d587ff9dcf..0ec3f50f0f 100644 --- a/packages/service-worker/worker/src/app-version.ts +++ b/packages/service-worker/worker/src/app-version.ts @@ -74,11 +74,11 @@ export class AppVersion implements UpdateSource { }); // Process each `DataGroup` declared in the manifest. - this.dataGroups = - (manifest.dataGroups || []) - .map( - config => new DataGroup( - this.scope, this.adapter, config, this.database, `${config.version}:data`)); + this.dataGroups = (manifest.dataGroups || []) + .map( + config => new DataGroup( + this.scope, this.adapter, config, this.database, + `ngsw:${config.version}:data`)); } /** @@ -88,13 +88,13 @@ export class AppVersion implements UpdateSource { */ async initializeFully(updateFrom?: UpdateSource): Promise { try { - // Fully initialize each asset group, in series. Starts with an empty Promise, and waits for - // the previous - // groups to have been initialized before initializing the next one in turn. + // Fully initialize each asset group, in series. Starts with an empty Promise, + // and waits for the previous groups to have been initialized before initializing + // the next one in turn. await this.assetGroups.reduce>(async(previous, group) => { - // Wait for the previous groups to complete initialization. If there is a failure, this will - // throw, and - // each subsequent group will throw, until the whole sequence fails. + // Wait for the previous groups to complete initialization. If there is a + // failure, this will throw, and each subsequent group will throw, until the + // whole sequence fails. await previous; // Initialize this group. @@ -151,7 +151,8 @@ 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.adapter) && req.url !== this.manifest.index) { + if (isNavigationRequest(req, this.scope.registration.scope, this.adapter) && + req.url !== this.manifest.index) { // This was a navigation request. Re-enter `handleFetch` with a request for // the URL. return this.handleFetch(this.adapter.newRequest(this.manifest.index), context); diff --git a/packages/service-worker/worker/src/assets.ts b/packages/service-worker/worker/src/assets.ts index 4955cf2c02..aaaf9f399e 100644 --- a/packages/service-worker/worker/src/assets.ts +++ b/packages/service-worker/worker/src/assets.ts @@ -11,7 +11,7 @@ import {CacheState, UpdateCacheStatus, UpdateSource, UrlMetadata} from './api'; import {Database, Table} from './database'; import {IdleScheduler} from './idle'; import {AssetGroupConfig} from './manifest'; -import {sha1} from './sha1'; +import {sha1Binary} from './sha1'; /** * A group of assets that are cached in a `Cache` and managed by a given policy. @@ -46,6 +46,9 @@ export abstract class AssetGroup { */ protected metadata: Promise; + + private origin: string; + constructor( protected scope: ServiceWorkerGlobalScope, protected adapter: Adapter, protected idle: IdleScheduler, protected config: AssetGroupConfig, @@ -62,6 +65,11 @@ export abstract class AssetGroup { // 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`); + + // 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, this.scope.registration.scope).origin; } async cacheStatus(url: string): Promise { @@ -99,11 +107,11 @@ export abstract class AssetGroup { * 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 { + const url = this.getConfigUrl(req.url); // 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(req.url) !== -1 || - this.patterns.some(pattern => pattern.test(req.url))) { + if (this.config.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. @@ -116,7 +124,7 @@ export abstract class AssetGroup { if (cachedResponse !== undefined) { // A response has already been cached (which presumably matches the hash for this // resource). Check whether it's safe to serve this resource from cache. - if (this.hashes.has(req.url)) { + if (this.hashes.has(url)) { // This resource has a hash, and thus is versioned by the manifest. It's safe to return // the response. return cachedResponse; @@ -133,8 +141,10 @@ export abstract class AssetGroup { return cachedResponse; } } - // No already-cached response exists, so attempt a fetch/cache operation. - const res = await this.fetchAndCacheOnce(req); + // No already-cached response exists, so attempt a fetch/cache operation. The original request + // may specify things like credential inclusion, but for assets these are not honored in order + // to avoid issues with opaque responses. The SW requests the data itself. + const res = await this.fetchAndCacheOnce(this.adapter.newRequest(req.url)); // If this is successful, the response needs to be cloned as it might be used to respond to // multiple fetch operations at the same time. @@ -144,6 +154,18 @@ 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 * by HTTP caching headers. Check whether the given request/response pair is still valid @@ -271,7 +293,6 @@ export abstract class AssetGroup { return this.inFlightRequests.get(req.url) !; } - // No other caching operation is being attempted for this resource, so it will be owned here. // Go to the network and get the correct version. const fetchOp = this.fetchFromNetwork(req); @@ -315,16 +336,37 @@ export abstract class AssetGroup { } } + protected async fetchFromNetwork(req: Request, redirectLimit: number = 3): Promise { + // Make a cache-busted request for the resource. + const res = await this.cacheBustedFetchFromNetwork(req); + + // Check for redirected responses, and follow the redirects. + if ((res as any)['redirected'] && !!res.url) { + // If the redirect limit is exhausted, fail with an error. + if (redirectLimit === 0) { + throw new Error( + `Response hit redirect limit (fetchFromNetwork): request redirected too many times, next is ${res.url}`); + } + + // Unwrap the redirect directly. + return this.fetchFromNetwork(this.adapter.newRequest(res.url), redirectLimit - 1); + } + + return res; + } + /** * Load a particular asset from the network, accounting for hash validation. */ - protected async fetchFromNetwork(req: Request): Promise { + protected async cacheBustedFetchFromNetwork(req: Request): Promise { + const url = this.getConfigUrl(req.url); + // 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. - if (this.hashes.has(req.url)) { + if (this.hashes.has(url)) { // It turns out this resource does have a hash. Look it up. Unless the fetched version // matches this hash, it's invalid and the whole manifest may need to be thrown out. - const canonicalHash = this.hashes.get(req.url) !; + const canonicalHash = this.hashes.get(url) !; // Ideally, the resource would be requested with cache-busting to guarantee the SW gets // the freshest version. However, doing this would eliminate any chance of the response @@ -339,7 +381,7 @@ export abstract class AssetGroup { // a stale response. // Fetch the resource from the network (possibly hitting the HTTP cache). - const networkResult = await this.scope.fetch(req); + const networkResult = await this.safeFetch(req); // Decide whether a cache-busted request is necessary. It might be for two independent // reasons: either the non-cache-busted request failed (hopefully transiently) or if the @@ -350,7 +392,7 @@ export abstract class AssetGroup { // The request was successful. A cache-busted request is only necessary if the hashes // don't match. Compare them, making sure to clone the response so it can be used later // if it proves to be valid. - const fetchedHash = sha1(await networkResult.clone().text()); + const fetchedHash = sha1Binary(await networkResult.clone().arrayBuffer()); makeCacheBustedRequest = (fetchedHash !== canonicalHash); } @@ -361,22 +403,23 @@ export abstract class AssetGroup { // data, or because the version on the server really doesn't match. A cache-busting // request will differentiate these two situations. // TODO: handle case where the URL has parameters already (unlikely for assets). - const cacheBustedResult = await this.scope.fetch(this.cacheBust(req.url)); + const cacheBustReq = this.adapter.newRequest(this.cacheBust(req.url)); + const cacheBustedResult = await this.safeFetch(cacheBustReq); // If the response was unsuccessful, there's nothing more that can be done. if (!cacheBustedResult.ok) { throw new Error( - `Response not Ok (fetchFromNetwork): cache busted request for ${req.url} returned response ${cacheBustedResult.status} ${cacheBustedResult.statusText}`); + `Response not Ok (cacheBustedFetchFromNetwork): cache busted request for ${req.url} returned response ${cacheBustedResult.status} ${cacheBustedResult.statusText}`); } // Hash the contents. - const cacheBustedHash = sha1(await cacheBustedResult.clone().text()); + const cacheBustedHash = sha1Binary(await cacheBustedResult.clone().arrayBuffer()); // If the cache-busted version doesn't match, then the manifest is not an accurate // representation of the server's current set of files, and the SW should give up. if (canonicalHash !== cacheBustedHash) { throw new Error( - `Hash mismatch (${req.url}): expected ${canonicalHash}, got ${cacheBustedHash} (after cache busting)`); + `Hash mismatch (cacheBustedFetchFromNetwork): ${req.url}: expected ${canonicalHash}, got ${cacheBustedHash} (after cache busting)`); } // If it does match, then use the cache-busted result. @@ -388,7 +431,7 @@ export abstract class AssetGroup { return networkResult; } else { // This URL doesn't exist in our hash database, so it must be requested directly. - return this.scope.fetch(req); + return this.safeFetch(req); } } @@ -397,14 +440,15 @@ export abstract class AssetGroup { */ protected async maybeUpdate(updateFrom: UpdateSource, req: Request, cache: Cache): Promise { + const url = this.getConfigUrl(req.url); const meta = await this.metadata; // Check if this resource is hashed and already exists in the cache of a prior version. - if (this.hashes.has(req.url)) { - const hash = this.hashes.get(req.url) !; + if (this.hashes.has(url)) { + const hash = this.hashes.get(url) !; // Check the caches of prior versions, using the hash to ensure the correct version of // the resource is loaded. - const res = await updateFrom.lookupResourceWithHash(req.url, hash); + const res = await updateFrom.lookupResourceWithHash(url, hash); // If a previously cached version was available, copy it over to this cache. if (res !== null) { @@ -427,6 +471,17 @@ export abstract class AssetGroup { private cacheBust(url: string): string { return url + (url.indexOf('?') === -1 ? '?' : '&') + 'ngsw-cache-bust=' + Math.random(); } + + protected async safeFetch(req: Request): Promise { + try { + return await this.scope.fetch(req); + } catch (err) { + return this.adapter.newResponse('', { + status: 504, + statusText: 'Gateway Timeout', + }); + } + } } /** diff --git a/packages/service-worker/worker/src/data.ts b/packages/service-worker/worker/src/data.ts index af6834892d..2fce467d8f 100644 --- a/packages/service-worker/worker/src/data.ts +++ b/packages/service-worker/worker/src/data.ts @@ -332,7 +332,7 @@ export class DataGroup { await this.syncLru(); // Finally, fall back on the network. - return this.scope.fetch(req); + return this.safeFetch(req); } } @@ -347,7 +347,7 @@ export class DataGroup { res = fromCache.res; // Check the age of the resource. if (this.config.refreshAheadMs !== undefined && fromCache.age >= this.config.refreshAheadMs) { - ctx.waitUntil(this.safeCacheResponse(req, this.scope.fetch(req))); + ctx.waitUntil(this.safeCacheResponse(req, this.safeFetch(req))); } } @@ -414,17 +414,34 @@ export class DataGroup { } private networkFetchWithTimeout(req: Request): [Promise, Promise] { - const networkFetch = this.scope.fetch(req); - // If there is a timeout configured, race a timeout Promise with the network fetch. // Otherwise, just fetch from the network directly. if (this.config.timeoutMs !== undefined) { + const networkFetch = this.scope.fetch(req); + const safeNetworkFetch = (async() => { + try { + return await networkFetch; + } catch (err) { + return this.adapter.newResponse(null, { + status: 504, + statusText: 'Gateway Timeout', + }); + } + })(); + const networkFetchUndefinedError = (async() => { + try { + return await networkFetch; + } catch (err) { + return undefined; + } + })(); // Construct a Promise for the timeout. const timeout = this.adapter.timeout(this.config.timeoutMs) as Promise; - // Race that with the network fetch. This will either be a Response, an error, or - // `undefined` in the event that the request times out. - return [Promise.race([networkFetch, timeout]), networkFetch]; + // Race that with the network fetch. This will either be a Response, or `undefined` + // in the event that the request errored or timed out. + return [Promise.race([networkFetchUndefinedError, timeout]), safeNetworkFetch]; } else { + const networkFetch = this.safeFetch(req); // Do a plain fetch. return [networkFetch, networkFetch]; } @@ -538,4 +555,15 @@ export class DataGroup { ageTable.delete(url), ]); } + + private async safeFetch(req: Request): Promise { + try { + return this.scope.fetch(req); + } catch (err) { + return this.adapter.newResponse(null, { + status: 504, + statusText: 'Gateway Timeout', + }); + } + } } diff --git a/packages/service-worker/worker/src/debug.ts b/packages/service-worker/worker/src/debug.ts index f4bcfe712c..9843ef4090 100644 --- a/packages/service-worker/worker/src/debug.ts +++ b/packages/service-worker/worker/src/debug.ts @@ -7,9 +7,25 @@ */ import {Adapter} from './adapter'; -import {Debuggable} from './api'; +import {DebugLogger, Debuggable} from './api'; + +const DEBUG_LOG_BUFFER_SIZE = 100; + +interface DebugMessage { + time: number; + value: string; + context: string; +} + +export class DebugHandler implements DebugLogger { + // There are two debug log message arrays. debugLogA records new debugging messages. + // Once it reaches DEBUG_LOG_BUFFER_SIZE, the array is moved to debugLogB and a new + // array is assigned to debugLogA. This ensures that insertion to the debug log is + // always O(1) no matter the number of logged messages, and that the total number + // of messages in the log never exceeds 2 * DEBUG_LOG_BUFFER_SIZE. + private debugLogA: DebugMessage[] = []; + private debugLogB: DebugMessage[] = []; -export class DebugHandler { constructor(readonly driver: Debuggable, readonly adapter: Adapter) {} async handleFetch(req: Request): Promise { @@ -36,6 +52,10 @@ Last update tick: ${this.since(idle.lastTrigger)} Last update run: ${this.since(idle.lastRun)} Task queue: ${idle.queue.map(v => ' * ' + v).join('\n')} + +Debug log: +${this.formatDebugLog(this.debugLogB)} +${this.formatDebugLog(this.debugLogA)} `; return this.adapter.newResponse( @@ -65,4 +85,27 @@ ${msgIdle}`, (minutes > 0 ? `${minutes}m` : '') + (seconds > 0 ? `${seconds}s` : '') + (millis > 0 ? `${millis}u` : ''); } + + log(value: string|Error, context: string = ''): void { + // Rotate the buffers if debugLogA has grown too large. + if (this.debugLogA.length === DEBUG_LOG_BUFFER_SIZE) { + this.debugLogB = this.debugLogA; + this.debugLogA = []; + } + + // Convert errors to string for logging. + if (typeof value !== 'string') { + value = this.errorToString(value); + } + + // Log the message. + this.debugLogA.push({value, time: this.adapter.time, context}); + } + + private errorToString(err: Error): string { return `${err.name}(${err.message}, ${err.stack})`; } + + private formatDebugLog(log: DebugMessage[]): string { + return log.map(entry => `[${this.since(entry.time)}] ${entry.value} ${entry.context}`) + .join('\n'); + } } \ No newline at end of file diff --git a/packages/service-worker/worker/src/driver.ts b/packages/service-worker/worker/src/driver.ts index 7b910dc19f..7837b45d89 100644 --- a/packages/service-worker/worker/src/driver.ts +++ b/packages/service-worker/worker/src/driver.ts @@ -25,7 +25,7 @@ type ClientAssignments = { [id: string]: ManifestHash }; -const SYNC_THRESHOLD = 5000; +const IDLE_THRESHOLD = 5000; const SUPPORTED_CONFIG_VERSION = 1; @@ -42,36 +42,33 @@ enum DriverReadyState { // The SW is operating in a normal mode, responding to all traffic. NORMAL, - // The SW does not have a clean installation of the latest version of the app, but older cached - // versions - // are safe to use so long as they don't try to fetch new dependencies. This is a degraded state. + // The SW does not have a clean installation of the latest version of the app, but older + // cached versions are safe to use so long as they don't try to fetch new dependencies. + // This is a degraded state. EXISTING_CLIENTS_ONLY, - // The SW has decided that caching is completely unreliable, and is forgoing request handling - // until the - // next restart. + // The SW has decided that caching is completely unreliable, and is forgoing request + // handling until the next restart. SAFE_MODE, } export class Driver implements Debuggable, UpdateSource { /** - * Tracks the current readiness condition under which the SW is operating. This controls whether - * the SW - * attempts to respond to some or all requests. + * Tracks the current readiness condition under which the SW is operating. This controls + * whether the SW attempts to respond to some or all requests. */ private state: DriverReadyState = DriverReadyState.NORMAL; private stateMessage: string = '(nominal)'; /** - * Tracks whether the SW is in an initialized state or not. Before initialization, it's not legal - * to - * respond to requests. + * Tracks whether the SW is in an initialized state or not. Before initialization, + * it's not legal to respond to requests. */ initialized: Promise|null = null; /** - * Maps client IDs to the manifest hash of the application version being used to serve them. If a - * client ID is not present here, it has not yet been assigned a version. + * Maps client IDs to the manifest hash of the application version being used to serve + * them. If a client ID is not present here, it has not yet been assigned a version. * * If a ManifestHash appears here, it is also present in the `versions` map below. */ @@ -92,8 +89,8 @@ export class Driver implements Debuggable, UpdateSource { private lastUpdateCheck: number|null = null; /** - * A scheduler which manages a queue of tasks that need to be executed when the SW is not doing - * any other work (not processing any other requests). + * A scheduler which manages a queue of tasks that need to be executed when the SW is + * not doing any other work (not processing any other requests). */ idle: IdleScheduler; @@ -101,75 +98,132 @@ export class Driver implements Debuggable, UpdateSource { constructor( private scope: ServiceWorkerGlobalScope, private adapter: Adapter, private db: Database) { - // Listen to fetch events. - this.scope.addEventListener( - 'install', (event) => { event !.waitUntil(this.scope.skipWaiting()); }); + // Set up all the event handlers that the SW needs. + + // The install event is triggered when the service worker is first installed. + this.scope.addEventListener('install', (event) => { + // SW code updates are separate from application updates, so code updates are + // almost as straightforward as restarting the SW. Because of this, it's always + // safe to skip waiting until application tabs are closed, and activate the new + // SW version immediately. + event !.waitUntil(this.scope.skipWaiting()); + }); + + // The activate event is triggered when this version of the service worker is + // first activated. this.scope.addEventListener('activate', (event) => { + // As above, it's safe to take over from existing clients immediately, since + // the new SW version will continue to serve the old application. event !.waitUntil(this.scope.clients.claim()); + + // Rather than wait for the first fetch event, which may not arrive until + // the next time the application is loaded, the SW takes advantage of the + // activation event to schedule initialization. However, if this were run + // in the context of the 'activate' event, waitUntil() here would cause fetch + // events to block until initialization completed. Thus, the SW does a + // postMessage() to itself, to schedule a new event loop iteration with an + // entirely separate event context. The SW will be kept alive by waitUntil() + // within that separate context while initialization proceeds, while at the + // same time the activation event is allowed to resolve and traffic starts + // being served. if (this.scope.registration.active !== null) { this.scope.registration.active.postMessage({action: 'INITIALIZE'}); } }); + + // Handle the fetch, message, and push events. this.scope.addEventListener('fetch', (event) => this.onFetch(event !)); this.scope.addEventListener('message', (event) => this.onMessage(event !)); this.scope.addEventListener('push', (event) => this.onPush(event !)); - this.idle = new IdleScheduler(this.adapter, SYNC_THRESHOLD); + // The debugger generates debug pages in response to debugging requests. this.debugger = new DebugHandler(this, this.adapter); + + // The IdleScheduler will execute idle tasks after a given delay. + this.idle = new IdleScheduler(this.adapter, IDLE_THRESHOLD, this.debugger); } + /** + * The handler for fetch events. + * + * This is the transition point between the synchronous event handler and the + * asynchronous execution that eventually resolves for respondWith() and waitUntil(). + */ private onFetch(event: FetchEvent): void { // The only thing that is served unconditionally is the debug page. - if (this.adapter.getPath(event.request.url) === '/ngsw/state') { + if (this.adapter.parseUrl(event.request.url, this.scope.registration.scope).path === + '/ngsw/state') { + // Allow the debugger to handle the request, but don't affect SW state in any + // other way. event.respondWith(this.debugger.handleFetch(event.request)); return; } - // If the SW is in a broken state where it's not safe to handle requests at all, returning - // causes the request to fall back on the network. This is preferred over - // `respondWith(fetch(req))` because the latter still shows in DevTools that the request - // was handled by the SW. + // If the SW is in a broken state where it's not safe to handle requests at all, + // returning causes the request to fall back on the network. This is preferred over + // `respondWith(fetch(req))` because the latter still shows in DevTools that the + // request was handled by the SW. // TODO: try to handle DriverReadyState.EXISTING_CLIENTS_ONLY here. if (this.state === DriverReadyState.SAFE_MODE) { - // Even though the worker is in safe mode, idle tasks still need to happen so things - // like update checks, etc. can take place. + // Even though the worker is in safe mode, idle tasks still need to happen so + // things like update checks, etc. can take place. event.waitUntil(this.idle.trigger()); return; } - // Past this point, the SW commits to handling the request itself. This could still fail (and - // result in `state` being set to `SAFE_MODE`), but even in that case the SW will still deliver - // a response. + // Past this point, the SW commits to handling the request itself. This could still + // fail (and result in `state` being set to `SAFE_MODE`), but even in that case the + // SW will still deliver a response. event.respondWith(this.handleFetch(event)); } + /** + * The handler for message events. + */ private onMessage(event: ExtendableMessageEvent): void { + // Ignore message events when the SW is in safe mode, for now. if (this.state === DriverReadyState.SAFE_MODE) { return; } + // If the message doesn't have the expected signature, ignore it. const data = event.data; if (!data || !data.action) { return; } + // Initialization is the only event which is sent directly from the SW to itself, + // and thus `event.source` is not a Client. Handle it here, before the check + // for Client sources. if (data.action === 'INITIALIZE' && this.initialized === null) { + // Initialize the SW. this.initialized = this.initialize(); - event.waitUntil(this.initialized); - event.waitUntil(this.idle.trigger()); - return; + + // Wait until initialization is properly scheduled, then trigger idle + // events to allow it to complete (assuming the SW is idle). + event.waitUntil((async() => { + await this.initialized; + await this.idle.trigger(); + })()); } + // Only messages from true clients are accepted past this point (this is essentially + // a typecast). if (!this.adapter.isClient(event.source)) { return; } + + // Handle the message and keep the SW alive until it's handled. event.waitUntil(this.handleMessage(data, event.source)); } private onPush(msg: PushEvent): void { + // Push notifications without data have no effect. if (!msg.data) { return; } + + // Handle the push and keep the SW alive until it's handled. msg.waitUntil(this.handlePush(msg.data)); } @@ -212,7 +266,8 @@ export class Driver implements Debuggable, UpdateSource { } async updateClient(client: Client): Promise { - // Figure out which version the client is on. If it's not on the latest, it needs to be moved. + // Figure out which version the client is on. If it's not on the latest, + // it needs to be moved. const existing = this.clientVersionMap.get(client.id); if (existing === this.latestHash) { // Nothing to do, this client is already on the latest version. @@ -222,14 +277,14 @@ export class Driver implements Debuggable, UpdateSource { // Switch the client over. let previous: Object|undefined = undefined; - // Look up the application data associated with the existing version. If there isn't any, - // fall back on using the hash. + // Look up the application data associated with the existing version. If there + // isn't any, fall back on using the hash. if (existing !== undefined) { const existingVersion = this.versions.get(existing) !; previous = this.mergeHashWithAppData(existingVersion.manifest, existing); } - // Set the current version used by the client, and + // Set the current version used by the client, and sync the mapping to disk. this.clientVersionMap.set(client.id, this.latestHash !); await this.sync(); @@ -269,7 +324,7 @@ export class Driver implements Debuggable, UpdateSource { // Since the SW is already committed to responding to the currently active request, // respond with a network fetch. - return this.scope.fetch(event.request); + return this.safeFetch(event.request); } // Decide which version of the app to use to serve this request. This is asynchronous as in @@ -279,7 +334,7 @@ export class Driver implements Debuggable, UpdateSource { // Bail out if (appVersion === null) { event.waitUntil(this.idle.trigger()); - return this.scope.fetch(event.request); + return this.safeFetch(event.request); } // Handle the request. First try the AppVersion. If that doesn't work, fall back on the network. @@ -289,7 +344,7 @@ export class Driver implements Debuggable, UpdateSource { // request. In that case, just fall back on the network. if (res === null) { event.waitUntil(this.idle.trigger()); - return this.scope.fetch(event.request); + return this.safeFetch(event.request); } // Trigger the idle scheduling system. The Promise returned by trigger() will resolve after @@ -306,23 +361,21 @@ export class Driver implements Debuggable, UpdateSource { * Attempt to quickly reach a state where it's safe to serve responses. */ private async initialize(): Promise { - // On initialization, all of the serialized state is read out of the 'control' table. This - // includes: + // On initialization, all of the serialized state is read out of the 'control' + // table. This includes: // - map of hashes to manifests of currently loaded application versions // - map of client IDs to their pinned versions // - record of the most recently fetched manifest hash // - // If these values don't exist in the DB, then this is the either the first time the SW has run - // or - // the DB state has been wiped or is inconsistent. In that case, load a fresh copy of the - // manifest - // and reset the state from scratch. + // If these values don't exist in the DB, then this is the either the first time + // the SW has run or the DB state has been wiped or is inconsistent. In that case, + // load a fresh copy of the manifest and reset the state from scratch. // Open up the DB table. const table = await this.db.open('control'); - // Attempt to load the needed state from the DB. If this fails, the catch {} block will populate - // these variables with freshly constructed values. + // Attempt to load the needed state from the DB. If this fails, the catch {} block + // will populate these variables with freshly constructed values. let manifests: ManifestMap, assignments: ClientAssignments, latest: LatestEntry; try { // Read them from the DB simultaneously. @@ -332,16 +385,20 @@ export class Driver implements Debuggable, UpdateSource { table.read('latest'), ]); - // Successfully loaded from saved state. This implies a manifest exists, so the update check - // needs to happen in the background. + // Successfully loaded from saved state. This implies a manifest exists, so + // the update check needs to happen in the background. this.idle.schedule('init post-load (update, cleanup)', async() => { await this.checkForUpdate(); - await this.cleanupCaches(); + try { + await this.cleanupCaches(); + } catch (err) { + // Nothing to do - cleanup failed. Just log it. + this.debugger.log(err, 'cleanupCaches @ init post-load'); + } }); } catch (_) { - // Something went wrong. Try to start over by fetching a new manifest from the server and - // building - // up an empty initial state. + // Something went wrong. Try to start over by fetching a new manifest from the + // server and building up an empty initial state. const manifest = await this.fetchLatestManifest(); const hash = hashManifest(manifest); manifests = {}; @@ -357,49 +414,36 @@ export class Driver implements Debuggable, UpdateSource { ]); } - // At this point, either the state has been loaded successfully, or fresh state with a new copy - // of - // the manifest has been produced. At this point, the `Driver` can have its internals hydrated - // from - // the state. + // At this point, either the state has been loaded successfully, or fresh state + // with a new copy of the manifest has been produced. At this point, the `Driver` + // can have its internals hydrated from the state. - // Initialize the `versions` map by setting each hash to a new `AppVersion` instance for that - // manifest. + // Initialize the `versions` map by setting each hash to a new `AppVersion` instance + // for that manifest. Object.keys(manifests).forEach((hash: ManifestHash) => { const manifest = manifests[hash]; - // If the manifest is newly initialized, an AppVersion may have already been created for it. + // If the manifest is newly initialized, an AppVersion may have already been + // created for it. if (!this.versions.has(hash)) { this.versions.set( hash, new AppVersion(this.scope, this.adapter, this.db, this.idle, manifest, hash)); } }); - // Wait for the scheduling of initialization of all versions in the manifest. Ordinarily this - // just - // schedules the initializations to happen during the next idle period, but in development mode - // this might actually wait for the full initialization. - await Promise.all(Object.keys(manifests).map(async(hash: ManifestHash) => { - try { - // Attempt to schedule or initialize this version. If this operation is successful, then - // initialization either succeeded or was scheduled. If it fails, then full initialization - // was attempted and failed. - await this.scheduleInitialization(this.versions.get(hash) !); - } catch (err) { - return false; - } - })); - - // Map each client ID to its associated hash. Along the way, verify that the hash is still valid - // for that clinet ID. It should not be possible for a client to still be associated with a hash - // that was since removed from the state. + // Map each client ID to its associated hash. Along the way, verify that the hash + // is still valid for that client ID. It should not be possible for a client to + // still be associated with a hash that was since removed from the state. Object.keys(assignments).forEach((clientId: ClientId) => { const hash = assignments[clientId]; - if (!this.versions.has(hash)) { - throw new Error( - `Invariant violated (initialize): no manifest known for hash ${hash} active for client ${clientId}`); + if (this.versions.has(hash)) { + this.clientVersionMap.set(clientId, hash); + } else { + this.clientVersionMap.set(clientId, latest.latest); + this.debugger.log( + `Unknown version ${hash} mapped for client ${clientId}, using latest instead`, + `initialize: map assignments`); } - this.clientVersionMap.set(clientId, hash); }); // Set the latest version. @@ -410,6 +454,26 @@ export class Driver implements Debuggable, UpdateSource { throw new Error( `Invariant violated (initialize): latest hash ${latest.latest} has no known manifest`); } + + + + // Finally, wait for the scheduling of initialization of all versions in the + // manifest. Ordinarily this just schedules the initializations to happen during + // the next idle period, but in development mode this might actually wait for the + // full initialization. + // If any of these initializations fail, versionFailed() will be called either + // synchronously or asynchronously to handle the failure and re-map clients. + await Promise.all(Object.keys(manifests).map(async(hash: ManifestHash) => { + try { + // Attempt to schedule or initialize this version. If this operation is + // successful, then initialization either succeeded or was scheduled. If + // it fails, then full initialization was attempted and failed. + await this.scheduleInitialization(this.versions.get(hash) !); + } catch (err) { + this.debugger.log(err, `initialize: schedule init of ${hash}`); + return false; + } + })); } private lookupVersionByHash(hash: ManifestHash, debugName: string = 'lookupVersionByHash'): @@ -426,8 +490,8 @@ export class Driver implements Debuggable, UpdateSource { * Decide which version of the manifest to use for the event. */ private async assignVersion(event: FetchEvent): Promise { - // First, check whether the event has a client ID. If it does, the version may already be - // associated. + // First, check whether the event has a client ID. If it does, the version may + // already be associated. const clientId = event.clientId; if (clientId !== null) { // Check if there is an assigned client id. @@ -436,10 +500,10 @@ export class Driver implements Debuggable, UpdateSource { let hash = this.clientVersionMap.get(clientId) !; // 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. + // 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.adapter)) { + isNavigationRequest(event.request, this.scope.registration.scope, this.adapter)) { // Update this client to the latest version immediately. if (this.latestHash === null) { throw new Error(`Invariant violated (assignVersion): latestHash was null`); @@ -454,14 +518,16 @@ export class Driver implements Debuggable, UpdateSource { // TODO: make sure the version is valid. return this.lookupVersionByHash(hash, 'assignVersion'); } 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 that first. + // 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 + // that first. if (this.state !== DriverReadyState.NORMAL) { - // It's not safe to serve new clients in the current state. It's possible that this - // is an existing client which has not been mapped yet (see below) but even if that - // is the case, it's invalid to make an assignment to a known invalid version, even - // if that assignment was previously implicit. Return undefined here to let the - // caller know that no assignment is possible at this time. + // It's not safe to serve new clients in the current state. It's possible that + // this is an existing client which has not been mapped yet (see below) but + // even if that is the case, it's invalid to make an assignment to a known + // invalid version, even if that assignment was previously implicit. Return + // undefined here to let the caller know that no assignment is possible at + // this time. return null; } @@ -514,7 +580,8 @@ export class Driver implements Debuggable, UpdateSource { * Retrieve a copy of the latest manifest from the server. */ private async fetchLatestManifest(): Promise { - const res = await this.scope.fetch('/ngsw.json?ngsw-cache-bust=' + Math.random()); + const res = await this.safeFetch( + this.adapter.newRequest('/ngsw.json?ngsw-cache-bust=' + Math.random())); if (!res.ok) { if (res.status === 404) { await this.deleteAllCaches(); @@ -547,7 +614,8 @@ export class Driver implements Debuggable, UpdateSource { try { await appVersion.initializeFully(); } catch (err) { - this.versionFailed(appVersion, err); + this.debugger.log(err, `initializeFully for ${appVersion.manifestHash}`); + await this.versionFailed(appVersion, err); } }; // TODO: better logic for detecting localhost. @@ -557,7 +625,7 @@ export class Driver implements Debuggable, UpdateSource { this.idle.schedule(`initialization(${appVersion.manifestHash})`, initialize); } - private versionFailed(appVersion: AppVersion, err: Error): void { + private async versionFailed(appVersion: AppVersion, err: Error): Promise { // This particular AppVersion is broken. First, find the manifest hash. const broken = Array.from(this.versions.entries()).find(([hash, version]) => version === appVersion); @@ -573,8 +641,8 @@ export class Driver implements Debuggable, UpdateSource { // If so, the SW cannot accept new clients, but can continue to service old ones. if (this.latestHash === brokenHash) { // The latest manifest is broken. This means that new clients are at the mercy of the - // network, but caches continue to be valid for previous versions. This is unfortunate - // but unavoidable. + // network, but caches continue to be valid for previous versions. This is + // unfortunate but unavoidable. this.state = DriverReadyState.EXISTING_CLIENTS_ONLY; this.stateMessage = `Degraded due to failed initialization: ${errorToString(err)}`; @@ -582,15 +650,16 @@ export class Driver implements Debuggable, UpdateSource { Array.from(this.clientVersionMap.keys()) .forEach(clientId => this.clientVersionMap.delete(clientId)); } else { - // The current version is viable, but this older version isn't. The only possible remedy - // is to stop serving the older version and go to the network. Figure out which clients - // are affected and put them on the latest. + // The current version is viable, but this older version isn't. The only + // possible remedy is to stop serving the older version and go to the network. + // Figure out which clients are affected and put them on the latest. const affectedClients = Array.from(this.clientVersionMap.keys()) .filter(clientId => this.clientVersionMap.get(clientId) ! === brokenHash); // Push the affected clients onto the latest version. affectedClients.forEach(clientId => this.clientVersionMap.set(clientId, this.latestHash !)); } + await this.sync(); } private async setupUpdate(manifest: Manifest, hash: string): Promise { @@ -599,9 +668,9 @@ export class Driver implements Debuggable, UpdateSource { // Try to determine a version that's safe to update from. let updateFrom: AppVersion|undefined = undefined; - // It's always safe to update from a version, even a broken one, as it will still only have - // valid resources cached. If there is no latest version, though, this update will have to - // install as a fresh version. + // It's always safe to update from a version, even a broken one, as it will still + // only have valid resources cached. If there is no latest version, though, this + // update will have to install as a fresh version. if (this.latestHash !== null) { updateFrom = this.versions.get(this.latestHash); } @@ -614,8 +683,8 @@ export class Driver implements Debuggable, UpdateSource { `Invalid config version: expected ${SUPPORTED_CONFIG_VERSION}, got ${manifest.configVersion}.`); } - // Cause the new version to become fully initialized. If this fails, then the version will - // not be available for use. + // Cause the new version to become fully initialized. If this fails, then the + // version will not be available for use. await newVersion.initializeFully(this); // Install this as an active version of the app. @@ -636,6 +705,7 @@ export class Driver implements Debuggable, UpdateSource { if (this.versions.has(hash)) { return false; } + await this.setupUpdate(manifest, hash); return true; @@ -674,24 +744,23 @@ export class Driver implements Debuggable, UpdateSource { } async cleanupCaches(): Promise { - // Make sure internal state has been initialized before attempting to clean up caches. - await this.initialized; - - // Query for all currently active clients, and list the client ids. This may skip some - // clients in the browser back-forward cache, but not much can be done about that. + // Query for all currently active clients, and list the client ids. This may skip + // some clients in the browser back-forward cache, but not much can be done about + // that. const activeClients: ClientId[] = (await this.scope.clients.matchAll()).map(client => client.id); - // A simple list of client ids that the SW has kept track of. Subtracting activeClients - // from this list will result in the set of client ids which are being tracked but are no - // longer used in the browser, and thus can be cleaned up. + // A simple list of client ids that the SW has kept track of. Subtracting + // activeClients from this list will result in the set of client ids which are + // being tracked but are no longer used in the browser, and thus can be cleaned up. const knownClients: ClientId[] = Array.from(this.clientVersionMap.keys()); // Remove clients in the clientVersionMap that are no longer active. knownClients.filter(id => activeClients.indexOf(id) === -1) .forEach(id => this.clientVersionMap.delete(id)); - // Next, determine the set of versions which are still used. All others can be removed. + // Next, determine the set of versions which are still used. All others can be + // removed. const usedVersions = new Set(); this.clientVersionMap.forEach((version, _) => usedVersions.add(version)); @@ -705,8 +774,8 @@ export class Driver implements Debuggable, UpdateSource { // Wait for the other cleanup operations to complete. await previous; - // Try to get past the failure of one particular version to clean up (this shouldn't happen, - // but handle it just in case). + // Try to get past the failure of one particular version to clean up (this + // shouldn't happen, but handle it just in case). try { // Get ahold of the AppVersion for this particular hash. const instance = this.versions.get(version) !; @@ -716,9 +785,10 @@ export class Driver implements Debuggable, UpdateSource { // Clean it up. await instance.cleanup(); - } catch (e) { - // Oh well? Not much that can be done here. These caches will be removed when the SW revs - // its format version, which happens from time to time. + } catch (err) { + // Oh well? Not much that can be done here. These caches will be removed when + // the SW revs its format version, which happens from time to time. + this.debugger.log(err, `cleanupCaches - cleanup ${version}`); } }, Promise.resolve()); @@ -847,6 +917,18 @@ export class Driver implements Debuggable, UpdateSource { lastRun: this.idle.lastRun, }; } + + async safeFetch(req: Request): Promise { + try { + return await this.scope.fetch(req); + } catch (err) { + this.debugger.log(err, `Driver.fetch(${req.url})`); + return this.adapter.newResponse(null, { + status: 504, + statusText: 'Gateway Timeout', + }); + } + } } function errorToString(error: any): string { diff --git a/packages/service-worker/worker/src/idle.ts b/packages/service-worker/worker/src/idle.ts index ec599e5463..60a8f9b6a9 100644 --- a/packages/service-worker/worker/src/idle.ts +++ b/packages/service-worker/worker/src/idle.ts @@ -7,6 +7,7 @@ */ import {Adapter} from './adapter'; +import {DebugLogger} from './api'; export interface IdleTask { run: () => Promise; @@ -25,7 +26,7 @@ export class IdleScheduler { lastTrigger: number|null = null; lastRun: number|null = null; - constructor(private adapter: Adapter, private threshold: number) {} + constructor(private adapter: Adapter, private threshold: number, private debug: DebugLogger) {} async trigger(): Promise { this.lastTrigger = this.adapter.time; @@ -37,14 +38,14 @@ export class IdleScheduler { this.scheduled.cancel = true; } - this.scheduled = { + const scheduled = { cancel: false, }; + this.scheduled = scheduled; await this.adapter.timeout(this.threshold); - if (this.scheduled !== null && this.scheduled.cancel) { - this.scheduled = null; + if (scheduled.cancel) { return; } @@ -56,24 +57,24 @@ export class IdleScheduler { async execute(): Promise { this.lastRun = this.adapter.time; while (this.queue.length > 0) { - const queue = this.queue.map(task => { - try { - return task.run(); - } catch (e) { - // Ignore errors, for now. - return Promise.resolve(); - } - }); - + const queue = this.queue; this.queue = []; - await Promise.all(queue); - if (this.emptyResolve !== null) { - this.emptyResolve(); - this.emptyResolve = null; - } - this.empty = Promise.resolve(); + await queue.reduce(async(previous, task) => { + await previous; + try { + await task.run(); + } catch (err) { + this.debug.log(err, `while running idle task ${task.desc}`); + } + }, Promise.resolve()); } + + if (this.emptyResolve !== null) { + this.emptyResolve(); + this.emptyResolve = null; + } + this.empty = Promise.resolve(); } schedule(desc: string, run: () => Promise): void { diff --git a/packages/service-worker/worker/src/sha1.ts b/packages/service-worker/worker/src/sha1.ts index 48a2f90cf6..a01f0e744e 100644 --- a/packages/service-worker/worker/src/sha1.ts +++ b/packages/service-worker/worker/src/sha1.ts @@ -16,11 +16,19 @@ * * Borrowed from @angular/compiler/src/i18n/digest.ts */ + export function sha1(str: string): string { const utf8 = str; const words32 = stringToWords32(utf8, Endian.Big); - const len = utf8.length * 8; + return _sha1(words32, utf8.length * 8); +} +export function sha1Binary(buffer: ArrayBuffer): string { + const words32 = arrayBufferToWords32(buffer, Endian.Big); + return _sha1(words32, buffer.byteLength * 8); +} + +function _sha1(words32: number[], len: number): string { const w = new Array(80); let [a, b, c, d, e]: number[] = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0]; @@ -114,11 +122,24 @@ function stringToWords32(str: string, endian: Endian): number[] { return words32; } -function byteAt(str: string, index: number): number { - return index >= str.length ? 0 : str.charCodeAt(index) & 0xff; +function arrayBufferToWords32(buffer: ArrayBuffer, endian: Endian): number[] { + const words32 = Array((buffer.byteLength + 3) >>> 2); + const view = new Uint8Array(buffer); + for (let i = 0; i < words32.length; i++) { + words32[i] = wordAt(view, i * 4, endian); + } + return words32; } -function wordAt(str: string, index: number, endian: Endian): number { +function byteAt(str: string | Uint8Array, index: number): number { + if (typeof str === 'string') { + return index >= str.length ? 0 : str.charCodeAt(index) & 0xff; + } else { + return index >= str.byteLength ? 0 : str[index] & 0xff; + } +} + +function wordAt(str: string | Uint8Array, index: number, endian: Endian): number { let word = 0; if (endian === Endian.Big) { for (let i = 0; i < 4; i++) { diff --git a/packages/service-worker/worker/src/util.ts b/packages/service-worker/worker/src/util.ts index 3fa5f0fa68..bc2488d5d1 100644 --- a/packages/service-worker/worker/src/util.ts +++ b/packages/service-worker/worker/src/util.ts @@ -8,14 +8,14 @@ import {Adapter} from './adapter'; -export function isNavigationRequest(req: Request, adapter: Adapter): boolean { +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, adapter)) { + if (hasFileExtension(req.url, relativeTo, adapter)) { return false; } if (!acceptsTextHtml(req)) { @@ -24,8 +24,8 @@ export function isNavigationRequest(req: Request, adapter: Adapter): boolean { return true; } -function hasFileExtension(url: string, adapter: Adapter): boolean { - const path = adapter.getPath(url); +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; } diff --git a/packages/service-worker/worker/test/data_spec.ts b/packages/service-worker/worker/test/data_spec.ts index 469340180d..1a21a5a84d 100644 --- a/packages/service-worker/worker/test/data_spec.ts +++ b/packages/service-worker/worker/test/data_spec.ts @@ -135,6 +135,12 @@ export function main() { }); describe('in performance mode', () => { + async_it('names the caches correctly', async() => { + expect(await makeRequest(scope, '/api/test')).toEqual('version 1'); + const keys = await scope.caches.keys(); + expect(keys.every(key => key.startsWith('ngsw:'))).toEqual(true); + }); + async_it('caches a basic request', async() => { expect(await makeRequest(scope, '/api/test')).toEqual('version 1'); server.assertSawRequestFor('/api/test'); diff --git a/packages/service-worker/worker/test/happy_spec.ts b/packages/service-worker/worker/test/happy_spec.ts index 4227cc2db4..5599af47cc 100644 --- a/packages/service-worker/worker/test/happy_spec.ts +++ b/packages/service-worker/worker/test/happy_spec.ts @@ -26,7 +26,6 @@ const dist = .addUnhashedFile('/unhashed/a.txt', 'this is unhashed', {'Cache-Control': 'max-age=10'}) .build(); - const distUpdate = new MockFileSystemBuilder() .addFile('/foo.txt', 'this is foo v2') @@ -51,6 +50,7 @@ const manifest: Manifest = { urls: [ '/foo.txt', '/bar.txt', + '/redirected.txt', ], patterns: [ '/unhashed/.*', @@ -91,6 +91,7 @@ const manifestUpdate: Manifest = { urls: [ '/foo.txt', '/bar.txt', + '/redirected.txt', ], patterns: [ '/unhashed/.*', @@ -117,10 +118,19 @@ const manifestUpdate: Manifest = { hashTable: tmpHashTableForFs(distUpdate), }; -const server = new MockServerStateBuilder().withStaticFiles(dist).withManifest(manifest).build(); +const server = new MockServerStateBuilder() + .withStaticFiles(dist) + .withManifest(manifest) + .withRedirect('/redirected.txt', '/redirect-target.txt', 'this was a redirect') + .withError('/error.txt') + .build(); const serverUpdate = - new MockServerStateBuilder().withStaticFiles(distUpdate).withManifest(manifestUpdate).build(); + new MockServerStateBuilder() + .withStaticFiles(distUpdate) + .withManifest(manifestUpdate) + .withRedirect('/redirected.txt', '/redirect-target.txt', 'this was a redirect') + .build(); const server404 = new MockServerStateBuilder().withStaticFiles(dist).build(); @@ -151,6 +161,7 @@ export function main() { server.assertSawRequestFor('/ngsw.json'); server.assertSawRequestFor('/foo.txt'); server.assertSawRequestFor('/bar.txt'); + server.assertSawRequestFor('/redirected.txt'); expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); expect(await makeRequest(scope, '/bar.txt')).toEqual('this is bar'); server.assertNoOtherRequests(); @@ -162,11 +173,40 @@ export function main() { server.assertSawRequestFor('/ngsw.json'); server.assertSawRequestFor('/foo.txt'); server.assertSawRequestFor('/bar.txt'); + server.assertSawRequestFor('/redirected.txt'); expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); expect(await makeRequest(scope, '/bar.txt')).toEqual('this is bar'); server.assertNoOtherRequests(); }); + async_it('handles non-relative URLs', async() => { + expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); + await driver.initialized; + server.clearRequests(); + expect(await makeRequest(scope, 'http://localhost/foo.txt')).toEqual('this is foo'); + server.assertNoOtherRequests(); + }); + + async_it('handles actual errors from the browser', async() => { + expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); + await driver.initialized; + server.clearRequests(); + + const [resPromise, done] = scope.handleFetch(new MockRequest('/error.txt'), 'default'); + await done; + const res = (await resPromise) !; + expect(res.status).toEqual(504); + expect(res.statusText).toEqual('Gateway Timeout'); + }); + + async_it('handles redirected responses', async() => { + expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); + await driver.initialized; + server.clearRequests(); + expect(await makeRequest(scope, '/redirected.txt')).toEqual('this was a redirect'); + server.assertNoOtherRequests(); + }); + async_it('caches lazy content on-request', async() => { expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); await driver.initialized; @@ -192,6 +232,7 @@ export function main() { expect(await driver.checkForUpdate()).toEqual(true); serverUpdate.assertSawRequestFor('/ngsw.json'); serverUpdate.assertSawRequestFor('/foo.txt'); + serverUpdate.assertSawRequestFor('/redirected.txt'); serverUpdate.assertNoOtherRequests(); expect(client.messages).toEqual([{ @@ -224,6 +265,7 @@ export function main() { expect(await driver.checkForUpdate()).toEqual(true); serverUpdate.assertSawRequestFor('/ngsw.json'); serverUpdate.assertSawRequestFor('/foo.txt'); + serverUpdate.assertSawRequestFor('/redirected.txt'); serverUpdate.assertNoOtherRequests(); expect(client.messages).toEqual([{ @@ -288,8 +330,10 @@ export function main() { scope.advance(12000); await driver.idle.empty; + serverUpdate.assertSawRequestFor('/ngsw.json'); serverUpdate.assertSawRequestFor('/foo.txt'); + serverUpdate.assertSawRequestFor('/redirected.txt'); serverUpdate.assertNoOtherRequests(); }); @@ -456,6 +500,15 @@ export function main() { server.assertNoOtherRequests(); }); + async_it('avoid opaque responses', async() => { + expect(await makeRequest(scope, '/unhashed/a.txt', 'default', { + credentials: 'include' + })).toEqual('this is unhashed'); + server.assertSawRequestFor('/unhashed/a.txt'); + expect(await makeRequest(scope, '/unhashed/a.txt')).toEqual('this is unhashed'); + server.assertNoOtherRequests(); + }); + async_it('expire according to Cache-Control headers', async() => { expect(await makeRequest(scope, '/unhashed/a.txt')).toEqual('this is unhashed'); server.clearRequests(); diff --git a/packages/service-worker/worker/test/idle_spec.ts b/packages/service-worker/worker/test/idle_spec.ts new file mode 100644 index 0000000000..2ebfc023ef --- /dev/null +++ b/packages/service-worker/worker/test/idle_spec.ts @@ -0,0 +1,137 @@ +/** + * @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 {IdleScheduler} from '../src/idle'; +import {SwTestHarness, SwTestHarnessBuilder} from '../testing/scope'; +import {async_beforeEach, async_fit, async_it} from './async'; + +export function main() { + // Skip environments that don't support the minimum APIs needed to run the SW tests. + if (!SwTestHarness.envIsSupported()) { + return; + } + describe('IdleScheduler', () => { + let scope: SwTestHarness; + let idle: IdleScheduler; + + beforeEach(() => { + scope = new SwTestHarnessBuilder().build(); + idle = new IdleScheduler(scope, 1000, { + log: (v, context) => console.error(v, context), + }); + }); + + // Validate that a single idle task executes when trigger() + // is called and the idle timeout passes. + async_it('executes scheduled work on time', async() => { + // Set up a single idle task to set the completed flag to true when it runs. + let completed: boolean = false; + idle.schedule('work', async() => { completed = true; }); + + // Simply scheduling the task should not cause it to execute. + expect(completed).toEqual(false); + + // Trigger the idle mechanism. This returns a Promise that should resolve + // once the idle timeout has passed. + const trigger = idle.trigger(); + + // Advance the clock beyond the idle timeout, causing the idle tasks to run. + scope.advance(1100); + + // It should now be possible to wait for the trigger, and for the idle queue + // to be empty. + await trigger; + await idle.empty; + + // The task should now have run. + expect(completed).toEqual(true); + }); + + async_it('waits for multiple tasks to complete serially', async() => { + // Schedule several tasks that will increase a counter according to its + // current value. If these tasks execute in parallel, the writes to the counter + // will race, and the test will fail. + let counter: number = 2; + idle.schedule('double counter', async() => { + let local = counter; + await Promise.resolve(); + local *= 2; + await Promise.resolve(); + counter = local * 2; + }); + idle.schedule('triple counter', async() => { + // If this expect fails, it comes out of the 'await trigger' below. + expect(counter).toEqual(8); + + // Multiply the counter by 3 twice. + let local = counter; + await Promise.resolve(); + local *= 3; + await Promise.resolve(); + counter = local * 3; + }); + + // Trigger the idle mechanism once. + const trigger = idle.trigger(); + + // Advance the clock beyond the idle timeout, causing the idle tasks to run, and + // wait for them to complete. + scope.advance(1100); + await trigger; + await idle.empty; + + // Assert that both tasks executed in the correct serial sequence by validating + // that the counter reached the correct value. + expect(counter).toEqual(2 * 2 * 2 * 3 * 3); + }); + + // Validate that a single idle task does not execute until trigger() has been called + // and sufficient time passes without it being called again. + async_it('does not execute work until timeout passes with no triggers', async() => { + // Set up a single idle task to set the completed flag to true when it runs. + let completed: boolean = false; + idle.schedule('work', async() => { completed = true; }); + + // Trigger the queue once. This trigger will start a timer for the idle timeout, + // but another trigger() will be called before that timeout passes. + const firstTrigger = idle.trigger(); + + // Advance the clock a little, but not enough to actually cause tasks to execute. + scope.advance(500); + + // Assert that the task has not yet run. + expect(completed).toEqual(false); + + // Next, trigger the queue again. + const secondTrigger = idle.trigger(); + + // Advance the clock beyond the timeout for the first trigger, but not the second. + // This should cause the first trigger to resolve, but without running the task. + scope.advance(600); + await firstTrigger; + expect(completed).toEqual(false); + + // Schedule a third trigger. This is the one that will eventually resolve the task. + const thirdTrigger = idle.trigger(); + + // Again, advance beyond the second trigger and verify it didn't resolve the task. + scope.advance(500); + await secondTrigger; + expect(completed).toEqual(false); + + // Finally, advance beyond the third trigger, which should cause the task to be + // executed finally. + scope.advance(600); + await thirdTrigger; + await idle.empty; + + // The task should have executed. + expect(completed).toEqual(true); + }); + }); +} \ No newline at end of file diff --git a/packages/service-worker/worker/test/prefetch_spec.ts b/packages/service-worker/worker/test/prefetch_spec.ts index 668c54b37a..a62277631b 100644 --- a/packages/service-worker/worker/test/prefetch_spec.ts +++ b/packages/service-worker/worker/test/prefetch_spec.ts @@ -38,7 +38,9 @@ export function main() { let group: PrefetchAssetGroup; let idle: IdleScheduler; beforeEach(() => { - idle = new IdleScheduler(null !, 3000); + idle = new IdleScheduler(null !, 3000, { + log: (v, ctx = '') => console.error(v, ctx), + }); group = new PrefetchAssetGroup( scope, scope, idle, manifest.assetGroups ![0], tmpHashTable(manifest), db, 'test'); }); diff --git a/packages/service-worker/worker/testing/cache.ts b/packages/service-worker/worker/testing/cache.ts index 88825fe87a..b08428e20f 100644 --- a/packages/service-worker/worker/testing/cache.ts +++ b/packages/service-worker/worker/testing/cache.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {MockResponse} from './fetch'; +import {MockRequest, MockResponse} from './fetch'; export interface DehydratedResponse { body: string|null; @@ -25,11 +25,11 @@ export type DehydratedCacheStorage = { export class MockCacheStorage implements CacheStorage { private caches = new Map(); - constructor(hydrateFrom?: string) { + constructor(private origin: string, hydrateFrom?: string) { if (hydrateFrom !== undefined) { const hydrated = JSON.parse(hydrateFrom) as DehydratedCacheStorage; Object.keys(hydrated).forEach( - name => { this.caches.set(name, new MockCache(hydrated[name])); }); + name => { this.caches.set(name, new MockCache(this.origin, hydrated[name])); }); } } @@ -39,7 +39,7 @@ export class MockCacheStorage implements CacheStorage { async open(name: string): Promise { if (!this.caches.has(name)) { - this.caches.set(name, new MockCache()); + this.caches.set(name, new MockCache(this.origin)); } return this.caches.get(name) !; } @@ -77,7 +77,7 @@ export class MockCacheStorage implements CacheStorage { export class MockCache implements Cache { private cache = new Map(); - constructor(hydrated?: DehydratedCache) { + constructor(private origin: string, hydrated?: DehydratedCache) { if (hydrated !== undefined) { Object.keys(hydrated).forEach(url => { const resp = hydrated[url]; @@ -110,7 +110,10 @@ export class MockCache implements Cache { } async match(request: RequestInfo, options?: CacheQueryOptions): Promise { - const url = (typeof request === 'string' ? request : request.url); + let url = (typeof request === 'string' ? request : request.url); + if (url.startsWith(this.origin)) { + url = '/' + url.substr(this.origin.length); + } // TODO: cleanup typings. Typescript doesn't know this can resolve to undefined. let res = this.cache.get(url); if (res !== undefined) { diff --git a/packages/service-worker/worker/testing/fetch.ts b/packages/service-worker/worker/testing/fetch.ts index b8ece304f1..8c342dd1cc 100644 --- a/packages/service-worker/worker/testing/fetch.ts +++ b/packages/service-worker/worker/testing/fetch.ts @@ -11,7 +11,19 @@ export class MockBody implements Body { constructor(public _body: string|null) {} - async arrayBuffer(): Promise { throw 'Not implemented'; } + async arrayBuffer(): Promise { + this.bodyUsed = true; + if (this._body !== null) { + const buffer = new ArrayBuffer(this._body.length); + const access = new Uint8Array(buffer); + for (let i = 0; i < this._body.length; i++) { + access[i] = this._body.charCodeAt(i); + } + return buffer; + } else { + throw new Error('No body'); + } + } async blob(): Promise { throw 'Not implemented'; } @@ -64,7 +76,7 @@ export class MockRequest extends MockBody implements Request { readonly referrer: string = ''; readonly referrerPolicy: ReferrerPolicy = 'no-referrer'; readonly type: RequestType = ''; - readonly url: string; + url: string; constructor(input: string|Request, init: RequestInit = {}) { super(init !== undefined ? init.body || null : null); @@ -84,13 +96,18 @@ export class MockRequest extends MockBody implements Request { if (init.mode !== undefined) { this.mode = init.mode; } + if (init.credentials !== undefined) { + this.credentials = init.credentials; + } } clone(): Request { if (this.bodyUsed) { throw 'Body already consumed'; } - return new MockRequest(this.url, {body: this._body}); + return new MockRequest( + this.url, + {body: this._body, mode: this.mode, credentials: this.credentials, headers: this.headers}); } } @@ -102,8 +119,11 @@ export class MockResponse extends MockBody implements Response { readonly type: ResponseType = 'basic'; readonly url: string = ''; readonly body: ReadableStream|null = null; + readonly redirected: boolean = false; - constructor(body?: any, init: ResponseInit = {}) { + constructor( + body?: any, + init: ResponseInit&{type?: ResponseType, redirected?: boolean, url?: string} = {}) { super(typeof body === 'string' ? body : null); this.status = (init.status !== undefined) ? init.status : 200; this.statusText = init.statusText || 'OK'; @@ -116,6 +136,15 @@ export class MockResponse extends MockBody implements Response { }); } } + if (init.type !== undefined) { + this.type = init.type; + } + if (init.redirected !== undefined) { + this.redirected = init.redirected; + } + if (init.url !== undefined) { + this.url = init.url; + } } clone(): Response { diff --git a/packages/service-worker/worker/testing/mock.ts b/packages/service-worker/worker/testing/mock.ts index 35bb4dff56..52100a628c 100644 --- a/packages/service-worker/worker/testing/mock.ts +++ b/packages/service-worker/worker/testing/mock.ts @@ -62,6 +62,7 @@ export class MockFileSystem { export class MockServerStateBuilder { private resources = new Map(); + private errors = new Set(); withStaticFiles(fs: MockFileSystem): MockServerStateBuilder { fs.list().forEach(path => { @@ -76,7 +77,18 @@ export class MockServerStateBuilder { return this; } - build(): MockServerState { return new MockServerState(this.resources); } + withRedirect(from: string, to: string, toContents: string): MockServerStateBuilder { + this.resources.set(from, new MockResponse(toContents, {redirected: true, url: to})); + this.resources.set(to, new MockResponse(toContents)); + return this; + } + + withError(url: string): MockServerStateBuilder { + this.errors.add(url); + return this; + } + + build(): MockServerState { return new MockServerState(this.resources, this.errors); } } export class MockServerState { @@ -86,7 +98,7 @@ export class MockServerState { private resolveNextRequest: Function; nextRequest: Promise; - constructor(private resources: Map) { + constructor(private resources: Map, private errors: Set) { this.nextRequest = new Promise(resolve => { this.resolveNextRequest = resolve; }); } @@ -95,11 +107,18 @@ export class MockServerState { this.nextRequest = new Promise(resolve => { this.resolveNextRequest = resolve; }); await this.gate; + + if (req.credentials === 'include') { + return new MockResponse(null, {status: 0, statusText: '', type: 'opaque'}); + } const url = req.url.split('?')[0]; this.requests.push(req); if (this.resources.has(url)) { return this.resources.get(url) !.clone(); } + if (this.errors.has(url)) { + throw new Error('Intentional failure!'); + } return new MockResponse(null, {status: 404, statusText: 'Not Found'}); } diff --git a/packages/service-worker/worker/testing/scope.ts b/packages/service-worker/worker/testing/scope.ts index 8db0a89328..bb97a2deaa 100644 --- a/packages/service-worker/worker/testing/scope.ts +++ b/packages/service-worker/worker/testing/scope.ts @@ -18,6 +18,8 @@ import {MockServerState, MockServerStateBuilder} from './mock'; const EMPTY_SERVER_STATE = new MockServerStateBuilder().build(); +const MOCK_ORIGIN = 'http://localhost/'; + export class MockClient { queue = new Subject(); @@ -33,10 +35,10 @@ export class MockClient { export class SwTestHarnessBuilder { private server = EMPTY_SERVER_STATE; - private caches = new MockCacheStorage(); + private caches = new MockCacheStorage(MOCK_ORIGIN); withCacheState(cache: string): SwTestHarnessBuilder { - this.caches = new MockCacheStorage(cache); + this.caches = new MockCacheStorage(MOCK_ORIGIN, cache); return this; } @@ -86,7 +88,7 @@ export class SwTestHarness implements ServiceWorkerGlobalScope, Adapter, Context active: { postMessage: (msg: any) => { this.selfMessageQueue.push(msg); }, }, - scope: 'http://localhost/', + scope: MOCK_ORIGIN, showNotification: (title: string, options: Object) => { this.notifications.push({title, options}); }, @@ -144,9 +146,16 @@ export class SwTestHarness implements ServiceWorkerGlobalScope, Adapter, Context fetch(req: string|Request): Promise { if (typeof req === 'string') { + if (req.startsWith(MOCK_ORIGIN)) { + req = '/' + req.substr(MOCK_ORIGIN.length); + } return this.server.fetch(new MockRequest(req)); } else { - return this.server.fetch(req); + const mockReq = req.clone() as MockRequest; + if (mockReq.url.startsWith(MOCK_ORIGIN)) { + mockReq.url = '/' + mockReq.url.substr(MOCK_ORIGIN.length); + } + return this.server.fetch(mockReq); } } @@ -156,9 +165,9 @@ export class SwTestHarness implements ServiceWorkerGlobalScope, Adapter, Context removeEventListener(event: string, handler?: Function): void { this.eventHandlers.delete(event); } - newRequest(url: string): Request { return new MockRequest(url); } + newRequest(url: string, init: Object = {}): Request { return new MockRequest(url, init); } - newResponse(body: string): Response { return new MockResponse(body); } + newResponse(body: string, init: Object = {}): Response { return new MockResponse(body, init); } newHeaders(headers: {[name: string]: string}): Headers { return Object.keys(headers).reduce((mock, name) => { @@ -167,11 +176,13 @@ export class SwTestHarness implements ServiceWorkerGlobalScope, Adapter, Context }, new MockHeaders()); } - getPath(url: string): string { + parseUrl(url: string, relativeTo: string): {origin: string, path: string} { if (typeof URL === 'function') { - return new URL(url, 'http://localhost/').pathname; + const obj = new URL(url, relativeTo); + return {origin: obj.origin, path: obj.pathname}; } else { - return require('url').parse(url).pathname; + const obj = require('url').parse(url); + return {origin: obj.origin, path: obj.pathname}; } } diff --git a/tools/public_api_guard/service-worker/config.d.ts b/tools/public_api_guard/service-worker/config.d.ts index 6462ee2f23..b82be6638b 100644 --- a/tools/public_api_guard/service-worker/config.d.ts +++ b/tools/public_api_guard/service-worker/config.d.ts @@ -36,6 +36,7 @@ export declare type Duration = string; /** @experimental */ export interface Filesystem { + hash(file: string): Promise; list(dir: string): Promise; read(file: string): Promise; write(file: string, contents: string): Promise;