| // Copyright 2011 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import type * as ProtocolProxyApi from '../../generated/protocol-proxy-api.js'; |
| import * as Protocol from '../../generated/protocol.js'; |
| import * as Common from '../common/common.js'; |
| import * as i18n from '../i18n/i18n.js'; |
| import type * as Platform from '../platform/platform.js'; |
| |
| import {Events as RuntimeModelEvents, type ExecutionContext, RuntimeModel} from './RuntimeModel.js'; |
| import {SDKModel} from './SDKModel.js'; |
| import {Capability, type Target, Type} from './Target.js'; |
| import {TargetManager} from './TargetManager.js'; |
| |
| const UIStrings = { |
| /** |
| * @description Service worker running status displayed in the Service Workers view in the Application panel |
| */ |
| running: 'running', |
| /** |
| * @description Service worker running status displayed in the Service Workers view in the Application panel |
| */ |
| starting: 'starting', |
| /** |
| * @description Service worker running status displayed in the Service Workers view in the Application panel |
| */ |
| stopped: 'stopped', |
| /** |
| * @description Service worker running status displayed in the Service Workers view in the Application panel |
| */ |
| stopping: 'stopping', |
| /** |
| * @description Service worker version status displayed in the Threads view of the Debugging side pane in the Sources panel |
| */ |
| activated: 'activated', |
| /** |
| * @description Service worker version status displayed in the Threads view of the Debugging side pane in the Sources panel |
| */ |
| activating: 'activating', |
| /** |
| * @description Service worker version status displayed in the Threads view of the Debugging side pane in the Sources panel |
| */ |
| installed: 'installed', |
| /** |
| * @description Service worker version status displayed in the Threads view of the Debugging side pane in the Sources panel |
| */ |
| installing: 'installing', |
| /** |
| * @description Service worker version status displayed in the Threads view of the Debugging side pane in the Sources panel |
| */ |
| new: 'new', |
| /** |
| * @description Service worker version status displayed in the Threads view of the Debugging side pane in the Sources panel |
| */ |
| redundant: 'redundant', |
| /** |
| * @description Service worker version status displayed in the Threads view of the Debugging side pane in the Sources panel |
| * @example {sw.js} PH1 |
| * @example {117} PH2 |
| * @example {activated} PH3 |
| */ |
| sSS: '{PH1} #{PH2} ({PH3})', |
| } as const; |
| const str_ = i18n.i18n.registerUIStrings('core/sdk/ServiceWorkerManager.ts', UIStrings); |
| const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); |
| const i18nLazyString = i18n.i18n.getLazilyComputedLocalizedString.bind(undefined, str_); |
| |
| export class ServiceWorkerManager extends SDKModel<EventTypes> { |
| readonly #agent: ProtocolProxyApi.ServiceWorkerApi; |
| readonly #registrations = new Map<string, ServiceWorkerRegistration>(); |
| #enabled = false; |
| readonly #forceUpdateSetting: Common.Settings.Setting<boolean>; |
| |
| constructor(target: Target) { |
| super(target); |
| target.registerServiceWorkerDispatcher(new ServiceWorkerDispatcher(this)); |
| this.#agent = target.serviceWorkerAgent(); |
| void this.enable(); |
| this.#forceUpdateSetting = |
| Common.Settings.Settings.instance().createSetting('service-worker-update-on-reload', false); |
| if (this.#forceUpdateSetting.get()) { |
| this.forceUpdateSettingChanged(); |
| } |
| this.#forceUpdateSetting.addChangeListener(this.forceUpdateSettingChanged, this); |
| new ServiceWorkerContextNamer(target, this); |
| } |
| |
| async enable(): Promise<void> { |
| if (this.#enabled) { |
| return; |
| } |
| this.#enabled = true; |
| await this.#agent.invoke_enable(); |
| } |
| |
| async disable(): Promise<void> { |
| if (!this.#enabled) { |
| return; |
| } |
| this.#enabled = false; |
| this.#registrations.clear(); |
| await this.#agent.invoke_enable(); |
| } |
| |
| registrations(): Map<string, ServiceWorkerRegistration> { |
| return this.#registrations; |
| } |
| |
| findVersion(versionId: string): ServiceWorkerVersion|null { |
| for (const registration of this.registrations().values()) { |
| const version = registration.versions.get(versionId); |
| if (version) { |
| return version; |
| } |
| } |
| return null; |
| } |
| |
| deleteRegistration(registrationId: string): void { |
| const registration = this.#registrations.get(registrationId); |
| if (!registration) { |
| return; |
| } |
| if (registration.isRedundant()) { |
| this.#registrations.delete(registrationId); |
| this.dispatchEventToListeners(Events.REGISTRATION_DELETED, registration); |
| return; |
| } |
| registration.deleting = true; |
| for (const version of registration.versions.values()) { |
| void this.stopWorker(version.id); |
| } |
| void this.unregister(registration.scopeURL); |
| } |
| |
| async updateRegistration(registrationId: string): Promise<void> { |
| const registration = this.#registrations.get(registrationId); |
| if (!registration) { |
| return; |
| } |
| await this.#agent.invoke_updateRegistration({scopeURL: registration.scopeURL}); |
| } |
| |
| async deliverPushMessage(registrationId: Protocol.ServiceWorker.RegistrationID, data: string): Promise<void> { |
| const registration = this.#registrations.get(registrationId); |
| if (!registration) { |
| return; |
| } |
| const origin = Common.ParsedURL.ParsedURL.extractOrigin(registration.scopeURL); |
| await this.#agent.invoke_deliverPushMessage({origin, registrationId, data}); |
| } |
| |
| async dispatchSyncEvent(registrationId: Protocol.ServiceWorker.RegistrationID, tag: string, lastChance: boolean): |
| Promise<void> { |
| const registration = this.#registrations.get(registrationId); |
| if (!registration) { |
| return; |
| } |
| const origin = Common.ParsedURL.ParsedURL.extractOrigin(registration.scopeURL); |
| await this.#agent.invoke_dispatchSyncEvent({origin, registrationId, tag, lastChance}); |
| } |
| |
| async dispatchPeriodicSyncEvent(registrationId: Protocol.ServiceWorker.RegistrationID, tag: string): Promise<void> { |
| const registration = this.#registrations.get(registrationId); |
| if (!registration) { |
| return; |
| } |
| const origin = Common.ParsedURL.ParsedURL.extractOrigin(registration.scopeURL); |
| await this.#agent.invoke_dispatchPeriodicSyncEvent({origin, registrationId, tag}); |
| } |
| |
| private async unregister(scopeURL: string): Promise<void> { |
| await this.#agent.invoke_unregister({scopeURL}); |
| } |
| |
| async startWorker(scopeURL: string): Promise<void> { |
| await this.#agent.invoke_startWorker({scopeURL}); |
| } |
| |
| async skipWaiting(scopeURL: string): Promise<void> { |
| await this.#agent.invoke_skipWaiting({scopeURL}); |
| } |
| |
| async stopWorker(versionId: string): Promise<void> { |
| await this.#agent.invoke_stopWorker({versionId}); |
| } |
| |
| workerRegistrationUpdated(registrations: Protocol.ServiceWorker.ServiceWorkerRegistration[]): void { |
| for (const payload of registrations) { |
| let registration = this.#registrations.get(payload.registrationId); |
| if (!registration) { |
| registration = new ServiceWorkerRegistration(payload); |
| this.#registrations.set(payload.registrationId, registration); |
| this.dispatchEventToListeners(Events.REGISTRATION_UPDATED, registration); |
| continue; |
| } |
| registration.update(payload); |
| |
| if (registration.shouldBeRemoved()) { |
| this.#registrations.delete(registration.id); |
| this.dispatchEventToListeners(Events.REGISTRATION_DELETED, registration); |
| } else { |
| this.dispatchEventToListeners(Events.REGISTRATION_UPDATED, registration); |
| } |
| } |
| } |
| |
| workerVersionUpdated(versions: Protocol.ServiceWorker.ServiceWorkerVersion[]): void { |
| const registrations = new Set<ServiceWorkerRegistration>(); |
| for (const payload of versions) { |
| const registration = this.#registrations.get(payload.registrationId); |
| if (!registration) { |
| continue; |
| } |
| registration.updateVersion(payload); |
| registrations.add(registration); |
| } |
| for (const registration of registrations) { |
| if (registration.shouldBeRemoved()) { |
| this.#registrations.delete(registration.id); |
| this.dispatchEventToListeners(Events.REGISTRATION_DELETED, registration); |
| } else { |
| this.dispatchEventToListeners(Events.REGISTRATION_UPDATED, registration); |
| } |
| } |
| } |
| |
| workerErrorReported(payload: Protocol.ServiceWorker.ServiceWorkerErrorMessage): void { |
| const registration = this.#registrations.get(payload.registrationId); |
| if (!registration) { |
| return; |
| } |
| registration.errors.push(payload); |
| this.dispatchEventToListeners(Events.REGISTRATION_ERROR_ADDED, {registration, error: payload}); |
| } |
| |
| private forceUpdateSettingChanged(): void { |
| const forceUpdateOnPageLoad = this.#forceUpdateSetting.get(); |
| void this.#agent.invoke_setForceUpdateOnPageLoad({forceUpdateOnPageLoad}); |
| } |
| } |
| |
| export const enum Events { |
| REGISTRATION_UPDATED = 'RegistrationUpdated', |
| REGISTRATION_ERROR_ADDED = 'RegistrationErrorAdded', |
| REGISTRATION_DELETED = 'RegistrationDeleted', |
| } |
| |
| export interface RegistrationErrorAddedEvent { |
| registration: ServiceWorkerRegistration; |
| error: Protocol.ServiceWorker.ServiceWorkerErrorMessage; |
| } |
| |
| export interface EventTypes { |
| [Events.REGISTRATION_UPDATED]: ServiceWorkerRegistration; |
| [Events.REGISTRATION_ERROR_ADDED]: RegistrationErrorAddedEvent; |
| [Events.REGISTRATION_DELETED]: ServiceWorkerRegistration; |
| } |
| |
| class ServiceWorkerDispatcher implements ProtocolProxyApi.ServiceWorkerDispatcher { |
| readonly #manager: ServiceWorkerManager; |
| constructor(manager: ServiceWorkerManager) { |
| this.#manager = manager; |
| } |
| |
| workerRegistrationUpdated({registrations}: Protocol.ServiceWorker.WorkerRegistrationUpdatedEvent): void { |
| this.#manager.workerRegistrationUpdated(registrations); |
| } |
| |
| workerVersionUpdated({versions}: Protocol.ServiceWorker.WorkerVersionUpdatedEvent): void { |
| this.#manager.workerVersionUpdated(versions); |
| } |
| |
| workerErrorReported({errorMessage}: Protocol.ServiceWorker.WorkerErrorReportedEvent): void { |
| this.#manager.workerErrorReported(errorMessage); |
| } |
| } |
| |
| /** |
| * For every version, we keep a history of ServiceWorkerVersionState. Every time |
| * a version is updated we will add a new state at the head of the history chain. |
| * This history tells us information such as what the current state is, or when |
| * the version becomes installed. |
| */ |
| export class ServiceWorkerVersionState { |
| runningStatus: Protocol.ServiceWorker.ServiceWorkerVersionRunningStatus; |
| status: Protocol.ServiceWorker.ServiceWorkerVersionStatus; |
| lastUpdatedTimestamp: number; |
| previousState: ServiceWorkerVersionState|null; |
| constructor( |
| runningStatus: Protocol.ServiceWorker.ServiceWorkerVersionRunningStatus, |
| status: Protocol.ServiceWorker.ServiceWorkerVersionStatus, previousState: ServiceWorkerVersionState|null, |
| timestamp: number) { |
| this.runningStatus = runningStatus; |
| this.status = status; |
| this.lastUpdatedTimestamp = timestamp; |
| this.previousState = previousState; |
| } |
| } |
| |
| export class ServiceWorkerRouterRule { |
| condition: string; |
| source: string; |
| id: number; |
| constructor(condition: string, source: string, id: number) { |
| this.condition = condition; |
| this.source = source; |
| this.id = id; |
| } |
| } |
| |
| export class ServiceWorkerVersion { |
| id!: string; |
| scriptURL!: Platform.DevToolsPath.UrlString; |
| parsedURL!: Common.ParsedURL.ParsedURL; |
| securityOrigin!: string; |
| scriptLastModified!: number|undefined; |
| scriptResponseTime!: number|undefined; |
| controlledClients!: Protocol.Target.TargetID[]; |
| targetId!: string|null; |
| routerRules!: ServiceWorkerRouterRule[]|null; |
| currentState!: ServiceWorkerVersionState; |
| registration: ServiceWorkerRegistration; |
| constructor(registration: ServiceWorkerRegistration, payload: Protocol.ServiceWorker.ServiceWorkerVersion) { |
| this.registration = registration; |
| this.update(payload); |
| } |
| |
| update(payload: Protocol.ServiceWorker.ServiceWorkerVersion): void { |
| this.id = payload.versionId; |
| this.scriptURL = payload.scriptURL as Platform.DevToolsPath.UrlString; |
| const parsedURL = new Common.ParsedURL.ParsedURL(payload.scriptURL); |
| this.securityOrigin = parsedURL.securityOrigin(); |
| this.currentState = |
| new ServiceWorkerVersionState(payload.runningStatus, payload.status, this.currentState, Date.now()); |
| this.scriptLastModified = payload.scriptLastModified; |
| this.scriptResponseTime = payload.scriptResponseTime; |
| if (payload.controlledClients) { |
| this.controlledClients = payload.controlledClients.slice(); |
| } else { |
| this.controlledClients = []; |
| } |
| this.targetId = payload.targetId || null; |
| this.routerRules = null; |
| if (payload.routerRules) { |
| this.routerRules = this.parseJSONRules(payload.routerRules); |
| } |
| } |
| |
| isStartable(): boolean { |
| return !this.registration.isDeleted && this.isActivated() && this.isStopped(); |
| } |
| |
| isStoppedAndRedundant(): boolean { |
| return this.runningStatus === Protocol.ServiceWorker.ServiceWorkerVersionRunningStatus.Stopped && |
| this.status === Protocol.ServiceWorker.ServiceWorkerVersionStatus.Redundant; |
| } |
| |
| isStopped(): boolean { |
| return this.runningStatus === Protocol.ServiceWorker.ServiceWorkerVersionRunningStatus.Stopped; |
| } |
| |
| isStarting(): boolean { |
| return this.runningStatus === Protocol.ServiceWorker.ServiceWorkerVersionRunningStatus.Starting; |
| } |
| |
| isRunning(): boolean { |
| return this.runningStatus === Protocol.ServiceWorker.ServiceWorkerVersionRunningStatus.Running; |
| } |
| |
| isStopping(): boolean { |
| return this.runningStatus === Protocol.ServiceWorker.ServiceWorkerVersionRunningStatus.Stopping; |
| } |
| |
| isNew(): boolean { |
| return this.status === Protocol.ServiceWorker.ServiceWorkerVersionStatus.New; |
| } |
| |
| isInstalling(): boolean { |
| return this.status === Protocol.ServiceWorker.ServiceWorkerVersionStatus.Installing; |
| } |
| |
| isInstalled(): boolean { |
| return this.status === Protocol.ServiceWorker.ServiceWorkerVersionStatus.Installed; |
| } |
| |
| isActivating(): boolean { |
| return this.status === Protocol.ServiceWorker.ServiceWorkerVersionStatus.Activating; |
| } |
| |
| isActivated(): boolean { |
| return this.status === Protocol.ServiceWorker.ServiceWorkerVersionStatus.Activated; |
| } |
| |
| isRedundant(): boolean { |
| return this.status === Protocol.ServiceWorker.ServiceWorkerVersionStatus.Redundant; |
| } |
| |
| get status(): Protocol.ServiceWorker.ServiceWorkerVersionStatus { |
| return this.currentState.status; |
| } |
| |
| get runningStatus(): Protocol.ServiceWorker.ServiceWorkerVersionRunningStatus { |
| return this.currentState.runningStatus; |
| } |
| |
| mode(): string { |
| if (this.isNew() || this.isInstalling()) { |
| return ServiceWorkerVersion.Modes.INSTALLING; |
| } |
| if (this.isInstalled()) { |
| return ServiceWorkerVersion.Modes.WAITING; |
| } |
| if (this.isActivating() || this.isActivated()) { |
| return ServiceWorkerVersion.Modes.ACTIVE; |
| } |
| return ServiceWorkerVersion.Modes.REDUNDANT; |
| } |
| |
| private parseJSONRules(input: string): ServiceWorkerRouterRule[]|null { |
| try { |
| const parsedObject = JSON.parse(input); |
| if (!Array.isArray(parsedObject)) { |
| console.error('Parse error: `routerRules` in ServiceWorkerVersion should be an array'); |
| return null; |
| } |
| const routerRules: ServiceWorkerRouterRule[] = []; |
| for (const parsedRule of parsedObject) { |
| const {condition, source, id} = parsedRule; |
| if (condition === undefined || source === undefined || id === undefined) { |
| console.error('Parse error: Missing some fields of `routerRules` in ServiceWorkerVersion'); |
| return null; |
| } |
| routerRules.push(new ServiceWorkerRouterRule(JSON.stringify(condition), JSON.stringify(source), id)); |
| } |
| return routerRules; |
| } catch { |
| console.error('Parse error: Invalid `routerRules` in ServiceWorkerVersion'); |
| return null; |
| } |
| } |
| } |
| |
| export namespace ServiceWorkerVersion { |
| export const RunningStatus = { |
| [Protocol.ServiceWorker.ServiceWorkerVersionRunningStatus.Running]: i18nLazyString(UIStrings.running), |
| [Protocol.ServiceWorker.ServiceWorkerVersionRunningStatus.Starting]: i18nLazyString(UIStrings.starting), |
| [Protocol.ServiceWorker.ServiceWorkerVersionRunningStatus.Stopped]: i18nLazyString(UIStrings.stopped), |
| [Protocol.ServiceWorker.ServiceWorkerVersionRunningStatus.Stopping]: i18nLazyString(UIStrings.stopping), |
| }; |
| |
| export const Status = { |
| [Protocol.ServiceWorker.ServiceWorkerVersionStatus.Activated]: i18nLazyString(UIStrings.activated), |
| [Protocol.ServiceWorker.ServiceWorkerVersionStatus.Activating]: i18nLazyString(UIStrings.activating), |
| [Protocol.ServiceWorker.ServiceWorkerVersionStatus.Installed]: i18nLazyString(UIStrings.installed), |
| [Protocol.ServiceWorker.ServiceWorkerVersionStatus.Installing]: i18nLazyString(UIStrings.installing), |
| [Protocol.ServiceWorker.ServiceWorkerVersionStatus.New]: i18nLazyString(UIStrings.new), |
| [Protocol.ServiceWorker.ServiceWorkerVersionStatus.Redundant]: i18nLazyString(UIStrings.redundant), |
| }; |
| |
| export const enum Modes { |
| INSTALLING = 'installing', |
| WAITING = 'waiting', |
| ACTIVE = 'active', |
| REDUNDANT = 'redundant', |
| } |
| } |
| |
| export class ServiceWorkerRegistration { |
| #fingerprint!: symbol; |
| id!: Protocol.ServiceWorker.RegistrationID; |
| scopeURL!: Platform.DevToolsPath.UrlString; |
| securityOrigin!: Platform.DevToolsPath.UrlString; |
| isDeleted!: boolean; |
| versions = new Map<string, ServiceWorkerVersion>(); |
| deleting = false; |
| errors: Protocol.ServiceWorker.ServiceWorkerErrorMessage[] = []; |
| |
| constructor(payload: Protocol.ServiceWorker.ServiceWorkerRegistration) { |
| this.update(payload); |
| } |
| |
| update(payload: Protocol.ServiceWorker.ServiceWorkerRegistration): void { |
| this.#fingerprint = Symbol('fingerprint'); |
| this.id = payload.registrationId; |
| this.scopeURL = payload.scopeURL as Platform.DevToolsPath.UrlString; |
| const parsedURL = new Common.ParsedURL.ParsedURL(payload.scopeURL); |
| this.securityOrigin = parsedURL.securityOrigin(); |
| this.isDeleted = payload.isDeleted; |
| } |
| |
| fingerprint(): symbol { |
| return this.#fingerprint; |
| } |
| |
| versionsByMode(): Map<string, ServiceWorkerVersion> { |
| const result = new Map<string, ServiceWorkerVersion>(); |
| for (const version of this.versions.values()) { |
| result.set(version.mode(), version); |
| } |
| return result; |
| } |
| |
| updateVersion(payload: Protocol.ServiceWorker.ServiceWorkerVersion): ServiceWorkerVersion { |
| this.#fingerprint = Symbol('fingerprint'); |
| let version = this.versions.get(payload.versionId); |
| if (!version) { |
| version = new ServiceWorkerVersion(this, payload); |
| this.versions.set(payload.versionId, version); |
| return version; |
| } |
| version.update(payload); |
| return version; |
| } |
| |
| isRedundant(): boolean { |
| for (const version of this.versions.values()) { |
| if (!version.isStoppedAndRedundant()) { |
| return false; |
| } |
| } |
| return true; |
| } |
| |
| shouldBeRemoved(): boolean { |
| return this.isRedundant() && (!this.errors.length || this.deleting); |
| } |
| |
| canBeRemoved(): boolean { |
| return this.isDeleted || this.deleting; |
| } |
| } |
| |
| class ServiceWorkerContextNamer { |
| readonly #target: Target; |
| readonly #serviceWorkerManager: ServiceWorkerManager; |
| readonly #versionByTargetId = new Map<string, ServiceWorkerVersion>(); |
| |
| constructor(target: Target, serviceWorkerManager: ServiceWorkerManager) { |
| this.#target = target; |
| this.#serviceWorkerManager = serviceWorkerManager; |
| serviceWorkerManager.addEventListener(Events.REGISTRATION_UPDATED, this.registrationsUpdated, this); |
| serviceWorkerManager.addEventListener(Events.REGISTRATION_DELETED, this.registrationsUpdated, this); |
| TargetManager.instance().addModelListener( |
| RuntimeModel, RuntimeModelEvents.ExecutionContextCreated, this.executionContextCreated, this); |
| } |
| |
| private registrationsUpdated(): void { |
| this.#versionByTargetId.clear(); |
| const registrations = this.#serviceWorkerManager.registrations().values(); |
| for (const registration of registrations) { |
| for (const version of registration.versions.values()) { |
| if (version.targetId) { |
| this.#versionByTargetId.set(version.targetId, version); |
| } |
| } |
| } |
| this.updateAllContextLabels(); |
| } |
| |
| private executionContextCreated(event: Common.EventTarget.EventTargetEvent<ExecutionContext>): void { |
| const executionContext = event.data; |
| const serviceWorkerTargetId = this.serviceWorkerTargetId(executionContext.target()); |
| if (!serviceWorkerTargetId) { |
| return; |
| } |
| this.updateContextLabel(executionContext, this.#versionByTargetId.get(serviceWorkerTargetId) || null); |
| } |
| |
| private serviceWorkerTargetId(target: Target): string|null { |
| if (target.parentTarget() !== this.#target || target.type() !== Type.ServiceWorker) { |
| return null; |
| } |
| return target.id(); |
| } |
| |
| private updateAllContextLabels(): void { |
| for (const target of TargetManager.instance().targets()) { |
| const serviceWorkerTargetId = this.serviceWorkerTargetId(target); |
| if (!serviceWorkerTargetId) { |
| continue; |
| } |
| const version = this.#versionByTargetId.get(serviceWorkerTargetId) || null; |
| const runtimeModel = target.model(RuntimeModel); |
| const executionContexts = runtimeModel ? runtimeModel.executionContexts() : []; |
| for (const context of executionContexts) { |
| this.updateContextLabel(context, version); |
| } |
| } |
| } |
| |
| private updateContextLabel(context: ExecutionContext, version: ServiceWorkerVersion|null): void { |
| if (!version) { |
| context.setLabel(''); |
| return; |
| } |
| const parsedUrl = Common.ParsedURL.ParsedURL.fromString(context.origin); |
| const label = parsedUrl ? parsedUrl.lastPathComponentWithFragment() : context.name; |
| const localizedStatus = ServiceWorkerVersion.Status[version.status]; |
| context.setLabel(i18nString(UIStrings.sSS, {PH1: label, PH2: version.id, PH3: localizedStatus()})); |
| } |
| } |
| |
| SDKModel.register(ServiceWorkerManager, {capabilities: Capability.SERVICE_WORKER, autostart: true}); |