From c8f3cfaae6d8e3a23b2d5b57c5d156925df29438 Mon Sep 17 00:00:00 2001 From: Matthew Schile Date: Sat, 6 Apr 2024 20:37:59 -0600 Subject: [PATCH 01/31] updating controlledUrls --- .../lib/http/util/service-worker-injector.ts | 81 +++++++++++++++- .../lib/http/util/service-worker-manager.ts | 92 ++++++++++++++----- 2 files changed, 145 insertions(+), 28 deletions(-) diff --git a/packages/proxy/lib/http/util/service-worker-injector.ts b/packages/proxy/lib/http/util/service-worker-injector.ts index 8d17b3807dc5..0403bfd21c83 100644 --- a/packages/proxy/lib/http/util/service-worker-injector.ts +++ b/packages/proxy/lib/http/util/service-worker-injector.ts @@ -2,9 +2,64 @@ import type { ServiceWorkerClientEvent } from './service-worker-manager' +type FrameType = 'auxiliary' | 'nested' | 'none' | 'top-level' + +/** + * The Client interface represents an executable context such as a Worker, or a SharedWorker. Window clients are represented by the more-specific WindowClient. You can get Client/WindowClient objects from methods such as Clients.matchAll() and Clients.get(). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Client) + */ +interface Client { + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Client/frameType) */ + readonly frameType: FrameType + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Client/id) */ + readonly id: string + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Client/type) */ + readonly type: ClientTypes + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Client/url) */ + readonly url: string + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Client/postMessage) */ + postMessage(message: any, transfer: Transferable[]): void + postMessage(message: any, options?: StructuredSerializeOptions): void +} + +/** + * This ServiceWorker API interface represents the scope of a service worker client that is a document in a browser context, controlled by an active worker. The service worker client independently selects and uses a service worker for its own loading and sub-resources. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WindowClient) + */ +interface WindowClient extends Client { + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/WindowClient/focused) */ + readonly focused: boolean + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/WindowClient/visibilityState) */ + readonly visibilityState: DocumentVisibilityState + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/WindowClient/focus) */ + focus(): Promise + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/WindowClient/navigate) */ + navigate(url: string | URL): Promise +} + +/** + * Provides access to Client objects. Access it via self.clients within a service worker. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Clients) + */ +interface Clients { + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Clients/claim) */ + claim(): Promise + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Clients/get) */ + get(id: string): Promise + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Clients/matchAll) */ + matchAll(options?: T): Promise> + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Clients/openWindow) */ + openWindow(url: string | URL): Promise +} + // this should be of type ServiceWorkerGlobalScope from the webworker lib, // but we can't reference it directly because it causes errors in other packages interface ServiceWorkerGlobalScope extends WorkerGlobalScope { + clients: Clients + registration: ServiceWorkerRegistration onfetch: FetchListener | null addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void @@ -20,6 +75,8 @@ interface FetchEvent extends Event { type FetchListener = (this: ServiceWorkerGlobalScope, ev: FetchEvent) => any +type ServiceWorkerClientEventWithoutScope = Omit + declare let self: ServiceWorkerGlobalScope /** @@ -30,17 +87,17 @@ declare let self: ServiceWorkerGlobalScope export const injectIntoServiceWorker = (body: Buffer) => { function __cypressInjectIntoServiceWorker () { let listenerCount = 0 - let eventQueue: ServiceWorkerClientEvent[] = [] + let eventQueue: ServiceWorkerClientEventWithoutScope[] = [] const nonCaptureListenersMap = new WeakMap() const captureListenersMap = new WeakMap() const targetToWrappedHandleEventMap = new WeakMap() const targetToOrigHandleEventMap = new WeakMap() - const sendEvent = (event: ServiceWorkerClientEvent) => { + const sendEvent = (event: ServiceWorkerClientEventWithoutScope) => { // if the binding has been created, we can call it // otherwise, we need to queue the event if (self.__cypressServiceWorkerClientEvent) { - self.__cypressServiceWorkerClientEvent(JSON.stringify(event)) + self.__cypressServiceWorkerClientEvent(JSON.stringify({ ...event, scope: self.registration.scope })) } else { eventQueue.push(event) } @@ -59,6 +116,13 @@ export const injectIntoServiceWorker = (body: Buffer) => { sendEvent({ type: 'fetchRequest', payload }) } + const sendClientsUpdated = async () => { + const clients = (await self.clients.matchAll()).map(({ frameType, id, type, url }) => ({ frameType, id, type, url })) + + // call the CDP binding to inform the backend that the clients have been updated + sendEvent({ type: 'clientsUpdated', payload: { clients } }) + } + // A listener is considered valid if it is a function or an object (with the handleEvent function or the function could be added later) const isValidListener = (listener: EventListenerOrEventListenerObject) => { return listener && (typeof listener === 'function' || typeof listener === 'object') @@ -237,10 +301,19 @@ export const injectIntoServiceWorker = (body: Buffer) => { }, ) + const oldClientsClaim = self.clients.claim + + self.clients.claim = async () => { + await oldClientsClaim.call(self.clients) + + await sendClientsUpdated() + } + // listen for the activate event so we can inform the // backend whether or not the service worker has a handler - self.addEventListener('activate', () => { + self.addEventListener('activate', async () => { sendHasFetchEventHandlers() + await sendClientsUpdated() // if the binding has not been created yet, we need to wait for it if (!self.__cypressServiceWorkerClientEvent) { diff --git a/packages/proxy/lib/http/util/service-worker-manager.ts b/packages/proxy/lib/http/util/service-worker-manager.ts index 606aae28adff..f59cb11f73eb 100644 --- a/packages/proxy/lib/http/util/service-worker-manager.ts +++ b/packages/proxy/lib/http/util/service-worker-manager.ts @@ -8,6 +8,8 @@ const debug = Debug('cypress:proxy:service-worker-manager') type ServiceWorkerRegistration = { registrationId: string scopeURL: string + controlledURLs: string[] + hasFetchHandler: boolean activatedServiceWorker?: ServiceWorker } @@ -15,7 +17,6 @@ type ServiceWorker = { registrationId: string scriptURL: string initiatorOrigin?: string - controlledURLs: Set } type RegisterServiceWorkerOptions = { @@ -37,14 +38,24 @@ type AddInitiatorToServiceWorkerOptions = { initiatorOrigin: string } +type FrameType = 'auxiliary' | 'nested' | 'none' | 'top-level'; + +type Client = { + frameType: FrameType + id: string + type: ClientTypes + url: string +} + export const serviceWorkerClientEventHandlerName = '__cypressServiceWorkerClientEvent' export declare type ServiceWorkerEventsPayload = { 'fetchRequest': { url: string, isControlled: boolean } 'hasFetchHandler': { hasFetchHandler: boolean } + 'clientsUpdated': { clients: Client[] } } -type _ServiceWorkerClientEvent = { type: T, payload: ServiceWorkerEventsPayload[T] } +type _ServiceWorkerClientEvent = { type: T, scope: string, payload: ServiceWorkerEventsPayload[T] } export type ServiceWorkerClientEvent = _ServiceWorkerClientEvent @@ -86,7 +97,6 @@ export class ServiceWorkerManager { private pendingInitiators: Map = new Map() private pendingPotentiallyControlledRequests: Map[]> = new Map[]>() private pendingServiceWorkerFetches: Map = new Map() - private hasFetchHandler = false /** * Goes through the list of service worker registrations and adds or removes them from the manager. @@ -130,6 +140,7 @@ export class ServiceWorkerManager { } if (!initiatorAdded) { + debug('Service worker not activated yet, adding initiator origin to pending list: %s', scriptURL) this.pendingInitiators.set(scriptURL, initiatorOrigin) } } @@ -146,10 +157,13 @@ export class ServiceWorkerManager { this.handleServiceWorkerFetchEvent(event.payload as ServiceWorkerEventsPayload['fetchRequest']) break case 'hasFetchHandler': - this.hasServiceWorkerFetchHandlers(event.payload as ServiceWorkerEventsPayload['hasFetchHandler']) + this.hasServiceWorkerFetchHandlers(event.payload as ServiceWorkerEventsPayload['hasFetchHandler'], event.scope) + break + case 'clientsUpdated': + this.handleClientsUpdatedEvent(event.payload as ServiceWorkerEventsPayload['clientsUpdated'], event.scope) break default: - throw new Error(`Unknown event type: ${event.type}`) + debug('Unknown event type: %o', event) } } @@ -177,10 +191,12 @@ export class ServiceWorkerManager { // we have an activated service worker, the request URL does not come from the service worker, and the request // originates from the same origin as the service worker or from a script that is also controlled by the service worker. if (!activatedServiceWorker || + !registration.hasFetchHandler || + registration.controlledURLs.length === 0 || activatedServiceWorker.scriptURL === paramlessDocumentURL || !activatedServiceWorker.initiatorOrigin || !paramlessDocumentURL.startsWith(activatedServiceWorker.initiatorOrigin)) { - debug('Service worker not activated or request URL is from the service worker, skipping: %o', { activatedServiceWorker, browserPreRequest }) + debug('Service worker not activated or request URL is from the service worker, skipping: %o', { registration, browserPreRequest }) return } @@ -188,35 +204,51 @@ export class ServiceWorkerManager { const paramlessInitiatorURL = browserPreRequest.initiator?.url?.split('?')[0] const paramlessCallStackURL = browserPreRequest.initiator?.stack?.callFrames[0]?.url?.split('?')[0] const urlIsControlled = paramlessURL.startsWith(registration.scopeURL) - const initiatorUrlIsControlled = paramlessInitiatorURL && activatedServiceWorker.controlledURLs?.has(paramlessInitiatorURL) - const topStackUrlIsControlled = paramlessCallStackURL && activatedServiceWorker.controlledURLs?.has(paramlessCallStackURL) + const initiatorUrlIsControlled = paramlessInitiatorURL && registration.controlledURLs?.includes(paramlessInitiatorURL) + const topStackUrlIsControlled = paramlessCallStackURL && registration.controlledURLs?.includes(paramlessCallStackURL) if (urlIsControlled || initiatorUrlIsControlled || topStackUrlIsControlled) { requestPotentiallyControlledByServiceWorker = true } }) - if (activatedServiceWorker && requestPotentiallyControlledByServiceWorker && await this.isURLControlledByServiceWorker(browserPreRequest.url)) { - debug('Request is controlled by service worker: %o', browserPreRequest.url) - activatedServiceWorker.controlledURLs.add(paramlessURL) - - return true - } + let isControlled = false if (activatedServiceWorker) { + if (requestPotentiallyControlledByServiceWorker) { + isControlled = await this.isURLControlledByServiceWorker(paramlessURL) + + if (isControlled) { + debug('Request is controlled by service worker: %o', browserPreRequest.url) + + return true + } + + debug('Request is not controlled by service worker: %o', browserPreRequest.url) + } + debug('Request is not controlled by service worker: %o', browserPreRequest.url) } return false } + private getRegistrationForScope (scope: string) { + return Array.from(this.serviceWorkerRegistrations.values()).find((registration) => registration.scopeURL === scope) + } + /** * Handles a service worker has fetch handlers event. * @param event the service worker has fetch handlers event to handle */ - private hasServiceWorkerFetchHandlers (event: ServiceWorkerEventsPayload['hasFetchHandler']) { - debug('service worker has fetch handlers event called: %o', event) - this.hasFetchHandler = event.hasFetchHandler + private hasServiceWorkerFetchHandlers (event: ServiceWorkerEventsPayload['hasFetchHandler'], scope: string) { + debug('service worker has fetch handlers event called: %o', { event, scope }) + + const registration = this.getRegistrationForScope(scope) + + if (registration) { + registration.hasFetchHandler = event.hasFetchHandler + } } /** @@ -249,16 +281,25 @@ export class ServiceWorkerManager { } } + private handleClientsUpdatedEvent (event: ServiceWorkerEventsPayload['clientsUpdated'], scope: string) { + debug('service worker clients updated event called: %o', event) + + const registration = this.getRegistrationForScope(scope) + + if (registration) { + debug('updating controlled URLs for service worker: %o', { registrationId: registration.registrationId, scope, clients: event.clients }) + registration.controlledURLs = event.clients.map((client) => client.url) + } else { + debug('could not find service worker registration for scope: %s', scope) + } + } + /** * Determines if the given URL is controlled by a service worker. * @param url the URL to check * @returns a promise that resolves to `true` if the URL is controlled by a service worker, `false` otherwise. */ private isURLControlledByServiceWorker (url: string) { - if (!this.hasFetchHandler) { - return false - } - const fetches = this.pendingServiceWorkerFetches.get(url) if (fetches) { @@ -270,7 +311,7 @@ export class ServiceWorkerManager { this.pendingServiceWorkerFetches.delete(url) } - return Promise.resolve(isControlled) + return Promise.resolve(!!isControlled) } let promises = this.pendingPotentiallyControlledRequests.get(url) @@ -295,7 +336,9 @@ export class ServiceWorkerManager { debug('Registering service worker with registration ID %s and scope URL %s', registrationId, scopeURL) // Only register service workers if they haven't already been registered - if (this.serviceWorkerRegistrations.get(registrationId)?.scopeURL === scopeURL) { + const registration = this.serviceWorkerRegistrations.get(registrationId) + + if (registration && registration.scopeURL === scopeURL) { debug('Service worker with registration ID %s and scope URL %s already registered', registrationId, scopeURL) return @@ -304,6 +347,8 @@ export class ServiceWorkerManager { this.serviceWorkerRegistrations.set(registrationId, { registrationId, scopeURL, + controlledURLs: [], + hasFetchHandler: false, }) } @@ -328,7 +373,6 @@ export class ServiceWorkerManager { registration.activatedServiceWorker = { registrationId, scriptURL, - controlledURLs: registration.activatedServiceWorker?.controlledURLs || new Set(), initiatorOrigin: initiatorOrigin || registration.activatedServiceWorker?.initiatorOrigin, } From fe79da56699b7eeb701cc13a5ac65aac6ca81499 Mon Sep 17 00:00:00 2001 From: Matthew Schile Date: Sun, 7 Apr 2024 22:43:21 -0600 Subject: [PATCH 02/31] updating to start handling requests --- .../cypress/fixtures/service-worker.html | 2 +- .../lib/http/util/service-worker-injector.ts | 81 +++---------------- .../lib/http/util/service-worker-manager.ts | 57 +++++++------ 3 files changed, 41 insertions(+), 99 deletions(-) diff --git a/packages/driver/cypress/fixtures/service-worker.html b/packages/driver/cypress/fixtures/service-worker.html index 6ca9ad9eebf5..51a208eebcb0 100644 --- a/packages/driver/cypress/fixtures/service-worker.html +++ b/packages/driver/cypress/fixtures/service-worker.html @@ -4,7 +4,7 @@