diff --git a/goldens/public-api/service-worker/index.api.md b/goldens/public-api/service-worker/index.api.md index d913dd37321..13c32d9d123 100644 --- a/goldens/public-api/service-worker/index.api.md +++ b/goldens/public-api/service-worker/index.api.md @@ -109,7 +109,7 @@ export interface VersionDetectedEvent { } // @public -export type VersionEvent = VersionDetectedEvent | VersionInstallationFailedEvent | VersionReadyEvent | NoNewVersionDetectedEvent; +export type VersionEvent = VersionDetectedEvent | VersionInstallationFailedEvent | VersionReadyEvent | VersionFailedEvent | NoNewVersionDetectedEvent; // @public export interface VersionInstallationFailedEvent { diff --git a/packages/service-worker/src/low_level.ts b/packages/service-worker/src/low_level.ts index 41e166deee1..6fe3430f61e 100644 --- a/packages/service-worker/src/low_level.ts +++ b/packages/service-worker/src/low_level.ts @@ -70,6 +70,23 @@ export interface VersionReadyEvent { latestVersion: {hash: string; appData?: object}; } +/** + * An event emitted when a specific version of the app has encountered a critical failure + * that prevents it from functioning correctly. + * + * When a version fails, the service worker will notify all clients currently using that version + * and may degrade to serving only existing clients if the failed version was the latest one. + * + * @see {@link /ecosystem/service-workers/communications Service Worker Communication Guide} + * + * @publicApi + */ +export interface VersionFailedEvent { + type: 'VERSION_FAILED'; + version: {hash: string; appData?: object}; + error: string; +} + /** * A union of all event types that can be emitted by * {@link SwUpdate#versionUpdates}. @@ -80,6 +97,7 @@ export type VersionEvent = | VersionDetectedEvent | VersionInstallationFailedEvent | VersionReadyEvent + | VersionFailedEvent | NoNewVersionDetectedEvent; /** diff --git a/packages/service-worker/src/update.ts b/packages/service-worker/src/update.ts index 5839ced701a..461f0ae4e45 100644 --- a/packages/service-worker/src/update.ts +++ b/packages/service-worker/src/update.ts @@ -66,6 +66,7 @@ export class SwUpdate { 'VERSION_INSTALLATION_FAILED', 'VERSION_READY', 'NO_NEW_VERSION_DETECTED', + 'VERSION_FAILED', ]); this.unrecoverable = this.sw.eventsOfType('UNRECOVERABLE_STATE'); } diff --git a/packages/service-worker/test/comm_spec.ts b/packages/service-worker/test/comm_spec.ts index 51d36f43569..ef490bacc0b 100644 --- a/packages/service-worker/test/comm_spec.ts +++ b/packages/service-worker/test/comm_spec.ts @@ -13,6 +13,7 @@ import { NoNewVersionDetectedEvent, VersionDetectedEvent, VersionEvent, + VersionFailedEvent, VersionReadyEvent, } from '../src/low_level'; import {ngswCommChannelFactory, SwRegistrationOptions} from '../src/provider'; @@ -509,6 +510,25 @@ describe('ServiceWorker library', () => { }, }); }); + it('processes version failed events with cache corruption error', (done) => { + update.versionUpdates.subscribe((event) => { + expect(event.type).toEqual('VERSION_FAILED'); + expect((event as VersionFailedEvent).version).toEqual({ + hash: 'B', + appData: {name: 'test-app'}, + }); + expect((event as VersionFailedEvent).error).toContain('Cache corruption detected'); + done(); + }); + mock.sendMessage({ + type: 'VERSION_FAILED', + version: { + hash: 'B', + appData: {name: 'test-app'}, + }, + error: 'Cache corruption detected during resource fetch', + }); + }); it('activates updates when requested', async () => { mock.messages.subscribe((msg: {action: string; nonce: number}) => { expect(msg.action).toEqual('ACTIVATE_UPDATE'); diff --git a/packages/service-worker/worker/src/driver.ts b/packages/service-worker/worker/src/driver.ts index a6965fce47a..ebe751fbc89 100644 --- a/packages/service-worker/worker/src/driver.ts +++ b/packages/service-worker/worker/src/driver.ts @@ -956,7 +956,8 @@ export class Driver implements Debuggable, UpdateSource { // Therefore, we keep clients on their current version (even if broken) and ensure that no new // clients will be assigned to it. - // TODO: notify affected apps. + // Notify clients that are using this broken version about the failure. + await this.notifyClientsAboutVersionFailure(brokenHash, err); // The action taken depends on whether the broken manifest is the active (latest) or not. // - If the broken version is not the latest, no further action is necessary, since new clients @@ -1298,6 +1299,29 @@ export class Driver implements Debuggable, UpdateSource { ); } + async notifyClientsAboutVersionFailure(brokenHash: string, error: Error): Promise { + await this.initialized; + + // Find all clients using the broken version + const affectedClients = Array.from(this.clientVersionMap.entries()) + .filter(([clientId, hash]) => hash === brokenHash) + .map(([clientId]) => clientId); + + await Promise.all( + affectedClients.map(async (clientId) => { + const client = await this.scope.clients.get(clientId); + if (client) { + const brokenVersion = this.versions.get(brokenHash); + client.postMessage({ + type: 'VERSION_FAILED', + version: this.mergeHashWithAppData(brokenVersion!.manifest, brokenHash), + error: errorToString(error), + }); + } + }), + ); + } + async broadcast(msg: Object): Promise { const clients = await this.scope.clients.matchAll(); clients.forEach((client) => { diff --git a/packages/service-worker/worker/test/happy_spec.ts b/packages/service-worker/worker/test/happy_spec.ts index e25b6f02886..06015494a57 100644 --- a/packages/service-worker/worker/test/happy_spec.ts +++ b/packages/service-worker/worker/test/happy_spec.ts @@ -2564,6 +2564,7 @@ import {envIsSupported} from '../testing/utils'; expect(await makeRequest(scope, '/foo.hash.js', 'client-2')).toBeNull(); expect(mockClient2.messages).toEqual([ jasmine.objectContaining({type: 'UNRECOVERABLE_STATE'}), + jasmine.objectContaining({type: 'VERSION_FAILED'}), ]); // This should also enter the `SW` into degraded mode, because the broken version was the