diff --git a/src/browser/setup-worker.ts b/src/browser/setup-worker.ts index afa51007c..817747fe6 100644 --- a/src/browser/setup-worker.ts +++ b/src/browser/setup-worker.ts @@ -65,7 +65,7 @@ export function setupWorker(...handlers: Array): SetupWorker { } const httpSource = supportsServiceWorker() - ? new ServiceWorkerSource({ + ? await ServiceWorkerSource.from({ serviceWorker: { url: options?.serviceWorker?.url?.toString() || DEFAULT_WORKER_URL, diff --git a/src/browser/sources/service-worker-source.ts b/src/browser/sources/service-worker-source.ts index d1c9fd963..93046d499 100644 --- a/src/browser/sources/service-worker-source.ts +++ b/src/browser/sources/service-worker-source.ts @@ -21,6 +21,7 @@ import { WorkerChannel } from '../utils/workerChannel' import type { FindWorker } from '../glossary' import { deserializeRequest } from '../utils/deserializeRequest' import { validateWorkerScope } from '../utils/validate-worker-scope' +import { shouldInvalidateWorker } from '../utils/should-invalidate-worker' export interface ServiceWorkerSourceOptions { quiet?: boolean @@ -31,13 +32,13 @@ export interface ServiceWorkerSourceOptions { findWorker?: FindWorker } -type WorkerChannelRequestEvent = Emitter.EventType< +type WorkerChannelRequestEvent = Emitter.Event< WorkerChannel, 'REQUEST', WorkerChannelEventMap > -type WorkerChannelResponseEvent = Emitter.EventType< +type WorkerChannelResponseEvent = Emitter.Event< WorkerChannel, 'RESPONSE', WorkerChannelEventMap @@ -47,8 +48,31 @@ type WorkerChannelClient = WorkerChannelEventMap['MOCKING_ENABLED']['data']['client'] export class ServiceWorkerSource extends NetworkSource { + static #current?: ServiceWorkerSource + + /** + * Create a new Service Worker source or reuse an existing one. + * These sources act as a singleton and only get recreated if the options change. + */ + public static async from( + options: ServiceWorkerSourceOptions, + ): Promise { + if (ServiceWorkerSource.#current == null) { + ServiceWorkerSource.#current = new ServiceWorkerSource(options) + } else if ( + shouldInvalidateWorker(ServiceWorkerSource.#current.#options, options) + ) { + await ServiceWorkerSource.#current.terminate() + ServiceWorkerSource.#current = new ServiceWorkerSource(options) + } + + return ServiceWorkerSource.#current + } + + #options: ServiceWorkerSourceOptions #frames: Map #channel: WorkerChannel + #listenerController?: AbortController #clientPromise?: Promise #keepAliveInterval?: number #stoppedAt?: number @@ -57,7 +81,7 @@ export class ServiceWorkerSource extends NetworkSource - constructor(private readonly options: ServiceWorkerSourceOptions) { + constructor(options: ServiceWorkerSourceOptions) { super() invariant( @@ -65,17 +89,25 @@ export class ServiceWorkerSource extends NetworkSource worker), + getWorker: () => this.workerPromise.then(([worker]) => worker), }) } public async enable(): Promise { - this.#stoppedAt = undefined - - if (this.workerPromise.state !== 'pending') { + /** + * @note The source is considered already running if the worker has been + * resolved AND `stop()` has not been called since. `workerPromise` is NOT + * reset on `disable()` so that the channel's `getWorker()` can keep + * resolving to the registered SW for post-stop passthrough replies. + */ + if ( + this.workerPromise.state === 'fulfilled' && + typeof this.#stoppedAt == 'undefined' + ) { devUtils.warn( 'Found a redundant "worker.start()" call. Note that starting the worker while mocking is already enabled will have no effect. Consider removing this "worker.start()" call.', ) @@ -83,7 +115,11 @@ export class ServiceWorkerSource extends NetworkSource registration) } + this.#stoppedAt = undefined this.#channel.removeAllListeners() + this.#frames.clear() + + this.#listenerController = new AbortController() const [worker, registration] = await this.#startWorker() if (worker.state !== 'activated') { @@ -98,7 +134,9 @@ export class ServiceWorkerSource extends NetworkSource { + if (this.#keepAliveInterval != null) { + clearInterval(this.#keepAliveInterval) + this.#keepAliveInterval = undefined + } + + this.#frames.clear() + this.#channel.terminate() + this.#listenerController?.abort() + this.#listenerController = undefined + + if (this.workerPromise.state === 'fulfilled') { + const [, registration] = await this.workerPromise + await registration.unregister() + } + + if (ServiceWorkerSource.#current === this) { + ServiceWorkerSource.#current = undefined + } + } + async #startWorker() { if (this.#keepAliveInterval) { clearInterval(this.#keepAliveInterval) } - const workerUrl = this.options.serviceWorker.url + const workerUrl = this.#options.serviceWorker.url const [worker, registration] = await getWorkerInstance( workerUrl, - this.options.serviceWorker.options, - this.options.findWorker || this.#defaultFindWorker, + this.#options.serviceWorker.options, + this.#options.findWorker || this.#defaultFindWorker, ) if (worker == null) { - const missingWorkerMessage = this.options?.findWorker + const missingWorkerMessage = this.#options?.findWorker ? devUtils.formatMessage( `Failed to locate the Service Worker registration using a custom "findWorker" predicate. @@ -182,20 +261,37 @@ Please consider using a custom "serviceWorker.url" option to point to the actual throw new Error(missingWorkerMessage) } - this.workerPromise.resolve([worker, registration]) + if (this.workerPromise.state === 'pending') { + this.workerPromise.resolve([worker, registration]) + } else { + /** + * @note Re-enable after `stop()`: the previous `workerPromise` is already + * fulfilled and cannot be resolved again. Swap in a pre-resolved one so + * `getWorker()` sees the new worker instance immediately. + */ + this.workerPromise = new DeferredPromise((resolve) => { + resolve([worker, registration]) + }) + } this.#channel.on('REQUEST', this.#handleRequest.bind(this)) this.#channel.on('RESPONSE', this.#handleResponse.bind(this)) - window.addEventListener('beforeunload', () => { - if (worker.state !== 'redundant') { - this.#channel.postMessage('CLIENT_CLOSED') - } + window.addEventListener( + 'beforeunload', + () => { + if (worker.state !== 'redundant') { + this.#channel.postMessage('CLIENT_CLOSED') + } - clearInterval(this.#keepAliveInterval) + clearInterval(this.#keepAliveInterval) - window.postMessage({ type: 'msw/worker:stop' }) - }) + window.postMessage({ type: 'msw/worker:stop' }) + }, + { + signal: this.#listenerController?.signal, + }, + ) await this.#checkWorkerIntegrity().catch((error) => { devUtils.error( @@ -208,7 +304,7 @@ Please consider using a custom "serviceWorker.url" option to point to the actual this.#channel.postMessage('KEEPALIVE_REQUEST') }, 5000) - if (!this.options.quiet) { + if (!this.#options.quiet) { validateWorkerScope(registration) } diff --git a/src/browser/utils/should-invalidate-worker.test.ts b/src/browser/utils/should-invalidate-worker.test.ts new file mode 100644 index 000000000..1edbb2d28 --- /dev/null +++ b/src/browser/utils/should-invalidate-worker.test.ts @@ -0,0 +1,122 @@ +import { type ServiceWorkerSourceOptions } from '../sources/service-worker-source' +import { shouldInvalidateWorker } from './should-invalidate-worker' + +function createOptions( + overrides: Partial = {}, +): ServiceWorkerSourceOptions { + return { + serviceWorker: { + url: '/mockServiceWorker.js', + options: { scope: '/' }, + }, + ...overrides, + } +} + +it('returns true when the worker url differs', () => { + expect( + shouldInvalidateWorker( + createOptions({ + serviceWorker: { url: '/a.js', options: { scope: '/' } }, + }), + createOptions({ + serviceWorker: { url: '/b.js', options: { scope: '/' } }, + }), + ), + ).toBe(true) +}) + +it('returns true when the registration options differ', () => { + expect( + shouldInvalidateWorker( + createOptions({ + serviceWorker: { url: '/sw.js', options: { scope: '/' } }, + }), + createOptions({ + serviceWorker: { url: '/sw.js', options: { scope: '/app' } }, + }), + ), + ).toBe(true) +}) + +it('returns true when only one side has registration options', () => { + expect( + shouldInvalidateWorker( + createOptions({ serviceWorker: { url: '/sw.js' } }), + createOptions({ + serviceWorker: { url: '/sw.js', options: { scope: '/' } }, + }), + ), + ).toBe(true) +}) + +it('returns true when findWorker differs by reference', () => { + expect( + shouldInvalidateWorker( + createOptions({ findWorker: () => true }), + createOptions({ findWorker: () => true }), + ), + ).toBe(true) +}) + +it('returns true when findWorker is added on one side', () => { + expect( + shouldInvalidateWorker( + createOptions(), + createOptions({ findWorker: () => true }), + ), + ).toBe(true) +}) + +it('returns false for the same options reference', () => { + const options = createOptions() + expect(shouldInvalidateWorker(options, options)).toBe(false) +}) + +it('returns false for deeply equal options', () => { + expect( + shouldInvalidateWorker( + createOptions({ + serviceWorker: { url: '/sw.js', options: { scope: '/' } }, + }), + createOptions({ + serviceWorker: { url: '/sw.js', options: { scope: '/' } }, + }), + ), + ).toBe(false) +}) + +it('returns false for the same worker url without options', () => { + expect( + shouldInvalidateWorker( + createOptions({ serviceWorker: { url: '/sw.js' } }), + createOptions({ serviceWorker: { url: '/sw.js' } }), + ), + ).toBe(false) +}) + +it('returns false when findWorker is the same reference', () => { + const findWorker = () => true + expect( + shouldInvalidateWorker( + createOptions({ findWorker }), + createOptions({ findWorker }), + ), + ).toBe(false) +}) + +it('returns false regardless of the "quiet" option', () => { + expect( + shouldInvalidateWorker( + createOptions({ quiet: true }), + createOptions({ quiet: true }), + ), + ).toBe(false) + + expect( + shouldInvalidateWorker( + createOptions({ quiet: false }), + createOptions({ quiet: true }), + ), + ).toBe(false) +}) diff --git a/src/browser/utils/should-invalidate-worker.ts b/src/browser/utils/should-invalidate-worker.ts new file mode 100644 index 000000000..c2f74443e --- /dev/null +++ b/src/browser/utils/should-invalidate-worker.ts @@ -0,0 +1,13 @@ +import { type ServiceWorkerSourceOptions } from '../sources/service-worker-source' + +export function shouldInvalidateWorker( + prevOptions: ServiceWorkerSourceOptions, + nextOptions: ServiceWorkerSourceOptions, +): boolean { + return ( + prevOptions.findWorker !== nextOptions.findWorker || + prevOptions.serviceWorker.url !== nextOptions.serviceWorker.url || + JSON.stringify(prevOptions.serviceWorker.options) !== + JSON.stringify(nextOptions.serviceWorker.options) + ) +} diff --git a/src/browser/utils/workerChannel.ts b/src/browser/utils/workerChannel.ts index 7b39f7114..11cbdc23b 100644 --- a/src/browser/utils/workerChannel.ts +++ b/src/browser/utils/workerChannel.ts @@ -4,10 +4,6 @@ import { isObject } from '#core/utils/internal/isObject' import type { StringifiedResponse } from '../glossary' import { supportsServiceWorker } from '../utils/supports' -export interface WorkerChannelOptions { - worker: Promise -} - export type WorkerChannelEventMap = { REQUEST: WorkerEvent RESPONSE: WorkerEvent @@ -121,25 +117,42 @@ type OutgoingWorkerEvents = | 'KEEPALIVE_REQUEST' | 'CLIENT_CLOSED' -export class WorkerChannel extends Emitter { - constructor(protected readonly options: WorkerChannelOptions) { - super() +export interface WorkerChannelOptions { + getWorker: () => Promise +} - if (!SUPPORTS_SERVICE_WORKER) { - return - } +export class WorkerChannel extends Emitter { + #getWorker: WorkerChannelOptions['getWorker'] + #controller: AbortController - navigator.serviceWorker.addEventListener('message', async (event) => { - const worker = await this.options.worker + constructor(options: WorkerChannelOptions) { + super() - if (event.source != null && event.source !== worker) { - return - } + invariant( + SUPPORTS_SERVICE_WORKER, + 'Failed to open a WorkerChannel: Service Worker is not supported in this environment.', + ) - if (event.data && isObject(event.data) && 'type' in event.data) { - this.emit(new WorkerEvent(event)) - } - }) + this.#getWorker = options.getWorker + this.#controller = new AbortController() + + navigator.serviceWorker.addEventListener( + 'message', + async (event) => { + const worker = await this.#getWorker() + + if (event.source != null && event.source !== worker) { + return + } + + if (event.data && isObject(event.data) && 'type' in event.data) { + this.emit(new WorkerEvent(event)) + } + }, + { + signal: this.#controller.signal, + }, + ) } /** @@ -149,11 +162,20 @@ export class WorkerChannel extends Emitter { public postMessage(type: OutgoingWorkerEvents): void { invariant( SUPPORTS_SERVICE_WORKER, - 'Failed to post message on a WorkerChannel: the Service Worker API is unavailable in this context. This is likely an issue with MSW. Please report it on GitHub: https://github.com/mswjs/msw/issues', + 'Failed to post message on a WorkerChannel: the Service Worker API is unavailable in this environment. This is likely an issue with MSW. Please report it on GitHub: https://github.com/mswjs/msw/issues', ) - this.options.worker.then((worker) => { + this.#getWorker().then((worker) => { worker.postMessage(type) }) } + + /** + * Terminal teardown. Removes the `navigator.serviceWorker` message listener + * and all emitter subscriptions. The channel is not usable afterwards. + */ + public terminate(): void { + this.#controller.abort() + this.removeAllListeners() + } } diff --git a/src/core/experimental/define-network.ts b/src/core/experimental/define-network.ts index 09fef4279..91ddbede2 100644 --- a/src/core/experimental/define-network.ts +++ b/src/core/experimental/define-network.ts @@ -1,7 +1,7 @@ import { invariant } from 'outvariant' import { Emitter, type DefaultEventMap } from 'rettime' import { - type NetworkSource, + NetworkSource, type ExtractSourceEvents, } from './sources/network-source' import { type NetworkFrameResolutionContext } from './frames/network-frame' @@ -161,6 +161,14 @@ export function defineNetwork>>( readyState = NetworkReadyState.ENABLED const result = resolvedOptions.sources.map((source) => { + /** + * @note Preemptively disable the network source before enabling. + * This intentionally calls only the prototype method that clears the + * event listeners and nothing else. This prevents the "frame" listeners + * from accumulating across enable/disable in case the source is a singleton. + */ + NetworkSource.prototype.disable.call(source) + source.on('frame', async ({ frame }) => { frame.events.on('*', (event) => events.emit(event), { signal: listenersController.signal, diff --git a/test/browser/msw-api/setup-worker/start/handlers-reset.mocks.ts b/test/browser/msw-api/setup-worker/start/handlers-reset.mocks.ts new file mode 100644 index 000000000..a3699fd98 --- /dev/null +++ b/test/browser/msw-api/setup-worker/start/handlers-reset.mocks.ts @@ -0,0 +1,17 @@ +import { http, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' + +let callCount = 0 + +const worker = setupWorker( + http.get('/resource', () => { + callCount++ + return HttpResponse.json({ callCount }) + }), +) + +worker.start() + +window.msw = { + worker, +} diff --git a/test/browser/msw-api/setup-worker/start/handlers-reset.test.ts b/test/browser/msw-api/setup-worker/start/handlers-reset.test.ts new file mode 100644 index 000000000..830332220 --- /dev/null +++ b/test/browser/msw-api/setup-worker/start/handlers-reset.test.ts @@ -0,0 +1,45 @@ +/** + * @see https://github.com/mswjs/msw/issues/2714 + */ +import { test, expect } from '../../../playwright.extend' + +declare global { + interface Window { + msw: { + worker: import('msw/browser').SetupWorker + } + } +} + +test('does not accumulate request handlers across restarts', async ({ + loadExample, + page, + fetch, +}) => { + await loadExample(new URL('./handlers-reset.mocks.ts', import.meta.url)) + + // First request after the initial start. + const firstResponse = await fetch('/resource') + await expect(firstResponse.json()).resolves.toEqual({ callCount: 1 }) + + // Stop and restart the worker. + await page.evaluate(async () => { + window.msw.worker.stop() + await window.msw.worker.start({ quiet: true }) + }) + + // Second request after the first restart. + // The handler must be called exactly once per request. + const secondResponse = await fetch('/resource') + await expect(secondResponse.json()).resolves.toEqual({ callCount: 2 }) + + // Stop and restart the worker again. + await page.evaluate(async () => { + window.msw.worker.stop() + await window.msw.worker.start({ quiet: true }) + }) + + // Third request after the second restart. + const thirdResponse = await fetch('/resource') + await expect(thirdResponse.json()).resolves.toEqual({ callCount: 3 }) +}) diff --git a/test/browser/msw-api/setup-worker/start/start-after-stop.mocks.ts b/test/browser/msw-api/setup-worker/start/start-after-stop.mocks.ts new file mode 100644 index 000000000..aebc7164a --- /dev/null +++ b/test/browser/msw-api/setup-worker/start/start-after-stop.mocks.ts @@ -0,0 +1,14 @@ +import { http, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' + +const worker = setupWorker( + http.get('/resource', () => { + return HttpResponse.text('hello world') + }), +) + +worker.start() + +window.msw = { + worker, +} diff --git a/test/browser/msw-api/setup-worker/start/start-after-stop.test.ts b/test/browser/msw-api/setup-worker/start/start-after-stop.test.ts new file mode 100644 index 000000000..1fe8bb6a4 --- /dev/null +++ b/test/browser/msw-api/setup-worker/start/start-after-stop.test.ts @@ -0,0 +1,42 @@ +/** + * @see https://github.com/mswjs/msw/issues/2714 + */ +import { test, expect } from '../../../playwright.extend' + +declare global { + interface Window { + msw: { + worker: import('msw/browser').SetupWorker + } + } +} + +test('handles requests after starting a stopped worker', async ({ + loadExample, + page, + fetch, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(new URL('./start-after-stop.mocks.ts', import.meta.url)) + + await page.evaluate(() => { + window.msw.worker.stop() + }) + + expect(consoleSpy.get('log')).toEqual( + expect.arrayContaining(['[MSW] Mocking disabled.']), + ) + + await page.evaluate(async () => { + await window.msw.worker.start() + }) + + expect(consoleSpy.get('startGroupCollapsed')).toEqual( + expect.arrayContaining(['[MSW] Mocking enabled.']), + ) + + const response = await fetch('/resource') + expect.soft(response.status()).toBe(200) + await expect(response.text()).resolves.toBe('hello world') +}) diff --git a/test/browser/msw-api/setup-worker/stop.test.ts b/test/browser/msw-api/setup-worker/stop.test.ts index 6e6180c91..b869ec031 100644 --- a/test/browser/msw-api/setup-worker/stop.test.ts +++ b/test/browser/msw-api/setup-worker/stop.test.ts @@ -62,7 +62,11 @@ test('disables the mocking when the worker is stopped', async ({ const response = await fetch(server.http.url('/resource')) - expect.soft(response.fromServiceWorker()).toBe(true) + /** + * @note Currently, "worker.stop()" sends "CLIENT_CLOSED". + * Since it's the only active client, the worker will self-destruct. + */ + expect.soft(response.fromServiceWorker()).toBe(false) await expect(response.json()).resolves.toEqual({ original: true }) })