Skip to content

Commit 580256b

Browse files
authored
fix: implement proper ServiceWorkerSource singleton pattern (#2715)
1 parent 9dedf52 commit 580256b

11 files changed

Lines changed: 432 additions & 49 deletions

src/browser/setup-worker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export function setupWorker(...handlers: Array<AnyHandler>): SetupWorker {
6565
}
6666

6767
const httpSource = supportsServiceWorker()
68-
? new ServiceWorkerSource({
68+
? await ServiceWorkerSource.from({
6969
serviceWorker: {
7070
url:
7171
options?.serviceWorker?.url?.toString() || DEFAULT_WORKER_URL,

src/browser/sources/service-worker-source.ts

Lines changed: 121 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { WorkerChannel } from '../utils/workerChannel'
2121
import type { FindWorker } from '../glossary'
2222
import { deserializeRequest } from '../utils/deserializeRequest'
2323
import { validateWorkerScope } from '../utils/validate-worker-scope'
24+
import { shouldInvalidateWorker } from '../utils/should-invalidate-worker'
2425

2526
export 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

4950
export 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

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { type ServiceWorkerSourceOptions } from '../sources/service-worker-source'
2+
import { shouldInvalidateWorker } from './should-invalidate-worker'
3+
4+
function createOptions(
5+
overrides: Partial<ServiceWorkerSourceOptions> = {},
6+
): ServiceWorkerSourceOptions {
7+
return {
8+
serviceWorker: {
9+
url: '/mockServiceWorker.js',
10+
options: { scope: '/' },
11+
},
12+
...overrides,
13+
}
14+
}
15+
16+
it('returns true when the worker url differs', () => {
17+
expect(
18+
shouldInvalidateWorker(
19+
createOptions({
20+
serviceWorker: { url: '/a.js', options: { scope: '/' } },
21+
}),
22+
createOptions({
23+
serviceWorker: { url: '/b.js', options: { scope: '/' } },
24+
}),
25+
),
26+
).toBe(true)
27+
})
28+
29+
it('returns true when the registration options differ', () => {
30+
expect(
31+
shouldInvalidateWorker(
32+
createOptions({
33+
serviceWorker: { url: '/sw.js', options: { scope: '/' } },
34+
}),
35+
createOptions({
36+
serviceWorker: { url: '/sw.js', options: { scope: '/app' } },
37+
}),
38+
),
39+
).toBe(true)
40+
})
41+
42+
it('returns true when only one side has registration options', () => {
43+
expect(
44+
shouldInvalidateWorker(
45+
createOptions({ serviceWorker: { url: '/sw.js' } }),
46+
createOptions({
47+
serviceWorker: { url: '/sw.js', options: { scope: '/' } },
48+
}),
49+
),
50+
).toBe(true)
51+
})
52+
53+
it('returns true when findWorker differs by reference', () => {
54+
expect(
55+
shouldInvalidateWorker(
56+
createOptions({ findWorker: () => true }),
57+
createOptions({ findWorker: () => true }),
58+
),
59+
).toBe(true)
60+
})
61+
62+
it('returns true when findWorker is added on one side', () => {
63+
expect(
64+
shouldInvalidateWorker(
65+
createOptions(),
66+
createOptions({ findWorker: () => true }),
67+
),
68+
).toBe(true)
69+
})
70+
71+
it('returns false for the same options reference', () => {
72+
const options = createOptions()
73+
expect(shouldInvalidateWorker(options, options)).toBe(false)
74+
})
75+
76+
it('returns false for deeply equal options', () => {
77+
expect(
78+
shouldInvalidateWorker(
79+
createOptions({
80+
serviceWorker: { url: '/sw.js', options: { scope: '/' } },
81+
}),
82+
createOptions({
83+
serviceWorker: { url: '/sw.js', options: { scope: '/' } },
84+
}),
85+
),
86+
).toBe(false)
87+
})
88+
89+
it('returns false for the same worker url without options', () => {
90+
expect(
91+
shouldInvalidateWorker(
92+
createOptions({ serviceWorker: { url: '/sw.js' } }),
93+
createOptions({ serviceWorker: { url: '/sw.js' } }),
94+
),
95+
).toBe(false)
96+
})
97+
98+
it('returns false when findWorker is the same reference', () => {
99+
const findWorker = () => true
100+
expect(
101+
shouldInvalidateWorker(
102+
createOptions({ findWorker }),
103+
createOptions({ findWorker }),
104+
),
105+
).toBe(false)
106+
})
107+
108+
it('returns false regardless of the "quiet" option', () => {
109+
expect(
110+
shouldInvalidateWorker(
111+
createOptions({ quiet: true }),
112+
createOptions({ quiet: true }),
113+
),
114+
).toBe(false)
115+
116+
expect(
117+
shouldInvalidateWorker(
118+
createOptions({ quiet: false }),
119+
createOptions({ quiet: true }),
120+
),
121+
).toBe(false)
122+
})

0 commit comments

Comments
 (0)