msw-fetch-mock 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 recca0120
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # msw-fetch-mock
2
+
3
+ [![CI](https://github.com/recca0120/msw-fetch-mock/actions/workflows/ci.yml/badge.svg)](https://github.com/recca0120/msw-fetch-mock/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/msw-fetch-mock.svg)](https://www.npmjs.com/package/msw-fetch-mock)
5
+ [![license](https://img.shields.io/npm/l/msw-fetch-mock.svg)](https://github.com/recca0120/msw-fetch-mock/blob/main/LICENSE.md)
6
+
7
+ Undici-style fetch mock API built on [MSW](https://mswjs.io/) (Mock Service Worker).
8
+
9
+ If you're familiar with Cloudflare Workers' `fetchMock` (from `cloudflare:test`) or Node.js undici's `MockAgent`, you already know this API.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install -D msw-fetch-mock msw
15
+ ```
16
+
17
+ `msw` is a peer dependency — you provide your own version.
18
+
19
+ ## Quick Start
20
+
21
+ ```typescript
22
+ import { setupServer } from 'msw/node';
23
+ import { createFetchMock } from 'msw-fetch-mock';
24
+
25
+ const server = setupServer();
26
+ const fetchMock = createFetchMock(server);
27
+
28
+ beforeAll(() => fetchMock.activate());
29
+ afterAll(() => fetchMock.deactivate());
30
+ afterEach(() => {
31
+ fetchMock.clearCallHistory();
32
+ fetchMock.assertNoPendingInterceptors();
33
+ });
34
+
35
+ it('mocks a GET request', async () => {
36
+ fetchMock
37
+ .get('https://api.example.com')
38
+ .intercept({ path: '/users', method: 'GET' })
39
+ .reply(200, { users: [{ id: '1', name: 'Alice' }] });
40
+
41
+ const res = await fetch('https://api.example.com/users');
42
+ const data = await res.json();
43
+
44
+ expect(data.users).toHaveLength(1);
45
+ });
46
+ ```
47
+
48
+ ## API Overview
49
+
50
+ ### `createFetchMock(server?)`
51
+
52
+ Creates a `FetchMock` instance. Optionally accepts an existing MSW `SetupServer`; creates one internally if omitted.
53
+
54
+ ### Intercepting & Replying
55
+
56
+ ```typescript
57
+ fetchMock
58
+ .get(origin) // select origin
59
+ .intercept({ path, method, headers, body, query }) // match criteria
60
+ .reply(status, body, options) // define response
61
+ .times(n) / .persist(); // repeat control
62
+ ```
63
+
64
+ ### Call History
65
+
66
+ ```typescript
67
+ const history = fetchMock.getCallHistory();
68
+ history.calls(); // all calls
69
+ history.lastCall(); // most recent
70
+ history.firstCall(); // earliest
71
+ history.nthCall(2); // 2nd call (1-indexed)
72
+ history.filterCalls({ method: 'POST', path: '/users' }, { operator: 'AND' });
73
+ ```
74
+
75
+ ### Assertions
76
+
77
+ ```typescript
78
+ fetchMock.assertNoPendingInterceptors(); // throws if unconsumed interceptors remain
79
+ ```
80
+
81
+ ## Documentation
82
+
83
+ - [API Reference](docs/api.md) — full API details, matching options, reply callbacks
84
+ - [Cloudflare Workers Migration](docs/cloudflare-migration.md) — migrating from `cloudflare:test` fetchMock
85
+
86
+ ## License
87
+
88
+ [MIT](LICENSE.md)
@@ -0,0 +1,5 @@
1
+ export { createFetchMock, FetchMock } from './mock-server';
2
+ export type { InterceptOptions, MockPool, MockInterceptor, MockReplyChain, ReplyOptions, PendingInterceptor, } from './mock-server';
3
+ export { MockCallHistory, MockCallHistoryLog } from './mock-call-history';
4
+ export type { MockCallHistoryLogData, CallHistoryFilterCriteria } from './mock-call-history';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAC3D,YAAY,EACV,gBAAgB,EAChB,QAAQ,EACR,eAAe,EACf,cAAc,EACd,YAAY,EACZ,kBAAkB,GACnB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAC1E,YAAY,EAAE,sBAAsB,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { createFetchMock, FetchMock } from './mock-server';
2
+ export { MockCallHistory, MockCallHistoryLog } from './mock-call-history';
@@ -0,0 +1,65 @@
1
+ export interface MockCallHistoryLogData {
2
+ body: string | null;
3
+ method: string;
4
+ headers: Record<string, string>;
5
+ fullUrl: string;
6
+ origin: string;
7
+ path: string;
8
+ searchParams: Record<string, string>;
9
+ protocol: string;
10
+ host: string;
11
+ port: string;
12
+ hash: string;
13
+ }
14
+ export declare class MockCallHistoryLog {
15
+ readonly body: string | null;
16
+ readonly method: string;
17
+ readonly headers: Record<string, string>;
18
+ readonly fullUrl: string;
19
+ readonly origin: string;
20
+ readonly path: string;
21
+ readonly searchParams: Record<string, string>;
22
+ readonly protocol: string;
23
+ readonly host: string;
24
+ readonly port: string;
25
+ readonly hash: string;
26
+ constructor(data: MockCallHistoryLogData);
27
+ json(): unknown;
28
+ toMap(): Map<string, string | null | Record<string, string>>;
29
+ toString(): string;
30
+ }
31
+ export interface CallHistoryFilterCriteria {
32
+ method?: string;
33
+ path?: string;
34
+ origin?: string;
35
+ protocol?: string;
36
+ host?: string;
37
+ port?: string;
38
+ hash?: string;
39
+ fullUrl?: string;
40
+ }
41
+ export declare class MockCallHistory {
42
+ private logs;
43
+ get length(): number;
44
+ record(data: MockCallHistoryLogData): void;
45
+ called(criteria?: ((log: MockCallHistoryLog) => boolean) | CallHistoryFilterCriteria | RegExp): boolean;
46
+ calls(): MockCallHistoryLog[];
47
+ firstCall(criteria?: ((log: MockCallHistoryLog) => boolean) | CallHistoryFilterCriteria | RegExp): MockCallHistoryLog | undefined;
48
+ lastCall(criteria?: ((log: MockCallHistoryLog) => boolean) | CallHistoryFilterCriteria | RegExp): MockCallHistoryLog | undefined;
49
+ nthCall(n: number, criteria?: ((log: MockCallHistoryLog) => boolean) | CallHistoryFilterCriteria | RegExp): MockCallHistoryLog | undefined;
50
+ clear(): void;
51
+ [Symbol.iterator](): Iterator<MockCallHistoryLog>;
52
+ filterCalls(criteria: ((log: MockCallHistoryLog) => boolean) | CallHistoryFilterCriteria | RegExp, options?: {
53
+ operator?: 'AND' | 'OR';
54
+ }): MockCallHistoryLog[];
55
+ private filterBy;
56
+ filterCallsByMethod(filter: string | RegExp): MockCallHistoryLog[];
57
+ filterCallsByPath(filter: string | RegExp): MockCallHistoryLog[];
58
+ filterCallsByOrigin(filter: string | RegExp): MockCallHistoryLog[];
59
+ filterCallsByProtocol(filter: string | RegExp): MockCallHistoryLog[];
60
+ filterCallsByHost(filter: string | RegExp): MockCallHistoryLog[];
61
+ filterCallsByPort(filter: string | RegExp): MockCallHistoryLog[];
62
+ filterCallsByHash(filter: string | RegExp): MockCallHistoryLog[];
63
+ filterCallsByFullUrl(filter: string | RegExp): MockCallHistoryLog[];
64
+ }
65
+ //# sourceMappingURL=mock-call-history.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mock-call-history.d.ts","sourceRoot":"","sources":["../src/mock-call-history.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,140 @@
1
+ export class MockCallHistoryLog {
2
+ body;
3
+ method;
4
+ headers;
5
+ fullUrl;
6
+ origin;
7
+ path;
8
+ searchParams;
9
+ protocol;
10
+ host;
11
+ port;
12
+ hash;
13
+ constructor(data) {
14
+ this.body = data.body;
15
+ this.method = data.method;
16
+ this.headers = data.headers;
17
+ this.fullUrl = data.fullUrl;
18
+ this.origin = data.origin;
19
+ this.path = data.path;
20
+ this.searchParams = data.searchParams;
21
+ this.protocol = data.protocol;
22
+ this.host = data.host;
23
+ this.port = data.port;
24
+ this.hash = data.hash;
25
+ }
26
+ json() {
27
+ if (this.body === null)
28
+ return null;
29
+ return JSON.parse(this.body);
30
+ }
31
+ toMap() {
32
+ return new Map([
33
+ ['body', this.body],
34
+ ['method', this.method],
35
+ ['headers', this.headers],
36
+ ['fullUrl', this.fullUrl],
37
+ ['origin', this.origin],
38
+ ['path', this.path],
39
+ ['searchParams', this.searchParams],
40
+ ['protocol', this.protocol],
41
+ ['host', this.host],
42
+ ['port', this.port],
43
+ ['hash', this.hash],
44
+ ]);
45
+ }
46
+ toString() {
47
+ return [
48
+ `method->${this.method}`,
49
+ `protocol->${this.protocol}`,
50
+ `host->${this.host}`,
51
+ `port->${this.port}`,
52
+ `origin->${this.origin}`,
53
+ `path->${this.path}`,
54
+ `hash->${this.hash}`,
55
+ `fullUrl->${this.fullUrl}`,
56
+ ].join('|');
57
+ }
58
+ }
59
+ export class MockCallHistory {
60
+ logs = [];
61
+ get length() {
62
+ return this.logs.length;
63
+ }
64
+ record(data) {
65
+ this.logs.push(data instanceof MockCallHistoryLog ? data : new MockCallHistoryLog(data));
66
+ }
67
+ called(criteria) {
68
+ if (criteria === undefined)
69
+ return this.logs.length > 0;
70
+ return this.filterCalls(criteria).length > 0;
71
+ }
72
+ calls() {
73
+ return [...this.logs];
74
+ }
75
+ firstCall(criteria) {
76
+ if (criteria === undefined)
77
+ return this.logs[0];
78
+ return this.filterCalls(criteria)[0];
79
+ }
80
+ lastCall(criteria) {
81
+ if (criteria === undefined)
82
+ return this.logs[this.logs.length - 1];
83
+ const filtered = this.filterCalls(criteria);
84
+ return filtered[filtered.length - 1];
85
+ }
86
+ nthCall(n, criteria) {
87
+ if (criteria === undefined)
88
+ return this.logs[n - 1];
89
+ return this.filterCalls(criteria)[n - 1];
90
+ }
91
+ clear() {
92
+ this.logs = [];
93
+ }
94
+ [Symbol.iterator]() {
95
+ return this.logs[Symbol.iterator]();
96
+ }
97
+ filterCalls(criteria, options) {
98
+ if (typeof criteria === 'function') {
99
+ return this.logs.filter(criteria);
100
+ }
101
+ if (criteria instanceof RegExp) {
102
+ return this.logs.filter((log) => criteria.test(log.toString()));
103
+ }
104
+ const operator = options?.operator ?? 'OR';
105
+ const keys = Object.keys(criteria);
106
+ const predicates = keys
107
+ .filter((key) => criteria[key] !== undefined)
108
+ .map((key) => (log) => log[key] === criteria[key]);
109
+ if (predicates.length === 0)
110
+ return [...this.logs];
111
+ return this.logs.filter((log) => operator === 'AND' ? predicates.every((p) => p(log)) : predicates.some((p) => p(log)));
112
+ }
113
+ filterBy(field, filter) {
114
+ return this.logs.filter((log) => typeof filter === 'string' ? log[field] === filter : filter.test(String(log[field])));
115
+ }
116
+ filterCallsByMethod(filter) {
117
+ return this.filterBy('method', filter);
118
+ }
119
+ filterCallsByPath(filter) {
120
+ return this.filterBy('path', filter);
121
+ }
122
+ filterCallsByOrigin(filter) {
123
+ return this.filterBy('origin', filter);
124
+ }
125
+ filterCallsByProtocol(filter) {
126
+ return this.filterBy('protocol', filter);
127
+ }
128
+ filterCallsByHost(filter) {
129
+ return this.filterBy('host', filter);
130
+ }
131
+ filterCallsByPort(filter) {
132
+ return this.filterBy('port', filter);
133
+ }
134
+ filterCallsByHash(filter) {
135
+ return this.filterBy('hash', filter);
136
+ }
137
+ filterCallsByFullUrl(filter) {
138
+ return this.filterBy('fullUrl', filter);
139
+ }
140
+ }
@@ -0,0 +1,69 @@
1
+ import { MockCallHistory } from './mock-call-history';
2
+ /** Structural type to avoid cross-package nominal type mismatch on MSW's private fields */
3
+ interface SetupServerLike {
4
+ use(...handlers: Array<unknown>): void;
5
+ resetHandlers(...handlers: Array<unknown>): void;
6
+ listen(options?: Record<string, unknown>): void;
7
+ close(): void;
8
+ }
9
+ type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
10
+ type PathMatcher = string | RegExp | ((path: string) => boolean);
11
+ type HeaderValueMatcher = string | RegExp | ((value: string) => boolean);
12
+ type BodyMatcher = string | RegExp | ((body: string) => boolean);
13
+ export interface InterceptOptions {
14
+ path: PathMatcher;
15
+ method?: HttpMethod;
16
+ headers?: Record<string, HeaderValueMatcher>;
17
+ body?: BodyMatcher;
18
+ query?: Record<string, string>;
19
+ }
20
+ export interface ReplyOptions {
21
+ headers?: Record<string, string>;
22
+ }
23
+ type ReplyCallback = (req: {
24
+ body: string | null;
25
+ }) => unknown | Promise<unknown>;
26
+ export interface MockReplyChain {
27
+ times(n: number): void;
28
+ persist(): void;
29
+ delay(ms: number): void;
30
+ }
31
+ export interface MockInterceptor {
32
+ reply(status: number, body?: unknown, options?: ReplyOptions): MockReplyChain;
33
+ reply(status: number, callback: ReplyCallback): MockReplyChain;
34
+ replyWithError(error: Error): MockReplyChain;
35
+ }
36
+ export interface MockPool {
37
+ intercept(options: InterceptOptions): MockInterceptor;
38
+ }
39
+ export interface PendingInterceptor {
40
+ origin: string;
41
+ path: string;
42
+ method: string;
43
+ consumed: boolean;
44
+ times: number;
45
+ timesInvoked: number;
46
+ persist: boolean;
47
+ }
48
+ export declare class FetchMock {
49
+ private readonly _calls;
50
+ private server;
51
+ private readonly ownsServer;
52
+ private interceptors;
53
+ private netConnectAllowed;
54
+ get calls(): MockCallHistory;
55
+ constructor(externalServer?: SetupServerLike);
56
+ activate(): void;
57
+ disableNetConnect(): void;
58
+ enableNetConnect(matcher?: string | RegExp | ((host: string) => boolean)): void;
59
+ private isNetConnectAllowed;
60
+ getCallHistory(): MockCallHistory;
61
+ clearCallHistory(): void;
62
+ deactivate(): void;
63
+ assertNoPendingInterceptors(): void;
64
+ pendingInterceptors(): PendingInterceptor[];
65
+ get(origin: string): MockPool;
66
+ }
67
+ export declare function createFetchMock(externalServer?: SetupServerLike): FetchMock;
68
+ export {};
69
+ //# sourceMappingURL=mock-server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mock-server.d.ts","sourceRoot":"","sources":["../src/mock-server.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,269 @@
1
+ import { http, HttpResponse } from 'msw';
2
+ import { setupServer } from 'msw/node';
3
+ import { MockCallHistory } from './mock-call-history';
4
+ function isPending(p) {
5
+ if (p.persist)
6
+ return p.timesInvoked === 0;
7
+ return p.timesInvoked < p.times;
8
+ }
9
+ function escapeRegExp(str) {
10
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
11
+ }
12
+ function matchesValue(value, matcher) {
13
+ if (typeof matcher === 'string')
14
+ return value === matcher;
15
+ if (matcher instanceof RegExp)
16
+ return matcher.test(value);
17
+ return matcher(value);
18
+ }
19
+ function getHttpMethod(method) {
20
+ const methods = {
21
+ GET: http.get,
22
+ POST: http.post,
23
+ PUT: http.put,
24
+ DELETE: http.delete,
25
+ PATCH: http.patch,
26
+ };
27
+ return methods[method];
28
+ }
29
+ function matchPath(request, origin, pathMatcher) {
30
+ if (typeof pathMatcher === 'string')
31
+ return true; // string paths are matched by MSW URL pattern
32
+ const url = new URL(request.url);
33
+ const originPrefix = new URL(origin).pathname.replace(/\/$/, '');
34
+ const fullPath = url.pathname + url.search;
35
+ const relativePath = fullPath.startsWith(originPrefix)
36
+ ? fullPath.slice(originPrefix.length)
37
+ : fullPath;
38
+ return matchesValue(relativePath, pathMatcher);
39
+ }
40
+ function matchQuery(request, query) {
41
+ if (!query)
42
+ return true;
43
+ const url = new URL(request.url);
44
+ for (const [key, value] of Object.entries(query)) {
45
+ if (url.searchParams.get(key) !== value)
46
+ return false;
47
+ }
48
+ return true;
49
+ }
50
+ function matchHeaders(request, headers) {
51
+ if (!headers)
52
+ return true;
53
+ for (const [key, matcher] of Object.entries(headers)) {
54
+ const value = request.headers.get(key);
55
+ if (value === null || !matchesValue(value, matcher))
56
+ return false;
57
+ }
58
+ return true;
59
+ }
60
+ function matchBody(bodyText, bodyMatcher) {
61
+ if (!bodyMatcher)
62
+ return true;
63
+ return matchesValue(bodyText ?? '', bodyMatcher);
64
+ }
65
+ function recordCall(callHistory, request, bodyText) {
66
+ const url = new URL(request.url);
67
+ const requestHeaders = {};
68
+ request.headers.forEach((value, key) => {
69
+ requestHeaders[key] = value;
70
+ });
71
+ const searchParams = {};
72
+ url.searchParams.forEach((value, key) => {
73
+ searchParams[key] = value;
74
+ });
75
+ callHistory.record({
76
+ body: bodyText,
77
+ method: request.method,
78
+ headers: requestHeaders,
79
+ fullUrl: url.origin + url.pathname + url.search,
80
+ origin: url.origin,
81
+ path: url.pathname,
82
+ searchParams,
83
+ protocol: url.protocol,
84
+ host: url.host,
85
+ port: url.port,
86
+ hash: url.hash,
87
+ });
88
+ }
89
+ function buildResponse(status, responseBody, replyOptions) {
90
+ const headers = replyOptions?.headers ? new Headers(replyOptions.headers) : undefined;
91
+ if (responseBody === null || responseBody === undefined) {
92
+ return new HttpResponse(null, { status, headers });
93
+ }
94
+ return HttpResponse.json(responseBody, { status, headers });
95
+ }
96
+ export class FetchMock {
97
+ _calls = new MockCallHistory();
98
+ server;
99
+ ownsServer;
100
+ interceptors = [];
101
+ netConnectAllowed = false;
102
+ get calls() {
103
+ return this._calls;
104
+ }
105
+ constructor(externalServer) {
106
+ this.server = externalServer ?? null;
107
+ this.ownsServer = !externalServer;
108
+ }
109
+ activate() {
110
+ if (this.ownsServer) {
111
+ this.server = setupServer();
112
+ this.server.listen({
113
+ onUnhandledRequest: (request, print) => {
114
+ if (this.isNetConnectAllowed(request))
115
+ return;
116
+ print.error();
117
+ },
118
+ });
119
+ }
120
+ }
121
+ disableNetConnect() {
122
+ this.netConnectAllowed = false;
123
+ }
124
+ enableNetConnect(matcher) {
125
+ this.netConnectAllowed = matcher ?? true;
126
+ }
127
+ isNetConnectAllowed(request) {
128
+ if (this.netConnectAllowed === true)
129
+ return true;
130
+ if (this.netConnectAllowed === false)
131
+ return false;
132
+ const host = new URL(request.url).host;
133
+ if (typeof this.netConnectAllowed === 'string')
134
+ return host === this.netConnectAllowed;
135
+ if (this.netConnectAllowed instanceof RegExp)
136
+ return this.netConnectAllowed.test(host);
137
+ return this.netConnectAllowed(host);
138
+ }
139
+ getCallHistory() {
140
+ return this._calls;
141
+ }
142
+ clearCallHistory() {
143
+ this._calls.clear();
144
+ }
145
+ deactivate() {
146
+ this.interceptors = [];
147
+ this._calls.clear();
148
+ if (this.ownsServer) {
149
+ this.server?.close();
150
+ this.server = null;
151
+ }
152
+ }
153
+ assertNoPendingInterceptors() {
154
+ const unconsumed = this.interceptors.filter(isPending);
155
+ this.interceptors = [];
156
+ this._calls.clear();
157
+ if (this.ownsServer) {
158
+ this.server?.resetHandlers();
159
+ }
160
+ if (unconsumed.length > 0) {
161
+ const descriptions = unconsumed.map((p) => ` ${p.method} ${p.origin}${p.path}`);
162
+ throw new Error(`Pending interceptor(s) not consumed:\n${descriptions.join('\n')}`);
163
+ }
164
+ }
165
+ pendingInterceptors() {
166
+ return this.interceptors.filter(isPending).map((p) => ({ ...p }));
167
+ }
168
+ get(origin) {
169
+ return {
170
+ intercept: (options) => {
171
+ const method = options.method ?? 'GET';
172
+ const pathStr = typeof options.path === 'string'
173
+ ? options.path
174
+ : typeof options.path === 'function'
175
+ ? '<function>'
176
+ : options.path.toString();
177
+ const pending = {
178
+ origin,
179
+ path: pathStr,
180
+ method,
181
+ consumed: false,
182
+ times: 1,
183
+ timesInvoked: 0,
184
+ persist: false,
185
+ };
186
+ this.interceptors.push(pending);
187
+ const urlPattern = typeof options.path === 'string'
188
+ ? `${origin}${options.path}`
189
+ : new RegExp(`^${escapeRegExp(origin)}`);
190
+ const matchAndConsume = async (request) => {
191
+ if (!pending.persist && pending.timesInvoked >= pending.times)
192
+ return;
193
+ if (!matchPath(request, origin, options.path))
194
+ return;
195
+ if (!matchQuery(request, options.query))
196
+ return;
197
+ if (!matchHeaders(request, options.headers))
198
+ return;
199
+ const bodyText = (await request.text()) || null;
200
+ if (!matchBody(bodyText, options.body))
201
+ return;
202
+ pending.timesInvoked++;
203
+ if (!pending.persist && pending.timesInvoked >= pending.times) {
204
+ pending.consumed = true;
205
+ }
206
+ recordCall(this._calls, request, bodyText);
207
+ return bodyText;
208
+ };
209
+ const registerHandler = (handlerFn) => {
210
+ const handler = getHttpMethod(method)(urlPattern, async ({ request }) => handlerFn(request));
211
+ if (!this.server) {
212
+ throw new Error('FetchMock server is not active. Call activate() before registering interceptors.');
213
+ }
214
+ this.server.use(handler);
215
+ };
216
+ const buildChain = (delayRef) => ({
217
+ times(n) {
218
+ pending.times = n;
219
+ pending.consumed = false;
220
+ },
221
+ persist() {
222
+ pending.persist = true;
223
+ pending.consumed = false;
224
+ },
225
+ delay(ms) {
226
+ delayRef.ms = ms;
227
+ },
228
+ });
229
+ return {
230
+ reply: (status, bodyOrCallback, replyOptions) => {
231
+ const delayRef = { ms: 0 };
232
+ registerHandler(async (request) => {
233
+ const bodyText = await matchAndConsume(request);
234
+ if (bodyText === undefined)
235
+ return;
236
+ if (delayRef.ms > 0) {
237
+ await new Promise((resolve) => setTimeout(resolve, delayRef.ms));
238
+ }
239
+ let responseBody;
240
+ if (typeof bodyOrCallback === 'function') {
241
+ responseBody = await bodyOrCallback({
242
+ body: bodyText || null,
243
+ });
244
+ }
245
+ else {
246
+ responseBody = bodyOrCallback;
247
+ }
248
+ return buildResponse(status, responseBody, replyOptions);
249
+ });
250
+ return buildChain(delayRef);
251
+ },
252
+ replyWithError: () => {
253
+ const delayRef = { ms: 0 };
254
+ registerHandler(async (request) => {
255
+ const bodyText = await matchAndConsume(request);
256
+ if (bodyText === undefined)
257
+ return;
258
+ return HttpResponse.error();
259
+ });
260
+ return buildChain(delayRef);
261
+ },
262
+ };
263
+ },
264
+ };
265
+ }
266
+ }
267
+ export function createFetchMock(externalServer) {
268
+ return new FetchMock(externalServer);
269
+ }
package/docs/api.md ADDED
@@ -0,0 +1,472 @@
1
+ # API Reference
2
+
3
+ ## `new FetchMock(server?)`
4
+
5
+ Creates a `FetchMock` instance.
6
+
7
+ ```typescript
8
+ import { FetchMock } from 'msw-fetch-mock';
9
+
10
+ // Standalone (creates internal MSW server)
11
+ const fetchMock = new FetchMock();
12
+
13
+ // With external MSW server
14
+ import { setupServer } from 'msw/node';
15
+ const server = setupServer();
16
+ const fetchMock = new FetchMock(server);
17
+ ```
18
+
19
+ | Parameter | Type | Required | Description |
20
+ | --------- | ------------- | -------- | ------------------------------------------------------- |
21
+ | `server` | `SetupServer` | No | Existing MSW server. Creates one internally if omitted. |
22
+
23
+ > `createFetchMock(server?)` is also available as a backward-compatible factory function.
24
+
25
+ ---
26
+
27
+ ## `FetchMock`
28
+
29
+ ### Lifecycle
30
+
31
+ ```typescript
32
+ fetchMock.activate(); // start intercepting (calls server.listen())
33
+ fetchMock.deactivate(); // stop intercepting (calls server.close())
34
+ ```
35
+
36
+ > If you pass an external server that you manage yourself, `activate()` / `deactivate()` are no-ops.
37
+
38
+ ### `fetchMock.calls`
39
+
40
+ Returns the `MockCallHistory` instance for inspecting and managing recorded requests.
41
+
42
+ ```typescript
43
+ // Check call count
44
+ expect(fetchMock.calls.length).toBe(3);
45
+
46
+ // Inspect last call
47
+ const last = fetchMock.calls.lastCall();
48
+
49
+ // Clear history
50
+ fetchMock.calls.clear();
51
+ ```
52
+
53
+ ### `fetchMock.get(origin)`
54
+
55
+ Returns a `MockPool` scoped to the given origin.
56
+
57
+ ```typescript
58
+ const pool = fetchMock.get('https://api.example.com');
59
+ ```
60
+
61
+ ### `fetchMock.disableNetConnect()`
62
+
63
+ Prevents any real network requests. Unmatched requests will throw.
64
+
65
+ ### `fetchMock.enableNetConnect(matcher?)`
66
+
67
+ Allows real network requests to pass through. Without arguments, all requests are allowed. With a matcher, only matching hosts pass through.
68
+
69
+ ```typescript
70
+ // Allow all network requests
71
+ fetchMock.enableNetConnect();
72
+
73
+ // Allow specific host (exact match)
74
+ fetchMock.enableNetConnect('api.example.com');
75
+
76
+ // Allow hosts matching a RegExp
77
+ fetchMock.enableNetConnect(/\.example\.com$/);
78
+
79
+ // Allow hosts matching a function
80
+ fetchMock.enableNetConnect((host) => host.endsWith('.test'));
81
+ ```
82
+
83
+ | Parameter | Type | Required | Description |
84
+ | --------- | ----------------------------------------------- | -------- | ------------------------------------ |
85
+ | `matcher` | `string \| RegExp \| (host: string) => boolean` | No | Host matcher. Allows all if omitted. |
86
+
87
+ ### `fetchMock.getCallHistory()`
88
+
89
+ Returns the `MockCallHistory` instance. Cloudflare-compatible alias for `fetchMock.calls`.
90
+
91
+ ### `fetchMock.clearCallHistory()`
92
+
93
+ Clears all recorded calls. Cloudflare-compatible alias for `fetchMock.calls.clear()`.
94
+
95
+ ### `fetchMock.assertNoPendingInterceptors()`
96
+
97
+ Throws an error if any registered interceptor has not been consumed. Also **automatically clears** call history and resets handlers. Use in `afterEach` to catch missing requests.
98
+
99
+ ```typescript
100
+ afterEach(() => fetchMock.assertNoPendingInterceptors());
101
+ ```
102
+
103
+ > Call history is cleared automatically — no need for a separate `calls.clear()` call.
104
+
105
+ ### `fetchMock.pendingInterceptors()`
106
+
107
+ Returns an array of unconsumed interceptors with metadata:
108
+
109
+ ```typescript
110
+ interface PendingInterceptor {
111
+ origin: string;
112
+ path: string;
113
+ method: string;
114
+ consumed: boolean;
115
+ times: number;
116
+ timesInvoked: number;
117
+ persist: boolean;
118
+ }
119
+ ```
120
+
121
+ ---
122
+
123
+ ## `MockPool`
124
+
125
+ ### `pool.intercept(options)`
126
+
127
+ Registers an interceptor for matching requests.
128
+
129
+ ```typescript
130
+ pool.intercept({
131
+ path: '/users',
132
+ method: 'GET',
133
+ });
134
+ ```
135
+
136
+ Returns: `MockInterceptor`
137
+
138
+ #### `InterceptOptions`
139
+
140
+ | Property | Type | Required | Description |
141
+ | --------- | ---------------------------------------------------------------- | -------- | -------------------------------------- |
142
+ | `path` | `string \| RegExp \| (path: string) => boolean` | Yes | URL pathname to match |
143
+ | `method` | `'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH'` | No | HTTP method (default: `'GET'`) |
144
+ | `headers` | `Record<string, string \| RegExp \| (value: string) => boolean>` | No | Header matchers |
145
+ | `body` | `string \| RegExp \| (body: string) => boolean` | No | Request body matcher |
146
+ | `query` | `Record<string, string>` | No | Query parameter matchers (exact match) |
147
+
148
+ #### Path Matching
149
+
150
+ ```typescript
151
+ // Exact string
152
+ .intercept({ path: '/users' })
153
+
154
+ // RegExp
155
+ .intercept({ path: /^\/users\/\d+$/ })
156
+
157
+ // Function
158
+ .intercept({ path: (p) => p.startsWith('/users') })
159
+ ```
160
+
161
+ #### Header Matching
162
+
163
+ ```typescript
164
+ .intercept({
165
+ path: '/api',
166
+ method: 'POST',
167
+ headers: {
168
+ 'content-type': 'application/json', // exact match
169
+ authorization: /^Bearer /, // regex
170
+ 'x-custom': (v) => v.includes('special'), // function
171
+ },
172
+ })
173
+ ```
174
+
175
+ #### Body Matching
176
+
177
+ ```typescript
178
+ .intercept({
179
+ path: '/api',
180
+ method: 'POST',
181
+ body: '{"key":"value"}', // exact match
182
+ // body: /keyword/, // regex
183
+ // body: (b) => b.includes('key'), // function
184
+ })
185
+ ```
186
+
187
+ #### Query Parameters
188
+
189
+ ```typescript
190
+ .intercept({
191
+ path: '/search',
192
+ method: 'GET',
193
+ query: { q: 'test', page: '1' },
194
+ })
195
+ ```
196
+
197
+ ---
198
+
199
+ ## `MockInterceptor`
200
+
201
+ ### `interceptor.reply(status, body?, options?)`
202
+
203
+ Defines the mock response.
204
+
205
+ ```typescript
206
+ // Static body
207
+ .reply(200, { users: [] })
208
+
209
+ // With response headers
210
+ .reply(200, { users: [] }, { headers: { 'x-request-id': '123' } })
211
+
212
+ // Callback (receives request info)
213
+ .reply(200, (req) => {
214
+ const input = JSON.parse(req.body!);
215
+ return { echo: input };
216
+ })
217
+ ```
218
+
219
+ Returns: `MockReplyChain`
220
+
221
+ | Parameter | Type | Description |
222
+ | --------- | -------------------------------------- | ------------------------- |
223
+ | `status` | `number` | HTTP status code |
224
+ | `body` | `unknown \| (req) => unknown` | Response body or callback |
225
+ | `options` | `{ headers?: Record<string, string> }` | Response headers |
226
+
227
+ ### `interceptor.replyWithError(error)`
228
+
229
+ Replies with a network error (simulates a connection failure).
230
+
231
+ ```typescript
232
+ .replyWithError(new Error('connection refused'))
233
+ ```
234
+
235
+ Returns: `MockReplyChain`
236
+
237
+ | Parameter | Type | Description |
238
+ | --------- | ------- | ---------------------------------------- |
239
+ | `error` | `Error` | The error instance (used for semantics). |
240
+
241
+ ---
242
+
243
+ ## `MockReplyChain`
244
+
245
+ ### `chain.times(n)`
246
+
247
+ Interceptor will match exactly `n` times, then be consumed.
248
+
249
+ ```typescript
250
+ .reply(200, { ok: true }).times(3)
251
+ ```
252
+
253
+ ### `chain.persist()`
254
+
255
+ Interceptor will match indefinitely (never consumed).
256
+
257
+ ```typescript
258
+ .reply(200, { ok: true }).persist()
259
+ ```
260
+
261
+ ### `chain.delay(ms)`
262
+
263
+ Adds a delay before the response is sent.
264
+
265
+ ```typescript
266
+ .reply(200, { ok: true }).delay(500)
267
+ ```
268
+
269
+ ---
270
+
271
+ ## `MockCallHistory`
272
+
273
+ Tracks all requests that pass through the mock server.
274
+
275
+ ### `history.length`
276
+
277
+ Returns the number of recorded calls.
278
+
279
+ ```typescript
280
+ expect(fetchMock.calls.length).toBe(3);
281
+ ```
282
+
283
+ ### `history.called(criteria?)`
284
+
285
+ Returns `true` if any calls match the given criteria, or if any calls exist when no criteria is provided.
286
+
287
+ ```typescript
288
+ // Any calls recorded?
289
+ expect(fetchMock.calls.called()).toBe(true);
290
+
291
+ // Calls matching a filter?
292
+ expect(fetchMock.calls.called({ method: 'POST' })).toBe(true);
293
+ expect(fetchMock.calls.called(/\/users/)).toBe(true);
294
+ expect(fetchMock.calls.called((log) => log.path === '/users')).toBe(true);
295
+ ```
296
+
297
+ ### `history.calls()`
298
+
299
+ Returns a copy of all recorded `MockCallHistoryLog` entries.
300
+
301
+ ### `history.firstCall(criteria?)`
302
+
303
+ Returns the first recorded call, or `undefined`. Optionally filters by criteria.
304
+
305
+ ```typescript
306
+ const first = fetchMock.calls.firstCall();
307
+ const firstPost = fetchMock.calls.firstCall({ method: 'POST' });
308
+ ```
309
+
310
+ ### `history.lastCall(criteria?)`
311
+
312
+ Returns the most recent recorded call, or `undefined`. Optionally filters by criteria.
313
+
314
+ ```typescript
315
+ const last = fetchMock.calls.lastCall();
316
+ const lastPost = fetchMock.calls.lastCall({ method: 'POST' });
317
+ ```
318
+
319
+ ### `history.nthCall(n, criteria?)`
320
+
321
+ Returns the nth call (1-indexed), or `undefined`. Optionally filters by criteria.
322
+
323
+ ```typescript
324
+ const second = fetchMock.calls.nthCall(2);
325
+ const secondPost = fetchMock.calls.nthCall(2, { method: 'POST' });
326
+ ```
327
+
328
+ ### `history.clear()`
329
+
330
+ Removes all recorded calls.
331
+
332
+ ### `history.filterCalls(criteria, options?)`
333
+
334
+ Flexible filtering with three overloads:
335
+
336
+ ```typescript
337
+ // Function predicate
338
+ history.filterCalls((log) => log.body?.includes('test'));
339
+
340
+ // RegExp (tested against log.toString())
341
+ history.filterCalls(/POST.*\/users/);
342
+
343
+ // Structured criteria
344
+ history.filterCalls(
345
+ { method: 'POST', path: '/users' },
346
+ { operator: 'AND' } // default: 'OR'
347
+ );
348
+ ```
349
+
350
+ #### `CallHistoryFilterCriteria`
351
+
352
+ | Property | Type | Description |
353
+ | ---------- | -------- | ------------ |
354
+ | `method` | `string` | HTTP method |
355
+ | `path` | `string` | URL pathname |
356
+ | `origin` | `string` | URL origin |
357
+ | `protocol` | `string` | URL protocol |
358
+ | `host` | `string` | URL host |
359
+ | `port` | `string` | URL port |
360
+ | `hash` | `string` | URL hash |
361
+ | `fullUrl` | `string` | Complete URL |
362
+
363
+ ### `history.filterCallsByMethod(filter)`
364
+
365
+ ```typescript
366
+ history.filterCallsByMethod('POST');
367
+ history.filterCallsByMethod(/^P/); // POST, PUT, PATCH
368
+ ```
369
+
370
+ ### `history.filterCallsByPath(filter)`
371
+
372
+ ```typescript
373
+ history.filterCallsByPath('/users');
374
+ history.filterCallsByPath(/\/users\/\d+/);
375
+ ```
376
+
377
+ ### `history.filterCallsByOrigin(filter)`
378
+
379
+ ```typescript
380
+ history.filterCallsByOrigin('https://api.example.com');
381
+ history.filterCallsByOrigin(/example\.com/);
382
+ ```
383
+
384
+ ### `history.filterCallsByProtocol(filter)`
385
+
386
+ ```typescript
387
+ history.filterCallsByProtocol('https:');
388
+ ```
389
+
390
+ ### `history.filterCallsByHost(filter)`
391
+
392
+ ```typescript
393
+ history.filterCallsByHost('api.example.com');
394
+ history.filterCallsByHost(/example\.com/);
395
+ ```
396
+
397
+ ### `history.filterCallsByPort(filter)`
398
+
399
+ ```typescript
400
+ history.filterCallsByPort('8787');
401
+ ```
402
+
403
+ ### `history.filterCallsByHash(filter)`
404
+
405
+ ```typescript
406
+ history.filterCallsByHash('#section');
407
+ ```
408
+
409
+ ### `history.filterCallsByFullUrl(filter)`
410
+
411
+ ```typescript
412
+ history.filterCallsByFullUrl('https://api.example.com/users');
413
+ history.filterCallsByFullUrl(/\/users\?page=1/);
414
+ ```
415
+
416
+ ### Iteration
417
+
418
+ `MockCallHistory` implements `Symbol.iterator`:
419
+
420
+ ```typescript
421
+ for (const call of history) {
422
+ console.log(call.method, call.path);
423
+ }
424
+ ```
425
+
426
+ ---
427
+
428
+ ## `MockCallHistoryLog`
429
+
430
+ Each recorded call is an instance of `MockCallHistoryLog` with the following properties:
431
+
432
+ | Property | Type | Description |
433
+ | -------------- | ------------------------ | ---------------------------------- |
434
+ | `method` | `string` | HTTP method |
435
+ | `fullUrl` | `string` | Complete URL |
436
+ | `origin` | `string` | URL origin (`https://example.com`) |
437
+ | `path` | `string` | URL pathname (`/users`) |
438
+ | `searchParams` | `Record<string, string>` | Query parameters |
439
+ | `headers` | `Record<string, string>` | Request headers |
440
+ | `body` | `string \| null` | Request body |
441
+ | `protocol` | `string` | URL protocol (`https:`) |
442
+ | `host` | `string` | URL host |
443
+ | `port` | `string` | URL port |
444
+ | `hash` | `string` | URL hash |
445
+
446
+ ### `log.json()`
447
+
448
+ Parses the request body as JSON. Returns `null` if body is `null`.
449
+
450
+ ```typescript
451
+ const call = fetchMock.calls.lastCall()!;
452
+ const data = call.json() as { name: string };
453
+ expect(data.name).toBe('Alice');
454
+ ```
455
+
456
+ ### `log.toMap()`
457
+
458
+ Returns a `Map` of all log properties.
459
+
460
+ ```typescript
461
+ const map = call.toMap();
462
+ expect(map.get('method')).toBe('POST');
463
+ ```
464
+
465
+ ### `log.toString()`
466
+
467
+ Returns a pipe-delimited string representation for debugging and RegExp matching.
468
+
469
+ ```typescript
470
+ call.toString();
471
+ // "method->POST|protocol->https:|host->api.example.com|port->|origin->https://api.example.com|path->/users|hash->|fullUrl->https://api.example.com/users"
472
+ ```
@@ -0,0 +1,78 @@
1
+ # Cloudflare Workers Migration Guide
2
+
3
+ If you're migrating tests from Cloudflare Workers' `cloudflare:test` to a standard Node.js test environment, `msw-fetch-mock` provides the same `fetchMock` API you already know.
4
+
5
+ ## API Comparison
6
+
7
+ | cloudflare:test | msw-fetch-mock |
8
+ | -------------------------------------------------- | ----------------------------------------------------------- |
9
+ | `import { fetchMock } from 'cloudflare:test'` | `const fetchMock = new FetchMock(server)` |
10
+ | `fetchMock.activate()` | `fetchMock.activate()` |
11
+ | `fetchMock.disableNetConnect()` | `fetchMock.disableNetConnect()` |
12
+ | `fetchMock.enableNetConnect(matcher?)` | `fetchMock.enableNetConnect(matcher?)` |
13
+ | `fetchMock.deactivate()` | `fetchMock.deactivate()` |
14
+ | `fetchMock.get(origin).intercept(opts).reply(...)` | `fetchMock.get(origin).intercept(opts).reply(...)` |
15
+ | `.replyWithError(error)` | `.replyWithError(error)` |
16
+ | `.reply(...).delay(ms)` | `.reply(...).delay(ms)` |
17
+ | `fetchMock.getCallHistory()` | `fetchMock.getCallHistory()` or `fetchMock.calls` |
18
+ | `fetchMock.clearCallHistory()` | `fetchMock.clearCallHistory()` or `fetchMock.calls.clear()` |
19
+ | `fetchMock.assertNoPendingInterceptors()` | `fetchMock.assertNoPendingInterceptors()` |
20
+
21
+ ## Before (cloudflare:test)
22
+
23
+ ```typescript
24
+ import { fetchMock } from 'cloudflare:test';
25
+
26
+ beforeEach(() => {
27
+ fetchMock.activate();
28
+ fetchMock.disableNetConnect();
29
+ });
30
+
31
+ afterEach(() => fetchMock.assertNoPendingInterceptors());
32
+
33
+ it('calls API', async () => {
34
+ fetchMock
35
+ .get('https://api.example.com')
36
+ .intercept({ path: '/data', method: 'GET' })
37
+ .reply(200, { result: 'ok' });
38
+
39
+ const res = await fetch('https://api.example.com/data');
40
+ expect(await res.json()).toEqual({ result: 'ok' });
41
+ });
42
+ ```
43
+
44
+ ## After (msw-fetch-mock)
45
+
46
+ ```typescript
47
+ import { setupServer } from 'msw/node';
48
+ import { FetchMock } from 'msw-fetch-mock';
49
+
50
+ const server = setupServer();
51
+ const fetchMock = new FetchMock(server);
52
+
53
+ beforeAll(() => fetchMock.activate());
54
+ afterAll(() => fetchMock.deactivate());
55
+ afterEach(() => fetchMock.assertNoPendingInterceptors());
56
+
57
+ it('calls API', async () => {
58
+ fetchMock
59
+ .get('https://api.example.com')
60
+ .intercept({ path: '/data', method: 'GET' })
61
+ .reply(200, { result: 'ok' });
62
+
63
+ const res = await fetch('https://api.example.com/data');
64
+ expect(await res.json()).toEqual({ result: 'ok' });
65
+ });
66
+ ```
67
+
68
+ ## Key Differences
69
+
70
+ | Aspect | cloudflare:test | msw-fetch-mock |
71
+ | -------------------- | ------------------------------------ | ------------------------------------------------- |
72
+ | Server lifecycle | Implicit (managed by test framework) | Explicit (`activate()` / `deactivate()`) |
73
+ | Call history access | `fetchMock.getCallHistory()` | `fetchMock.getCallHistory()` or `fetchMock.calls` |
74
+ | Call history cleanup | Automatic per test | Automatic via `assertNoPendingInterceptors()` |
75
+ | Network connect | Must call `disableNetConnect()` | MSW blocks unhandled requests by default |
76
+ | Runtime | Cloudflare Workers (workerd) | Node.js |
77
+
78
+ > **Note:** `getCallHistory()` and `clearCallHistory()` are provided as Cloudflare-compatible aliases. You can use either the Cloudflare-style methods or the `fetchMock.calls` getter — they are equivalent.
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "msw-fetch-mock",
3
+ "version": "0.1.0",
4
+ "description": "Undici-style fetch mock API built on MSW (Mock Service Worker)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "msw",
9
+ "fetch",
10
+ "mock",
11
+ "testing",
12
+ "undici"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/recca0120/msw-fetch-mock.git"
17
+ },
18
+ "packageManager": "pnpm@10.12.4",
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "source": "./src/index.ts",
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "docs",
32
+ "LICENSE.md",
33
+ "README.md"
34
+ ],
35
+ "scripts": {
36
+ "prepare": "lefthook install",
37
+ "build": "tsc",
38
+ "test": "vitest",
39
+ "test:run": "vitest run",
40
+ "lint": "eslint src",
41
+ "lint:fix": "eslint src --fix",
42
+ "format": "prettier --write .",
43
+ "format:check": "prettier --check .",
44
+ "prepublishOnly": "tsc"
45
+ },
46
+ "peerDependencies": {
47
+ "msw": "^2.12.7"
48
+ },
49
+ "devDependencies": {
50
+ "@eslint/js": "^9.39.2",
51
+ "eslint": "^9.39.2",
52
+ "lefthook": "^2.0.15",
53
+ "msw": "^2.12.7",
54
+ "prettier": "^3.8.1",
55
+ "typescript": "^5.9.3",
56
+ "typescript-eslint": "^8.53.1",
57
+ "vitest": "^4.0.17"
58
+ }
59
+ }