@archon-research/http-client-msw
v0.11.0
Published
Typed [msw](https://mswjs.io) mocks for `@archon-research/http-client-core`.
Downloads
1,285
Readme
@archon-research/http-client-msw
Typed msw mocks for @archon-research/http-client-core.
The generated OpenAPI paths type is the endpoint definition. The same type
that drives createApiClient and createQueryApi drives the mock handlers, so a
handler path, its params, and the body it answers with are checked against the
contract — a fixture that no longer matches the API fails to typecheck instead of
quietly making every test that reads it a false pass. See
DESIGN.md for the contract, the openapi-msw verdict, and the
deliberate limits.
Installation
npm install -D @archon-research/http-client-msw mswmsw is a peer dependency: the service worker script is generated from the
installed msw and version-checked at start, so the app has to own the version.
Generate the worker script once and commit it:
npx msw init public --savepublic/mockServiceWorker.js is a build artifact that belongs in git — the app
serves it, and --save records the path in package.json so msw warns when an
upgrade leaves it stale. Adjust public if the bundler serves static files from
elsewhere.
Usage
1. Generate types
npx uikit-openapi-generate --schema openapi.json --output src/api.types.tsThe same src/api.types.ts the client and query layer already use. Nothing about
the mocks is described twice.
2. Write the handlers
Keep them in one module — a src/mocks/ folder in the app, or a workspace
package (@your-scope/api-mocks) when a Playwright suite and a unit-test suite
both consume them:
// src/mocks/index.ts
import {
createMockApi,
createMockStore,
createSeededRng,
mockDelay,
setupMocks,
} from '@archon-research/http-client-msw';
import type { paths } from '../api.types';
// The same baseUrl the app passes to `createApiClient`.
const mock = createMockApi<paths>({ baseUrl: '/api' });
const rng = createSeededRng(1337);
const positions = createMockStore(() =>
Array.from({ length: 8 }, (_, index) => ({
id: `p${index + 1}`,
label: `Position ${index + 1}`,
health: rng.int(50, 200) / 100,
})),
);
export const mocks = setupMocks(
[
mock.get('/positions', async ({ query, response }) => {
await mockDelay(300);
const limit = Number(query.get('limit') ?? '25');
return response(200).json(positions.list().slice(0, limit));
}),
mock.get('/positions/{id}', ({ params, response }) => {
const position = positions.get(params.id);
// Both branches are checked: 200 takes a Position, 404 takes the
// operation's own error body.
return position
? response(200).json(position)
: response(404).json({ message: `no position ${params.id}` });
}),
mock.post('/positions/{id}/close', ({ params, response }) =>
positions.remove(params.id)
? response(204).empty()
: response(404).json({ message: 'already closed' }),
),
],
// Everything a handler writes to is restored by `reset()`. Order matters:
// callbacks run as declared, and the store's seed function draws from the rng,
// so the rng has to be rewound first or each reset re-seeds from a different
// point in the sequence.
{ onReset: [rng.reset, positions.reset] },
);For a route the OpenAPI document does not describe at all — an auth callback on
another host, say — mock.untyped is msw's own http object, and
response.untyped(new Response(...)) returns an arbitrary response from a typed
handler.
3. Run them in the browser (dev, and Playwright)
Start the worker before rendering, so no component can fire a request the worker is not yet intercepting:
// src/main.tsx
import { createRoot } from 'react-dom/client';
import { App } from './App';
if (import.meta.env.VITE_API_MOCKS === '1') {
const { setupMockWorker } = await import(
'@archon-research/http-client-msw/browser'
);
const { mocks } = await import('./mocks');
// `baseUrl` here is the app's public base path, not the API base: it locates
// `mockServiceWorker.js` for a subpath deployment.
const mockWorker = setupMockWorker(mocks, {
baseUrl: import.meta.env.BASE_URL,
});
// Reachable from a Playwright test; see below.
Object.assign(window, { resetMocks: () => mockWorker.reset() });
await mockWorker.start();
}
createRoot(document.getElementById('root')!).render(<App />);VITE_API_MOCKS=1 npm run devThe gate matters as much as the mocks. Vite replaces import.meta.env.VITE_*
statically, so with the flag unset the whole branch is dead code and neither msw
nor the fixtures reach a production bundle — which is only true because both
imports are dynamic and behind the check. A runtime process.env read or a
static import of the mocks module would bundle them either way.
start() is idempotent, so a hot reload or a test fixture may call it again
without re-registering the worker. stop() releases that, so a later start()
registers again rather than resolving into a stopped worker.
For a Playwright suite, serve the app with the same flag on — the browser worker answers from the same handlers — and reset between tests through the hook the entry exposed:
// tests/positions.spec.ts
declare global {
interface Window {
resetMocks?: () => void;
}
}
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.evaluate(() => window.resetMocks?.());
});A full reload re-runs the entry and re-seeds the stores anyway; the explicit
resetMocks() matters when a test navigates within the app instead.
4. Run them in vitest
Same handler array, node interceptors:
// src/test-setup.ts
import { setupMockServer } from '@archon-research/http-client-msw/node';
import { afterAll, afterEach, beforeAll } from 'vitest';
import { mocks } from './mocks';
const mockServer = setupMockServer(mocks);
// `onUnhandledRequest` defaults to 'error': an unmocked request in a suite is a
// hole in the fixtures, not something to warn about and scroll past.
beforeAll(() => mockServer.listen());
afterEach(() => mockServer.reset());
afterAll(() => mockServer.close());// vitest.config.ts
export default defineConfig({
test: { setupFiles: ['./src/test-setup.ts'] },
});reset() covers both kinds of leakage between tests: state a handler wrote, and
handlers a test installed with mockServer.server.use(...).
An origin-relative baseUrl works in both environments because handler paths are
matched on any origin by default — msw would otherwise leave a relative path
unmatched under setupServer, where every request URL is absolute. Pass
origin: 'exact' to createMockApi to opt out; see
DESIGN.md.
API surface
| Export | What it does |
| --- | --- |
| createMockApi<TPaths>(options?) | Typed handler factories per method, plus untyped |
| setupMocks(handlers, options?) | Bundles handlers with their state resets, environment-neutral |
| setupMockWorker(mocks, options?) | /browser. Serves them from a service worker; idempotent start() |
| setupMockServer(mocks) | /node. Serves them from msw's node interceptors |
| createMockStore(seedFn, options?) | In-memory collection so a write shows up in the next read |
| createSeededRng(seed) | Deterministic PRNG for reproducible generated fixtures |
| mockDelay(ms \| { test, dev }) | Env-aware latency; no delay under test by default |
| resolveMockDelay / isTestEnvironment | The delay decision, for a consumer's own helpers |
| resolveWorkerScriptUrl / normalizeApiBaseUrl / resolveHandlerBase / isAbsoluteUrl | The URL primitives |
| buildWorkerStartOptions / createIdempotentStart | /browser. The start decisions, unit-testable |
Handler and resolver types are re-exported as MockHandler,
MockResponseResolver, MockPathsFor, MockRequestBodyFor, and
MockResponseBodyFor, so a helper written around a resolver types against the
same msw copy this package resolves.
Not in v1
Handlers generated from the OpenAPI document, runtime request-body validation,
named scenario switching, GraphQL/websocket typing, per-test handler isolation
via msw's boundary, and fault injection are deliberately out of scope — see
DESIGN.md.
Peer dependencies
msw(^2.10.5)
See also
- http-client-core for the client factory, the OpenAPI type generator, and the zod helpers
- http-client-react for the TanStack Query layer keyed off
the same
pathstype
