@kitr/mocks 0.0.0-stage → 0.2.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Platon Podkopov
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 CHANGED
@@ -1,3 +1,15 @@
1
- # Temporary Holding Version
1
+ # @kitr/mocks
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Mocks.** Fake your API while you build the frontend or run tests.
4
+
5
+ ```sh
6
+ pnpm kitr add mocks
7
+ ```
8
+
9
+ `kitr add` installs the package and wires it into your app: the provider in `kitrrc.ts`, its config file and its `AGENTS.md` section. Import it from `@kitr/mocks`.
10
+
11
+ Built on msw.
12
+
13
+ Read the guide at https://kitr.dev/packages/mocks.
14
+
15
+ Part of [kitr](https://kitr.dev), the TypeScript framework where everything is an add-on. MIT licensed.
@@ -0,0 +1,45 @@
1
+ import { HttpResponse, RequestHandler, http } from "msw";
2
+ /** One route of a kitr route table, as `routes.toJSON()` and `kitr api:sync` describe it. */
3
+ export interface RouteDescriptor {
4
+ /** The route name that `mockRoutes` resolvers are keyed by, such as `orders.show`. */
5
+ name: string;
6
+ /** The HTTP method, in any case: `GET`, `post`. */
7
+ method: string;
8
+ /** The path pattern with `:params`, such as `/api/orders/:id`. */
9
+ path: string;
10
+ }
11
+ type Resolver = (info: {
12
+ params: Record<string, string | readonly string[] | undefined>;
13
+ request: Request;
14
+ body: () => Promise<unknown>;
15
+ }) => unknown | Promise<unknown>;
16
+ /**
17
+ * MSW handlers from a kitr route table (`routes.toJSON()`) or an OpenAPI-ish list.
18
+ * Unhandled routes get a 501 so a missing mock is loud, not silent.
19
+ *
20
+ * const handlers = mockRoutes(routeTable, 'http://localhost:3000', {
21
+ * 'projects.index': () => [{ id: '1', name: 'Alpha' }],
22
+ * 'projects.show': ({ params }) => ({ id: params.id, name: 'Alpha' }),
23
+ * })
24
+ */
25
+ export declare function mockRoutes(table: RouteDescriptor[], baseUrl: string, resolvers: Record<string, Resolver | unknown>): RequestHandler[];
26
+ /**
27
+ * Turns anything thrown into the JSON response a kitr server sends for it: a `KitrError` keeps its
28
+ * code and status, anything else becomes `INTERNAL` with status 500.
29
+ */
30
+ export declare function kitrErrorResponse(error: unknown): HttpResponse<import("msw").JsonBodyType>;
31
+ /** A handler that throws a kitr validation error, to test form error mapping. */
32
+ export declare function mockValidationError(method: 'post' | 'put' | 'patch', url: string, fieldErrors: Record<string, string[]>): import("msw").HttpHandler;
33
+ /**
34
+ * Starts MSW in Node for tests: `const mocks = await setupNodeMocks(handlers)`. Requests no handler
35
+ * matches go to the real network. Returns MSW's server, so `use`, `resetHandlers` and `close` work.
36
+ */
37
+ export declare function setupNodeMocks(handlers: RequestHandler[]): Promise<import("msw/node").SetupServer>;
38
+ /**
39
+ * Starts MSW's service worker in the browser for development: `await setupBrowserMocks(handlers)`.
40
+ * Needs `mockServiceWorker.js` in the public folder. Pass `quiet: false` to see MSW's console messages.
41
+ */
42
+ export declare function setupBrowserMocks(handlers: RequestHandler[], options?: {
43
+ quiet?: boolean;
44
+ }): Promise<import("msw/browser").SetupWorker>;
45
+ export { HttpResponse, http };
package/dist/index.js ADDED
@@ -0,0 +1,71 @@
1
+ import { KitrError } from "@kitr/core";
2
+ import { HttpResponse, http } from "msw";
3
+ // src/index.ts
4
+ /**
5
+ * MSW handlers from a kitr route table (`routes.toJSON()`) or an OpenAPI-ish list.
6
+ * Unhandled routes get a 501 so a missing mock is loud, not silent.
7
+ *
8
+ * const handlers = mockRoutes(routeTable, 'http://localhost:3000', {
9
+ * 'projects.index': () => [{ id: '1', name: 'Alpha' }],
10
+ * 'projects.show': ({ params }) => ({ id: params.id, name: 'Alpha' }),
11
+ * })
12
+ */
13
+ function mockRoutes(table, baseUrl, resolvers) {
14
+ const base = baseUrl.replace(/\/$/, "");
15
+ return table.map((route) => {
16
+ const method = route.method.toLowerCase();
17
+ const pattern = `${base}${route.path}`;
18
+ const resolver = resolvers[route.name];
19
+ return http[method](pattern, async ({ params, request }) => {
20
+ if (resolver === void 0) return kitrErrorResponse(new KitrError("UNAVAILABLE", `No mock for ${route.name}`, { status: 501 }));
21
+ try {
22
+ const value = typeof resolver === "function" ? await resolver({
23
+ params,
24
+ request,
25
+ body: () => request.json()
26
+ }) : resolver;
27
+ if (value instanceof Response) return value;
28
+ if (value === void 0) return new HttpResponse(null, { status: 204 });
29
+ return HttpResponse.json(value);
30
+ } catch (error) {
31
+ return kitrErrorResponse(error);
32
+ }
33
+ });
34
+ });
35
+ }
36
+ /**
37
+ * Turns anything thrown into the JSON response a kitr server sends for it: a `KitrError` keeps its
38
+ * code and status, anything else becomes `INTERNAL` with status 500.
39
+ */
40
+ function kitrErrorResponse(error) {
41
+ const e = error instanceof KitrError ? error : new KitrError("INTERNAL", String(error?.message ?? error));
42
+ return HttpResponse.json(e.toJSON(), { status: e.status });
43
+ }
44
+ /** A handler that throws a kitr validation error, to test form error mapping. */
45
+ function mockValidationError(method, url, fieldErrors) {
46
+ return http[method](url, () => kitrErrorResponse(KitrError.validation(fieldErrors)));
47
+ }
48
+ /**
49
+ * Starts MSW in Node for tests: `const mocks = await setupNodeMocks(handlers)`. Requests no handler
50
+ * matches go to the real network. Returns MSW's server, so `use`, `resetHandlers` and `close` work.
51
+ */
52
+ async function setupNodeMocks(handlers) {
53
+ const { setupServer } = await import("msw/node");
54
+ const server = setupServer(...handlers);
55
+ server.listen({ onUnhandledFrame: "bypass" });
56
+ return server;
57
+ }
58
+ /**
59
+ * Starts MSW's service worker in the browser for development: `await setupBrowserMocks(handlers)`.
60
+ * Needs `mockServiceWorker.js` in the public folder. Pass `quiet: false` to see MSW's console messages.
61
+ */
62
+ async function setupBrowserMocks(handlers, options = {}) {
63
+ const { setupWorker } = await import("msw/browser");
64
+ const worker = setupWorker(...handlers);
65
+ await worker.start({
66
+ onUnhandledFrame: "bypass",
67
+ quiet: options.quiet ?? true
68
+ });
69
+ return worker;
70
+ }
71
+ export { HttpResponse, http, kitrErrorResponse, mockRoutes, mockValidationError, setupBrowserMocks, setupNodeMocks };
package/dist/kitr.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ /** `kitr add mocks`: MSW handlers from route tables and contracts, for dev without a backend and for tests. */
2
+ declare const _default: {
3
+ readonly name: "mocks";
4
+ readonly client: true;
5
+ readonly add: (ctx: import("@kitr/core/package").AddContext) => void;
6
+ readonly remove: (ctx: import("@kitr/core/package").AddContext) => void;
7
+ readonly eject: {
8
+ readonly '@kitr/mocks': "msw";
9
+ };
10
+ };
11
+ export { _default as default };
package/dist/kitr.js ADDED
@@ -0,0 +1,27 @@
1
+ import { definePackage } from "@kitr/core/package";
2
+ // src/kitr.ts
3
+ /** `kitr add mocks`: MSW handlers from route tables and contracts, for dev without a backend and for tests. */
4
+ var kitr_default = definePackage({
5
+ name: "mocks",
6
+ client: true,
7
+ add(ctx) {
8
+ ctx.deps.add({
9
+ name: "msw",
10
+ dev: true
11
+ });
12
+ ctx.write("app/mocks/handlers.ts", `import { HttpResponse, http } from '@kitr/mocks'\n\nexport const handlers = [\n http.get('*/me', () => HttpResponse.json({ id: 'u1', email: 'dev@example.com', role: 'admin' })),\n]\n`);
13
+ ctx.write("app/mocks/browser.ts", `import { setupBrowserMocks } from '@kitr/mocks'\nimport { handlers } from './handlers'\n\n/**\n * \`await enableMocks()\` in the client entry. Env values are strings, so only \`VITE_MOCKS=true\`\n * turns mocks on (\`false\` would be truthy), and only in dev.\n */\nexport async function enableMocks() {\n if (import.meta.env.DEV && import.meta.env.VITE_MOCKS === 'true') await setupBrowserMocks(handlers)\n}\n`);
14
+ ctx.packageJson.set("msw", { workerDirectory: ["public"] });
15
+ ctx.scripts.add("mocks:init", "msw init public");
16
+ ctx.env.add({ VITE_MOCKS: "false" }, "Set true to serve app/mocks/handlers.ts in the browser");
17
+ ctx.agents.add("mocks", `- Mocks live in \`app/mocks/handlers.ts\` (MSW). \`VITE_MOCKS=true\` enables them in dev (run \`npm run mocks:init\` once if public/mockServiceWorker.js is missing); tests use \`setupNodeMocks(handlers)\`. Mock a kitr route table with \`mockRoutes(routes.toJSON(), baseUrl, { 'projects.index': () => [...] })\`.`);
18
+ },
19
+ remove(ctx) {
20
+ ctx.deps.remove("msw");
21
+ ctx.packageJson.remove("msw", { workerDirectory: ["public"] });
22
+ ctx.scripts.remove("mocks:init", "msw init public");
23
+ ctx.agents.remove("mocks");
24
+ },
25
+ eject: { "@kitr/mocks": "msw" }
26
+ });
27
+ export { kitr_default as default };
package/docs/api.md ADDED
@@ -0,0 +1,83 @@
1
+ ---
2
+ title: API reference
3
+ description: Every export of @kitr/mocks, with its signature.
4
+ order: 2
5
+ ---
6
+
7
+ # API reference
8
+
9
+ Every export of `@kitr/mocks`, read from the published type definitions. The [guide](/packages/mocks/guide) shows them in use.
10
+
11
+ ## @kitr/mocks
12
+
13
+ Re-exports `msw` unchanged, plus the following.
14
+
15
+ ### kitrErrorResponse
16
+
17
+ ```ts
18
+ function kitrErrorResponse(error: unknown): HttpResponse<import("msw").JsonBodyType>;
19
+ ```
20
+
21
+ Turns anything thrown into the JSON response a kitr server sends for it: a `KitrError` keeps its
22
+ code and status, anything else becomes `INTERNAL` with status 500.
23
+
24
+ ### mockRoutes
25
+
26
+ ```ts
27
+ function mockRoutes(table: RouteDescriptor[], baseUrl: string, resolvers: Record<string, Resolver | unknown>): RequestHandler[];
28
+ ```
29
+
30
+ MSW handlers from a kitr route table (`routes.toJSON()`) or an OpenAPI-ish list.
31
+ Unhandled routes get a 501 so a missing mock is loud, not silent.
32
+
33
+ ```ts
34
+ const handlers = mockRoutes(routeTable, 'http://localhost:3000', {
35
+ 'projects.index': () => [{ id: '1', name: 'Alpha' }],
36
+ 'projects.show': ({ params }) => ({ id: params.id, name: 'Alpha' }),
37
+ })
38
+ ```
39
+
40
+ ### mockValidationError
41
+
42
+ ```ts
43
+ function mockValidationError(method: 'post' | 'put' | 'patch', url: string, fieldErrors: Record<string, string[]>): import("msw").HttpHandler;
44
+ ```
45
+
46
+ A handler that throws a kitr validation error, to test form error mapping.
47
+
48
+ ### setupBrowserMocks
49
+
50
+ ```ts
51
+ function setupBrowserMocks(handlers: RequestHandler[], options?: {
52
+ quiet?: boolean;
53
+ }): Promise<import("msw/browser").SetupWorker>;
54
+ ```
55
+
56
+ Starts MSW's service worker in the browser for development: `await setupBrowserMocks(handlers)`.
57
+ Needs `mockServiceWorker.js` in the public folder. Pass `quiet: false` to see MSW's console messages.
58
+
59
+ ### setupNodeMocks
60
+
61
+ ```ts
62
+ function setupNodeMocks(handlers: RequestHandler[]): Promise<import("msw/node").SetupServer>;
63
+ ```
64
+
65
+ Starts MSW in Node for tests: `const mocks = await setupNodeMocks(handlers)`. Requests no handler
66
+ matches go to the real network. Returns MSW's server, so `use`, `resetHandlers` and `close` work.
67
+
68
+ ### RouteDescriptor
69
+
70
+ ```ts
71
+ interface RouteDescriptor {
72
+ /** The route name that `mockRoutes` resolvers are keyed by, such as `orders.show`. */
73
+ name: string;
74
+ /** The HTTP method, in any case: `GET`, `post`. */
75
+ method: string;
76
+ /** The path pattern with `:params`, such as `/api/orders/:id`. */
77
+ path: string;
78
+ }
79
+ ```
80
+
81
+ One route of a kitr route table, as `routes.toJSON()` and `kitr api:sync` describe it.
82
+
83
+ <!-- GENERATED by scripts/api-docs.mjs from the type definitions. Do not edit. -->
package/docs/index.md ADDED
@@ -0,0 +1,527 @@
1
+ ---
2
+ title: Mocks
3
+ description: Fake your API while you build the frontend or run tests.
4
+ order: 1
5
+ ---
6
+
7
+ # Mocks
8
+
9
+ Frontend work shouldn't wait for the backend. The invoices screen is designed, the API that feeds it is still a ticket, and the tests for its empty and error states need answers the real server rarely gives. `@kitr/mocks` answers your app's HTTP requests with fake data, in the browser while you develop and in Node while your tests run.
10
+
11
+ > [!SUMMARY]
12
+ > Fake your API with Mock Service Worker, in the browser and in tests. Add it with `kitr add mocks`; the overview is at [/packages/mocks](/packages/mocks).
13
+
14
+ It is [Mock Service Worker](https://mswjs.io) (MSW), with `http` and `HttpResponse` re-exported unchanged. It adds `mockRoutes()`, which builds one handler per route of a kitr route table, and helpers that send errors in the shape a kitr server sends them.
15
+
16
+ In the browser a service worker intercepts `fetch`; in Node MSW patches it. Requests the server makes while rendering never reach the browser, so they are not mocked. To test your own API routes rather than fake them, use [testing](/packages/testing)'s `createTestClient`.
17
+
18
+ The examples follow Acme's billing team building the invoices screen at `app.acme.com` before the `/api/invoices` routes exist. Ada is an admin; Grace and Linus are customers.
19
+
20
+ ## Install
21
+
22
+ ```sh
23
+ kitr add mocks
24
+ ```
25
+
26
+ <details>
27
+ <summary>Everything kitr add changes</summary>
28
+
29
+ What `kitr add` does:
30
+
31
+ - Adds `@kitr/mocks` to `dependencies` and `msw` to `devDependencies`, and installs them. Pass `--no-install` to skip the install.
32
+ - Adds `'@kitr/mocks'` to `packages` in `kitrrc.ts`. It registers no provider: mocks start only when your code calls them.
33
+ - Writes `app/mocks/handlers.ts` with one handler for `*/me`, unless the file exists.
34
+ - Writes `app/mocks/browser.ts`, which exports `enableMocks()`, unless the file exists. It starts the worker only in development and only when `VITE_MOCKS` is exactly `'true'`.
35
+ - Sets `"msw": { "workerDirectory": ["public"] }` in `package.json`, so MSW's own install step writes and refreshes `public/mockServiceWorker.js`.
36
+ - Adds a `mocks:init` script (`msw init public`) that writes the worker on demand.
37
+ - Adds `VITE_MOCKS=false` to `.env.example`, and to `.env` when it exists, under the comment `# Set true to serve app/mocks/handlers.ts in the browser`.
38
+ - Adds a `## kitr: mocks` section to `AGENTS.md`: handlers live in `app/mocks/handlers.ts`, `VITE_MOCKS=true` turns them on in development, tests use `setupNodeMocks(handlers)`, and a kitr route table is mocked with `mockRoutes(routes.toJSON(), baseUrl, { ... })`.
39
+
40
+ The `app`, `desktop` and `mobile` kits include it. `kitr remove mocks` reverses the `package.json`, `kitrrc.ts` and `AGENTS.md` changes and lists files that still import `@kitr/mocks`. It leaves `app/mocks/`, the `VITE_MOCKS` line and `public/mockServiceWorker.js` for you to delete.
41
+
42
+ </details>
43
+
44
+ > [!WARNING] Client packages only
45
+ > In a project without React, `kitr add` skips mocks with a warning.
46
+
47
+ ## Configure
48
+
49
+ `@kitr/mocks` has no config file: you configure the handler list and one env variable. `kitr add` writes these two files:
50
+
51
+ ```ts
52
+ // app/mocks/handlers.ts
53
+ import { HttpResponse, http } from '@kitr/mocks'
54
+
55
+ export const handlers = [
56
+ http.get('*/me', () => HttpResponse.json({ id: 'u1', email: 'dev@example.com', role: 'admin' })),
57
+ ]
58
+ ```
59
+
60
+ ```ts
61
+ // app/mocks/browser.ts
62
+ import { setupBrowserMocks } from '@kitr/mocks'
63
+ import { handlers } from './handlers'
64
+
65
+ /**
66
+ * `await enableMocks()` in the client entry. Env values are strings, so only `VITE_MOCKS=true`
67
+ * turns mocks on (`false` would be truthy), and only in dev.
68
+ */
69
+ export async function enableMocks() {
70
+ if (import.meta.env.DEV && import.meta.env.VITE_MOCKS === 'true') await setupBrowserMocks(handlers)
71
+ }
72
+ ```
73
+
74
+ | Variable | Used for |
75
+ | --- | --- |
76
+ | `VITE_MOCKS` | `true` serves `app/mocks/handlers.ts` in the browser during `kitr dev`. Anything else leaves the real network alone. |
77
+
78
+ The functions that start mocks take these options:
79
+
80
+ | Option | Default | Description |
81
+ | --- | --- | --- |
82
+ | `setupBrowserMocks(handlers)` | required | The handlers to serve in the browser. |
83
+ | `setupBrowserMocks(_, { quiet })` | `true` | `false` lets MSW log `[MSW] Mocking enabled` and every request it handles to the console. |
84
+ | `setupNodeMocks(handlers)` | required | The handlers to serve in Node. Starts listening immediately. |
85
+ | `mockRoutes(table)` | required | The route table: `{ name, method, path }` entries, from `routes.toJSON()` or `kitr api:sync`. |
86
+ | `mockRoutes(_, baseUrl)` | required | Prefixed to every path. `'*'` matches any origin; `'http://localhost:3333'` matches only that one. A trailing slash is removed. |
87
+ | `mockRoutes(_, _, resolvers)` | required | The answers, keyed by route name. A route without one answers `501`. |
88
+
89
+ A request that no handler matches goes to the real network, so you can fake one endpoint and keep the rest.
90
+
91
+ ## Fake your first endpoint
92
+
93
+ Add a handler for the request your screen makes. This one answers the invoice list with three invoices:
94
+
95
+ ```ts
96
+ // app/mocks/handlers.ts
97
+ import { HttpResponse, http } from '@kitr/mocks'
98
+
99
+ export const handlers = [
100
+ http.get('*/api/me', () =>
101
+ HttpResponse.json({ id: 'u_ada', email: 'ada@acme.com', name: 'Ada', role: 'admin' }),
102
+ ),
103
+ http.get('*/api/invoices', () =>
104
+ HttpResponse.json([
105
+ { id: 'inv_1001', customer: 'Grace', amount: 120, status: 'paid' },
106
+ { id: 'inv_1002', customer: 'Linus', amount: 300, status: 'open' },
107
+ { id: 'inv_1003', customer: 'Grace', amount: 80, status: 'draft' },
108
+ ]),
109
+ ),
110
+ ]
111
+ ```
112
+
113
+ Set `VITE_MOCKS=true` in `.env`, restart `kitr dev`, and open the invoices page. The network panel shows each request answered `from service worker`.
114
+
115
+ The leading `*` matches any origin, in development or in a preview. `http` and `HttpResponse` are MSW's own, so [MSW's docs](https://mswjs.io/docs/http) apply.
116
+
117
+ ## Turn mocks on in the browser
118
+
119
+ MSW needs its worker script served from your site's root. The install writes `public/mockServiceWorker.js`; if it is missing, for example after `--no-install`, write it and commit it:
120
+
121
+ ```sh
122
+ pnpm mocks:init
123
+ ```
124
+
125
+ Then start the worker in `app/client.tsx`, before the app boots:
126
+
127
+ ```tsx
128
+ // app/client.tsx
129
+ import { StartClient } from '@tanstack/react-start/client'
130
+ import { StrictMode, startTransition } from 'react'
131
+ import { hydrateRoot } from 'react-dom/client'
132
+ import { app } from './app'
133
+
134
+ async function start() {
135
+ if (import.meta.env.DEV && import.meta.env.VITE_MOCKS === 'true') {
136
+ const { enableMocks } = await import('#app/mocks/browser')
137
+ await enableMocks()
138
+ }
139
+ await app.boot()
140
+ startTransition(() => {
141
+ hydrateRoot(
142
+ document,
143
+ <StrictMode>
144
+ <StartClient />
145
+ </StrictMode>,
146
+ )
147
+ })
148
+ }
149
+
150
+ start()
151
+ ```
152
+
153
+ The dynamic `import()` keeps MSW and your fake data out of the production bundle, and `import.meta.env.DEV` is `false` in every production build.
154
+
155
+ > [!WARNING] Compare to the string
156
+ > Don't write `if (import.meta.env.VITE_MOCKS)`: env values are strings, and `'false'` is truthy.
157
+
158
+ In a server-rendered app, loaders that fetch during the first render hit the real API. Requests after hydration, and every request in a single-page app, go through the mocks.
159
+
160
+ ## Write handlers
161
+
162
+ A handler pairs a method and URL pattern with a function that returns a response. Use `:name` for path params, `request.url` for the query string and `request.json()` for the body:
163
+
164
+ ```ts
165
+ // app/mocks/handlers.ts
166
+ import { HttpResponse, http } from '@kitr/mocks'
167
+
168
+ export const handlers = [
169
+ http.get('*/api/invoices/:id', ({ params }) =>
170
+ HttpResponse.json({ id: params.id, customer: 'Grace', amount: 120, status: 'paid' }),
171
+ ),
172
+
173
+ http.get('*/api/invoices', ({ request }) => {
174
+ const status = new URL(request.url).searchParams.get('status')
175
+ return HttpResponse.json([{ id: 'inv_1002', customer: 'Linus', amount: 300, status }])
176
+ }),
177
+
178
+ http.post('*/api/invoices', async ({ request }) => {
179
+ const input = (await request.json()) as { customer: string; amount: number }
180
+ return HttpResponse.json(
181
+ { id: 'inv_1004', status: 'draft', ...input },
182
+ { status: 201, headers: { location: '/api/invoices/inv_1004' } },
183
+ )
184
+ }),
185
+
186
+ http.delete('*/api/invoices/:id', () => new HttpResponse(null, { status: 204 })),
187
+ ]
188
+ ```
189
+
190
+ `HttpResponse.json()`, `.text()`, `.xml()` and a plain `new HttpResponse(body, init)` cover the response types. When two handlers match, the first in the list wins.
191
+
192
+ ## Keep fake data in one place
193
+
194
+ Handlers with literal objects drift apart. Put the records in one module and have the handlers read and write it, so a created invoice appears in the list:
195
+
196
+ ```ts
197
+ // app/mocks/data.ts
198
+ export interface Invoice {
199
+ id: string
200
+ customer: string
201
+ amount: number
202
+ status: 'draft' | 'open' | 'paid'
203
+ }
204
+
205
+ export const invoices: Invoice[] = [
206
+ { id: 'inv_1001', customer: 'Grace', amount: 120, status: 'paid' },
207
+ { id: 'inv_1002', customer: 'Linus', amount: 300, status: 'open' },
208
+ { id: 'inv_1003', customer: 'Grace', amount: 80, status: 'draft' },
209
+ ]
210
+ ```
211
+
212
+ ```ts
213
+ // app/mocks/handlers.ts
214
+ import { HttpResponse, http } from '@kitr/mocks'
215
+ import { type Invoice, invoices } from './data'
216
+
217
+ export const handlers = [
218
+ http.get('*/api/invoices', () => HttpResponse.json(invoices)),
219
+
220
+ http.post('*/api/invoices', async ({ request }) => {
221
+ const input = (await request.json()) as Pick<Invoice, 'customer' | 'amount'>
222
+ const invoice: Invoice = { id: `inv_${1001 + invoices.length}`, status: 'draft', ...input }
223
+ invoices.push(invoice)
224
+ return HttpResponse.json(invoice, { status: 201 })
225
+ }),
226
+ ]
227
+ ```
228
+
229
+ The array lives as long as the page or test file; reload and it starts over.
230
+
231
+ ## Mock a whole route table
232
+
233
+ With the [API](/packages/api) package, every route has a name. `mockRoutes` builds one handler per route; you write only the answers, keyed by name:
234
+
235
+ ```ts
236
+ // app/mocks/handlers.ts
237
+ import { KitrError } from 'kitr/app'
238
+ import { HttpResponse, mockRoutes } from '@kitr/mocks'
239
+ import { routeTable } from '#app/api/routes.gen'
240
+ import { type Invoice, invoices } from './data'
241
+
242
+ export const handlers = mockRoutes([...routeTable], '*', {
243
+ 'invoices.index': ({ request }) => {
244
+ const status = new URL(request.url).searchParams.get('status')
245
+ return status ? invoices.filter((invoice) => invoice.status === status) : invoices
246
+ },
247
+ 'invoices.show': ({ params }) => {
248
+ const invoice = invoices.find((i) => i.id === params.id)
249
+ if (!invoice) throw KitrError.notFound(`Invoice ${params.id}`)
250
+ return invoice
251
+ },
252
+ 'invoices.store': async ({ body }) => {
253
+ const input = (await body()) as Pick<Invoice, 'customer' | 'amount'>
254
+ const invoice: Invoice = { id: `inv_${1001 + invoices.length}`, status: 'draft', ...input }
255
+ invoices.push(invoice)
256
+ return HttpResponse.json(invoice, { status: 201 })
257
+ },
258
+ 'invoices.destroy': () => undefined,
259
+ 'auth.me': { id: 'u_ada', email: 'ada@acme.com', role: 'admin' },
260
+ })
261
+ ```
262
+
263
+ `routeTable` is the file `kitr api:sync` writes: names, methods and paths, with no server code. In a test, `routes.toJSON()` from `#server/routes` gives the same list.
264
+
265
+ What a resolver can be, and what the request gets back:
266
+
267
+ | Resolver | Response |
268
+ | --- | --- |
269
+ | A value, not a function: `{ id: 'u_ada' }` | `200` with that value as JSON, for every request. |
270
+ | A function returning a value | `200` with the value as JSON. |
271
+ | A function returning `undefined` | `204` with no body. |
272
+ | A function returning a `Response` | That response, as is. Use it for other statuses and headers. |
273
+ | A function that throws a `KitrError` | That error's status and JSON, as the real server sends it. |
274
+ | A function that throws anything else | `500` with `code: 'INTERNAL'` and the error's message. |
275
+ | No entry for the route | `501` with `code: 'UNAVAILABLE'` and `No mock for invoices.update`. |
276
+
277
+ A resolver function receives `params`, `request` and `body()`, which reads the JSON body. An entry whose value is `undefined` counts as no entry; write `() => undefined` for a `204`. The `501` is on purpose: a route you forgot to mock fails loudly with its name.
278
+
279
+ ## Send an error response
280
+
281
+ `kitrErrorResponse()` turns any error into the JSON response a kitr server would send, so the [http](/packages/http) client turns it back into the `KitrError` your error handling expects:
282
+
283
+ ```ts
284
+ // app/mocks/handlers.ts
285
+ import { KitrError } from 'kitr/app'
286
+ import { http, kitrErrorResponse } from '@kitr/mocks'
287
+
288
+ export const handlers = [
289
+ http.post('*/api/invoices/:id/refund', () =>
290
+ kitrErrorResponse(KitrError.forbidden('Only admins can refund')),
291
+ ),
292
+ http.get('*/api/invoices/export', () =>
293
+ kitrErrorResponse(new KitrError('RATE_LIMITED', 'Try again in a minute')),
294
+ ),
295
+ http.get('*/api/ledger', () => kitrErrorResponse(new Error('Ledger offline'))),
296
+ ]
297
+ ```
298
+
299
+ The three responses:
300
+
301
+ ```json
302
+ { "code": "FORBIDDEN", "message": "Only admins can refund", "status": 403 }
303
+ { "code": "RATE_LIMITED", "message": "Try again in a minute", "status": 429 }
304
+ { "code": "INTERNAL", "message": "Ledger offline", "status": 500 }
305
+ ```
306
+
307
+ A `KitrError` keeps its code, message, status, `fieldErrors` and `data`; anything else becomes `INTERNAL` (500). Inside `mockRoutes`, throwing does the same.
308
+
309
+ ## Show server validation errors in a form
310
+
311
+ `mockValidationError(method, url, fieldErrors)` answers with a `422`, the quickest way to check that a form shows server messages under the right fields:
312
+
313
+ ```ts
314
+ // app/mocks/handlers.ts
315
+ import { mockValidationError } from '@kitr/mocks'
316
+
317
+ export const handlers = [
318
+ mockValidationError('post', '*/api/invoices', {
319
+ customer: ['Customer is required'],
320
+ amount: ['Amount must be greater than 0'],
321
+ }),
322
+ ]
323
+ ```
324
+
325
+ ```json
326
+ {
327
+ "code": "VALIDATION",
328
+ "message": "Validation failed",
329
+ "status": 422,
330
+ "fieldErrors": {
331
+ "customer": ["Customer is required"],
332
+ "amount": ["Amount must be greater than 0"]
333
+ }
334
+ }
335
+ ```
336
+
337
+ `method` is `'post'`, `'put'` or `'patch'`. With [forms](/packages/forms), each message appears under its field. Keys can be nested paths such as `'lines.0.amount'`.
338
+
339
+ ## Simulate a slow or failed request
340
+
341
+ MSW's `delay` pauses a handler; `HttpResponse.error()` makes `fetch` reject as if the network were down. Import `delay` from `msw`:
342
+
343
+ ```ts
344
+ // app/mocks/handlers.ts
345
+ import { HttpResponse, http } from '@kitr/mocks'
346
+ import { delay } from 'msw'
347
+ import { invoices } from './data'
348
+
349
+ export const handlers = [
350
+ http.get('*/api/invoices', async () => {
351
+ await delay(1500)
352
+ return HttpResponse.json(invoices)
353
+ }),
354
+ http.get('*/api/invoices/:id/pdf', () => HttpResponse.error()),
355
+ ]
356
+ ```
357
+
358
+ `delay()` with no argument waits a realistic random time; `delay('infinite')` never answers, for testing a spinner.
359
+
360
+ ## Mock a third-party API
361
+
362
+ Handlers match full URLs too, so the same file can fake services you don't own:
363
+
364
+ ```ts
365
+ // app/mocks/handlers.ts
366
+ import { HttpResponse, http } from '@kitr/mocks'
367
+
368
+ export const handlers = [
369
+ http.get('https://api.exchangerate.host/latest', ({ request }) => {
370
+ const base = new URL(request.url).searchParams.get('base') ?? 'EUR'
371
+ return HttpResponse.json({ base, rates: { USD: 1.08, GBP: 0.85 } })
372
+ }),
373
+ ]
374
+ ```
375
+
376
+
377
+ ## Use mocks in tests
378
+
379
+ `setupNodeMocks(handlers)` starts MSW in Node and returns MSW's server. Start it once per file, reset overrides after each test, and close it at the end:
380
+
381
+ ```ts
382
+ // app/lib/invoices/invoices.test.ts
383
+ import { setupNodeMocks } from '@kitr/mocks'
384
+ import { afterAll, afterEach, beforeAll, expect, it } from 'vitest'
385
+ import { handlers } from '#app/mocks/handlers'
386
+
387
+ let mocks: Awaited<ReturnType<typeof setupNodeMocks>>
388
+ beforeAll(async () => {
389
+ mocks = await setupNodeMocks(handlers)
390
+ })
391
+ afterEach(() => mocks.resetHandlers())
392
+ afterAll(() => mocks.close())
393
+
394
+ it('lists open invoices', async () => {
395
+ const res = await fetch('https://app.acme.com/api/invoices?status=open')
396
+ expect(await res.json()).toEqual([
397
+ { id: 'inv_1002', customer: 'Linus', amount: 300, status: 'open' },
398
+ ])
399
+ })
400
+ ```
401
+
402
+ Any code that calls `fetch` sees the mocks. Under `happy-dom` a relative URL such as `/api/invoices` resolves against `http://localhost:3000`; in the `node` environment use absolute URLs.
403
+
404
+ To render a component against these mocks, combine them with `renderWithKitr` from [testing](/packages/testing#fake-the-network-for-components).
405
+
406
+ ## Override a handler for one test
407
+
408
+ `mocks.use()` puts handlers in front of the list until the next `resetHandlers()`, giving one test the empty state or error it needs:
409
+
410
+ ```ts
411
+ // app/lib/invoices/invoices.test.ts
412
+ import { KitrError } from 'kitr/app'
413
+ import { HttpResponse, http, kitrErrorResponse, setupNodeMocks } from '@kitr/mocks'
414
+ import { afterAll, afterEach, beforeAll, expect, it } from 'vitest'
415
+ import { handlers } from '#app/mocks/handlers'
416
+
417
+ let mocks: Awaited<ReturnType<typeof setupNodeMocks>>
418
+ beforeAll(async () => {
419
+ mocks = await setupNodeMocks(handlers)
420
+ })
421
+ afterEach(() => mocks.resetHandlers())
422
+ afterAll(() => mocks.close())
423
+
424
+ it('shows the empty state', async () => {
425
+ mocks.use(http.get('*/api/invoices', () => HttpResponse.json([])))
426
+ expect(await (await fetch('/api/invoices')).json()).toEqual([])
427
+ })
428
+
429
+ it('shows the permission error', async () => {
430
+ mocks.use(http.get('*/api/invoices', () => kitrErrorResponse(KitrError.forbidden())))
431
+ const res = await fetch('/api/invoices')
432
+ expect(res.status).toBe(403)
433
+ })
434
+
435
+ it('is back to the shared handlers', async () => {
436
+ expect((await fetch('/api/invoices')).status).toBe(200)
437
+ })
438
+ ```
439
+
440
+ > [!WARNING] Start once per file
441
+ > Call `setupNodeMocks` in `beforeAll`, not in each test, and override with `use()`. See MSW's [server API](https://mswjs.io/docs/api/setup-server).
442
+
443
+ ## Turn mocks off
444
+
445
+ Set `VITE_MOCKS=false` in `.env` and restart `kitr dev`; the real API answers. To mock one endpoint only, keep `VITE_MOCKS=true` and delete the other handlers. Production builds never start the worker.
446
+
447
+ ## Errors
448
+
449
+ The responses `@kitr/mocks` builds itself:
450
+
451
+ | Code | When | What to do |
452
+ | --- | --- | --- |
453
+ | `UNAVAILABLE` (501) | `mockRoutes` got a request for a route with no resolver: `No mock for invoices.update`. | Add a resolver under that route name, or remove the route from the table you pass. |
454
+ | `INTERNAL` (500) | A resolver threw something that is not a `KitrError`, or `kitrErrorResponse` got a plain error. | Throw a `KitrError` with the code you mean, or fix the resolver. |
455
+ | `VALIDATION` (422) | `mockValidationError` answered. | Expected: the form should show `fieldErrors`. |
456
+
457
+ When the browser shows `Failed to register a ServiceWorker` or a 404 for `/mockServiceWorker.js`, the worker file is missing. Run `pnpm mocks:init`.
458
+
459
+ ## Testing
460
+
461
+ Your handlers are the contract the frontend is built against, so test them like code. This starts the real `app/mocks/handlers.ts` in Node and checks the answers the invoices screen relies on:
462
+
463
+ ```ts
464
+ // app/mocks/handlers.test.ts
465
+ import { setupNodeMocks } from '@kitr/mocks'
466
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
467
+ import { handlers } from './handlers'
468
+
469
+ const api = 'https://app.acme.com/api'
470
+ let mocks: Awaited<ReturnType<typeof setupNodeMocks>>
471
+
472
+ beforeAll(async () => {
473
+ mocks = await setupNodeMocks(handlers)
474
+ })
475
+ afterAll(() => mocks.close())
476
+
477
+ describe('invoice mocks', () => {
478
+ it('filters the list by status', async () => {
479
+ const res = await fetch(`${api}/invoices?status=draft`)
480
+ expect(await res.json()).toEqual([
481
+ { id: 'inv_1003', customer: 'Grace', amount: 80, status: 'draft' },
482
+ ])
483
+ })
484
+
485
+ it('answers 404 in the kitr error shape', async () => {
486
+ const res = await fetch(`${api}/invoices/inv_9999`)
487
+ expect(res.status).toBe(404)
488
+ expect(await res.json()).toEqual({
489
+ code: 'NOT_FOUND',
490
+ message: 'Invoice inv_9999 not found',
491
+ status: 404,
492
+ })
493
+ })
494
+
495
+ it('adds a created invoice to the list', async () => {
496
+ const created = await fetch(`${api}/invoices`, {
497
+ method: 'POST',
498
+ headers: { 'content-type': 'application/json' },
499
+ body: JSON.stringify({ customer: 'Ada', amount: 45 }),
500
+ })
501
+ expect(created.status).toBe(201)
502
+ expect(await created.json()).toEqual({
503
+ id: 'inv_1004',
504
+ status: 'draft',
505
+ customer: 'Ada',
506
+ amount: 45,
507
+ })
508
+
509
+ const list = (await (await fetch(`${api}/invoices`)).json()) as Array<{ id: string }>
510
+ expect(list.map((invoice) => invoice.id)).toContain('inv_1004')
511
+ })
512
+
513
+ it('fails loudly for a route nobody mocked', async () => {
514
+ const res = await fetch(`${api}/invoices/inv_1001`, { method: 'PATCH', body: '{}' })
515
+ expect(res.status).toBe(501)
516
+ expect(await res.json()).toMatchObject({ message: 'No mock for invoices.update' })
517
+ })
518
+ })
519
+ ```
520
+
521
+ The test uses the handlers from [Mock a whole route table](#mock-a-whole-route-table). The third test's invoice stays in the list for the rest of the file; Vitest gives each file a fresh copy.
522
+
523
+ ## Next steps
524
+
525
+ - [API reference](/packages/mocks/api): every export of `@kitr/mocks`.
526
+ - [Testing](/packages/testing): `createTestApp`, `renderWithKitr` and the API test client.
527
+ - [API](/packages/api): route names and `kitr api:sync`, which feed `mockRoutes`.
@@ -0,0 +1,63 @@
1
+ ---
2
+ title: Overview
3
+ description: Fake your API while you build the frontend or run tests.
4
+ order: 0
5
+ ---
6
+
7
+ # Mocks
8
+
9
+ ## What it is
10
+
11
+ `@kitr/mocks` fakes your API. Write a handler per endpoint, or hand `mockRoutes()` your route table, and the app's real HTTP client gets realistic responses, including errors in the shape a kitr server sends. Turn them on in development with `VITE_MOCKS=true`, or in Vitest with `setupNodeMocks()`.
12
+
13
+ Runs in the browser and in tests. To test your own API routes, use [testing](/packages/testing).
14
+
15
+ ## Install
16
+
17
+ ```sh
18
+ kitr add mocks
19
+ ```
20
+
21
+ Adds `@kitr/mocks` and MSW, `app/mocks/handlers.ts`, the service worker in `public/` and `VITE_MOCKS=false` in `.env.example`. No provider. [Every change](/packages/mocks/guide#install).
22
+
23
+ ## Quick start
24
+
25
+ Add a handler for the request your screen makes:
26
+
27
+ ```ts
28
+ // app/mocks/handlers.ts
29
+ import { HttpResponse, http } from '@kitr/mocks'
30
+
31
+ export const handlers = [
32
+ http.get('*/api/me', () =>
33
+ HttpResponse.json({ id: 'u_ada', email: 'ada@acme.com', name: 'Ada', role: 'admin' }),
34
+ ),
35
+ http.get('*/api/invoices', () =>
36
+ HttpResponse.json([
37
+ { id: 'inv_1001', customer: 'Grace', amount: 120, status: 'paid' },
38
+ { id: 'inv_1002', customer: 'Linus', amount: 300, status: 'open' },
39
+ ]),
40
+ ),
41
+ ]
42
+ ```
43
+
44
+ Set `VITE_MOCKS=true`, restart `kitr dev`, and the page shows the two invoices. The network panel says `from service worker`. `*` matches any origin. Next in the guide: [Fake your first endpoint](/packages/mocks/guide#fake-your-first-endpoint).
45
+
46
+ ## Built on
47
+
48
+ kitr adds `mockRoutes()`, error responses in kitr's shape and the two setup helpers.
49
+
50
+ ## Problems it solves
51
+
52
+ - **Building screens before the API exists.** [Fake your first endpoint](/packages/mocks/guide#fake-your-first-endpoint).
53
+ - **Writing one handler per route by hand.** [Mock a whole route table](/packages/mocks/guide#mock-a-whole-route-table).
54
+ - **Error states that are hard to reach.** [Send an error response](/packages/mocks/guide#send-an-error-response).
55
+ - **Tests that hit the network.** [Use mocks in tests](/packages/mocks/guide#use-mocks-in-tests).
56
+ - **Third-party APIs in development.** [Mock a third-party API](/packages/mocks/guide#mock-a-third-party-api).
57
+
58
+ ## Further reading
59
+
60
+ - [Guide](/packages/mocks/guide).
61
+ - [API reference](/packages/mocks/api).
62
+ - [MSW docs](https://mswjs.io/docs).
63
+ - [Testing](/packages/testing), [API](/packages/api) and [HTTP](/packages/http).
package/package.json CHANGED
@@ -1,6 +1,66 @@
1
1
  {
2
2
  "name": "@kitr/mocks",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.1",
4
+ "description": "@kitr/mocks: MSW handlers from kitr contracts, for dev and tests.",
5
+ "kitr": {
6
+ "name": "mocks",
7
+ "title": "Mocks",
8
+ "summary": "Fake your API while you build the frontend or run tests.",
9
+ "category": "dev-tools",
10
+ "runsOn": "client",
11
+ "builtOn": [
12
+ "msw"
13
+ ],
14
+ "client": true,
15
+ "docs": "docs",
16
+ "setup": "./kitr"
17
+ },
18
+ "type": "module",
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/kitrjs/kitr.git",
23
+ "directory": "packages/mocks"
24
+ },
25
+ "homepage": "https://kitr.dev/packages/mocks",
26
+ "bugs": "https://github.com/kitrjs/kitr/issues",
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "sideEffects": false,
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "default": "./dist/index.js"
35
+ },
36
+ "./kitr": {
37
+ "types": "./dist/kitr.d.ts",
38
+ "default": "./dist/kitr.js"
39
+ },
40
+ "./package.json": "./package.json"
41
+ },
42
+ "files": [
43
+ "dist",
44
+ "docs"
45
+ ],
46
+ "dependencies": {
47
+ "@kitr/core": "0.2.1"
48
+ },
49
+ "peerDependencies": {
50
+ "msw": ">=2.0.0"
51
+ },
52
+ "devDependencies": {
53
+ "tsdown": "^0.23.7",
54
+ "typescript": "^5.9.3",
55
+ "msw": "^3.0.4",
56
+ "@types/node": "^22.19.0"
57
+ },
58
+ "keywords": [
59
+ "kitr",
60
+ "kitr-package"
61
+ ],
62
+ "scripts": {
63
+ "build": "tsdown",
64
+ "typecheck": "tsc -p tsconfig.json"
65
+ }
6
66
  }