@@ -21,6 +21,7 @@ import { WorkerChannel } from '../utils/workerChannel'
2121import type { FindWorker } from '../glossary'
2222import { deserializeRequest } from '../utils/deserializeRequest'
2323import { validateWorkerScope } from '../utils/validate-worker-scope'
24+ import { shouldInvalidateWorker } from '../utils/should-invalidate-worker'
2425
2526export interface ServiceWorkerSourceOptions {
2627 quiet ?: boolean
@@ -31,13 +32,13 @@ export interface ServiceWorkerSourceOptions {
3132 findWorker ?: FindWorker
3233}
3334
34- type WorkerChannelRequestEvent = Emitter . EventType <
35+ type WorkerChannelRequestEvent = Emitter . Event <
3536 WorkerChannel ,
3637 'REQUEST' ,
3738 WorkerChannelEventMap
3839>
3940
40- type WorkerChannelResponseEvent = Emitter . EventType <
41+ type WorkerChannelResponseEvent = Emitter . Event <
4142 WorkerChannel ,
4243 'RESPONSE' ,
4344 WorkerChannelEventMap
@@ -47,8 +48,31 @@ type WorkerChannelClient =
4748 WorkerChannelEventMap [ 'MOCKING_ENABLED' ] [ 'data' ] [ 'client' ]
4849
4950export class ServiceWorkerSource extends NetworkSource < ServiceWorkerHttpNetworkFrame > {
51+ static #current?: ServiceWorkerSource
52+
53+ /**
54+ * Create a new Service Worker source or reuse an existing one.
55+ * These sources act as a singleton and only get recreated if the options change.
56+ */
57+ public static async from (
58+ options : ServiceWorkerSourceOptions ,
59+ ) : Promise < ServiceWorkerSource > {
60+ if ( ServiceWorkerSource . #current == null ) {
61+ ServiceWorkerSource . #current = new ServiceWorkerSource ( options )
62+ } else if (
63+ shouldInvalidateWorker ( ServiceWorkerSource . #current. #options, options )
64+ ) {
65+ await ServiceWorkerSource . #current. terminate ( )
66+ ServiceWorkerSource . #current = new ServiceWorkerSource ( options )
67+ }
68+
69+ return ServiceWorkerSource . #current
70+ }
71+
72+ #options: ServiceWorkerSourceOptions
5073 #frames: Map < string , ServiceWorkerHttpNetworkFrame >
5174 #channel: WorkerChannel
75+ #listenerController?: AbortController
5276 #clientPromise?: Promise < WorkerChannelClient >
5377 #keepAliveInterval?: number
5478 #stoppedAt?: number
@@ -57,33 +81,45 @@ export class ServiceWorkerSource extends NetworkSource<ServiceWorkerHttpNetworkF
5781 [ ServiceWorker , ServiceWorkerRegistration ]
5882 >
5983
60- constructor ( private readonly options : ServiceWorkerSourceOptions ) {
84+ constructor ( options : ServiceWorkerSourceOptions ) {
6185 super ( )
6286
6387 invariant (
6488 supportsServiceWorker ( ) ,
6589 'Failed to use Service Worker as the network source: the Service Worker API is not supported in this environment' ,
6690 )
6791
92+ this . #options = options
6893 this . #frames = new Map ( )
6994 this . workerPromise = new DeferredPromise ( )
7095 this . #channel = new WorkerChannel ( {
71- worker : this . workerPromise . then ( ( [ worker ] ) => worker ) ,
96+ getWorker : ( ) => this . workerPromise . then ( ( [ worker ] ) => worker ) ,
7297 } )
7398 }
7499
75100 public async enable ( ) : Promise < ServiceWorkerRegistration > {
76- this . #stoppedAt = undefined
77-
78- if ( this . workerPromise . state !== 'pending' ) {
101+ /**
102+ * @note The source is considered already running if the worker has been
103+ * resolved AND `stop()` has not been called since. `workerPromise` is NOT
104+ * reset on `disable()` so that the channel's `getWorker()` can keep
105+ * resolving to the registered SW for post-stop passthrough replies.
106+ */
107+ if (
108+ this . workerPromise . state === 'fulfilled' &&
109+ typeof this . #stoppedAt == 'undefined'
110+ ) {
79111 devUtils . warn (
80112 '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.' ,
81113 )
82114
83115 return this . workerPromise . then ( ( [ , registration ] ) => registration )
84116 }
85117
118+ this . #stoppedAt = undefined
86119 this . #channel. removeAllListeners ( )
120+ this . #frames. clear ( )
121+
122+ this . #listenerController = new AbortController ( )
87123 const [ worker , registration ] = await this . #startWorker( )
88124
89125 if ( worker . state !== 'activated' ) {
@@ -98,7 +134,9 @@ export class ServiceWorkerSource extends NetworkSource<ServiceWorkerHttpNetworkF
98134 activationPromise . resolve ( )
99135 }
100136 } ,
101- { signal : controller . signal } ,
137+ {
138+ signal : controller . signal ,
139+ } ,
102140 )
103141
104142 await activationPromise
@@ -114,7 +152,7 @@ export class ServiceWorkerSource extends NetworkSource<ServiceWorkerHttpNetworkF
114152 } )
115153 await clientConfirmationPromise
116154
117- if ( ! this . options . quiet ) {
155+ if ( ! this . # options. quiet ) {
118156 this . #printStartMessage( )
119157 }
120158
@@ -138,29 +176,70 @@ export class ServiceWorkerSource extends NetworkSource<ServiceWorkerHttpNetworkF
138176 }
139177
140178 this . #stoppedAt = Date . now ( )
141- this . #frames. clear ( )
142- this . workerPromise = new DeferredPromise ( )
143179
144- if ( ! this . options . quiet ) {
180+ this . #listenerController?. abort ( )
181+ this . #listenerController = undefined
182+
183+ /**
184+ * @note Tell the Service Worker to drop this client from its active set
185+ * so it stops forwarding REQUEST events here. `stoppedAt` still guards
186+ * any requests the SW already forwarded before this message arrived.
187+ */
188+ this . #channel. postMessage ( 'CLIENT_CLOSED' )
189+
190+ /**
191+ * @note Do NOT reset `workerPromise` here. The channel must continue to
192+ * resolve the currently registered SW so that any in-flight requests the
193+ * worker forwards after `stop()` can be answered with `PASSTHROUGH` by
194+ * `#handleRequest`. `#startWorker` swaps in a fresh deferred on re-enable.
195+ */
196+
197+ if ( ! this . #options. quiet ) {
145198 this . #printStopMessage( )
146199 }
147200 }
148201
202+ /**
203+ * Terminal teardown. Unregisters the Service Worker, tears down the channel,
204+ * and clears timers. Called when the singleton is being replaced with one
205+ * that has different options. The instance is not usable afterwards.
206+ */
207+ public async terminate ( ) : Promise < void > {
208+ if ( this . #keepAliveInterval != null ) {
209+ clearInterval ( this . #keepAliveInterval)
210+ this . #keepAliveInterval = undefined
211+ }
212+
213+ this . #frames. clear ( )
214+ this . #channel. terminate ( )
215+ this . #listenerController?. abort ( )
216+ this . #listenerController = undefined
217+
218+ if ( this . workerPromise . state === 'fulfilled' ) {
219+ const [ , registration ] = await this . workerPromise
220+ await registration . unregister ( )
221+ }
222+
223+ if ( ServiceWorkerSource . #current === this ) {
224+ ServiceWorkerSource . #current = undefined
225+ }
226+ }
227+
149228 async #startWorker( ) {
150229 if ( this . #keepAliveInterval) {
151230 clearInterval ( this . #keepAliveInterval)
152231 }
153232
154- const workerUrl = this . options . serviceWorker . url
233+ const workerUrl = this . # options. serviceWorker . url
155234
156235 const [ worker , registration ] = await getWorkerInstance (
157236 workerUrl ,
158- this . options . serviceWorker . options ,
159- this . options . findWorker || this . #defaultFindWorker,
237+ this . # options. serviceWorker . options ,
238+ this . # options. findWorker || this . #defaultFindWorker,
160239 )
161240
162241 if ( worker == null ) {
163- const missingWorkerMessage = this . options ?. findWorker
242+ const missingWorkerMessage = this . # options?. findWorker
164243 ? devUtils . formatMessage (
165244 `Failed to locate the Service Worker registration using a custom "findWorker" predicate.
166245
@@ -182,20 +261,37 @@ Please consider using a custom "serviceWorker.url" option to point to the actual
182261 throw new Error ( missingWorkerMessage )
183262 }
184263
185- this . workerPromise . resolve ( [ worker , registration ] )
264+ if ( this . workerPromise . state === 'pending' ) {
265+ this . workerPromise . resolve ( [ worker , registration ] )
266+ } else {
267+ /**
268+ * @note Re-enable after `stop()`: the previous `workerPromise` is already
269+ * fulfilled and cannot be resolved again. Swap in a pre-resolved one so
270+ * `getWorker()` sees the new worker instance immediately.
271+ */
272+ this . workerPromise = new DeferredPromise ( ( resolve ) => {
273+ resolve ( [ worker , registration ] )
274+ } )
275+ }
186276
187277 this . #channel. on ( 'REQUEST' , this . #handleRequest. bind ( this ) )
188278 this . #channel. on ( 'RESPONSE' , this . #handleResponse. bind ( this ) )
189279
190- window . addEventListener ( 'beforeunload' , ( ) => {
191- if ( worker . state !== 'redundant' ) {
192- this . #channel. postMessage ( 'CLIENT_CLOSED' )
193- }
280+ window . addEventListener (
281+ 'beforeunload' ,
282+ ( ) => {
283+ if ( worker . state !== 'redundant' ) {
284+ this . #channel. postMessage ( 'CLIENT_CLOSED' )
285+ }
194286
195- clearInterval ( this . #keepAliveInterval)
287+ clearInterval ( this . #keepAliveInterval)
196288
197- window . postMessage ( { type : 'msw/worker:stop' } )
198- } )
289+ window . postMessage ( { type : 'msw/worker:stop' } )
290+ } ,
291+ {
292+ signal : this . #listenerController?. signal ,
293+ } ,
294+ )
199295
200296 await this . #checkWorkerIntegrity( ) . catch ( ( error ) => {
201297 devUtils . error (
@@ -208,7 +304,7 @@ Please consider using a custom "serviceWorker.url" option to point to the actual
208304 this . #channel. postMessage ( 'KEEPALIVE_REQUEST' )
209305 } , 5000 )
210306
211- if ( ! this . options . quiet ) {
307+ if ( ! this . # options. quiet ) {
212308 validateWorkerScope ( registration )
213309 }
214310
0 commit comments