Install
npm install --save-dev @noverax/courier-mockIt works with the app's own @noverax/courier. It also needs hono, and a mock server — one Studio runs, or one from /server — needs @hono/node-server. Add the ones the app does not depend on already, for development only:
npm install --save-dev hono @hono/node-serverLeave out any the app already lists under dependencies: --save-dev would move it out of them, and a production install would then go without it.
Declaring mocks
Declare mocks in courier.mocks.ts, beside courier.config.ts: its default export is defineMocks({ ... }), with one defineMockServer(...) under each key. Studio lists each mock by its key, generates its routes and data from its source, and runs it — at launch when autoStart is set, or when you start it from the Mocks view. Studio's mock wizard can write the declarations for you; see Add mocks from Studio.
The mocks have a file of their own because courier.config.ts is also loaded in production — by the standalone server, and by attachRuntime() — where this package is not installed. Only Studio reads courier.mocks.ts. courier.config.ts has no mocks key: defineConfig does not type-check with one, and Studio reads mocks from nowhere else.
// src/api.ts
import { courier, defineApi, defineGroup } from "@noverax/courier/define"
type User = { id: number; name: string }
const { endpoint } = courier()
const users = defineGroup({
prefix: "users",
endpoints: {
getAll: endpoint.get("").response<User[]>(),
getById: endpoint.get(":id").variables<{ id: number }>().response<User>(),
},
})
export const api = defineApi({ baseURL: "https://api.example.com", groups: { users } })// courier.config.ts
import { defineConfig } from "@noverax/courier/config"
import { api } from "./src/api"
export default defineConfig({ sources: { api } })// courier.mocks.ts
import { defineMocks, defineMockServer } from "@noverax/courier-mock"
import { api } from "./src/api"
export default defineMocks({
api: defineMockServer({
source: api,
port: 7001,
autoStart: true,
seed: { users: 5 },
overrides: (mock) => {
mock("users", "getById").rejects(404, { message: "Not found" })
},
}),
})sourceis the source object the mock serves. Import it from where the config does, as above, or read it from the config —import courierConfig from "./courier.config", thensource: courierConfig.sources.api— as Studio's wizard writes it. That form needs a config whose default export isdefineConfig({ sources }): a config that exportssourcesby name only loads, but a default import of it does not type-check. In a"type": "module"package whose tsconfig resolves modules as Node does (node16ornodenext), import./courier.config.jsinstead; TypeScript asks for the extension there, and the wizard writes it that way. Studio loadscourier.mocks.tstogether with the config, as one module graph, so a module both import runs once, and either way the mock holds the very source object the config declares.- The file may also be named
courier.mocks.mts,.cts,.js,.mjsor.cjs. Studio looks for these names in that order, in the directory that holdscourier.config, and loads the first it finds: acourier.mocks.tsthere hides acourier.mocks.jsbeside it. A TypeScript file loads as it is, with extensionless imports and tsconfig path aliases; nothing else needs installing. portis held exactly: when it is taken, Studio says so rather than moving the mock. Without one, Studio assigns the key a port fromstudio.mockBasePort(7001 by default), clear of the ports the project's other mocks were given. The mock takes that port, or the first free one above it, as it starts listening: the bind itself decides, with no check made beforehand, so two Studios starting their mocks at the same moment — a monorepo's dev task — never take the same port. The key is assigned another when a mock declares its port, and after Studio restarts the mock can get another too. Setportto keep one.- A QUERY is answered as the read it is, its terms read from the body: at the collection root, the records — paged as the body asks, as a GET's query string asks — and at a record's path, that record. A GET declared at the same path is still answered by its own endpoint, and a QUERY never counts as a change to the store.
- A
.head()or.options()endpoint is answered by the endpoint that declares it, a HEAD at a GET's path included. CORS answers only a browser's preflight — an OPTIONS that names the method it asks to send — and lets every verb through, QUERY among them. - A group inside another is answered at both groups' prefixes in turn, from a collection of its own, and mocked by its path:
mock("billing.invoices", "list"),endpoints: ["billing.invoices.list"],seed: { "billing.invoices": 50 }. A group without a prefix of its own is answered at the paths its endpoints declare, where its client calls them. - The mock answers each group at its prefix, and — for a source whose
baseURLhas a path, such ashttps://api.example.com/api/v3— under that path too, so a client pointed at the mock with only the host swapped reaches it at/api/v3/usersas one pointed at its bare URL does at/users. seedsets how many records each collection starts with:countfor every group (20 by default), and a number under a group's name for that group alone.autoStartdefaults tofalse. A key set toundefineddeclares no mock, so one can be switched off in place.- The records are generated from each endpoint's response schema, and pass it: the bounds, integer and
multipleOfchecks, string and array lengths, formats (email,url,uuid, ISO dates and times, and the other common ones),regexpatterns and prefixes that a Zod (v4 or v3) or Valibot schema declares all hold, so a typed client validating the responses accepts them. Field names pick realistic values where the schema allows one. Add record in Studio's Store adds one more record made the same way, under an id no record of the collection has; edit its cells from there. - An endpoint whose path fixes a query value — OpenAI's specification writes
/responses?beta=truebeside/responses— answers a request at its path that carries the value, and its twin without the query answers the rest, each with its own schema; such an endpoint is not one of its collection's list, get or create. - Each running mock is a process of its own that Studio starts: it loads the mocks file with Studio's loader, reusing the transforms Studio keeps, and serves that one mock on the port Studio chose.
- A mock that fails — a definition Studio cannot read, a missing package, a taken port, a crash — is reported in Studio's terminal, and its status in the Mocks view gives the reason on hover or keyboard focus. A running mock whose edited definition cannot be read keeps serving the last one that could, and says so the same way. The mock's own request log appears in Studio's terminal too;
log: "silent"turns it off. - A
courier.mocks.tsthat does not load — a syntax error, an import that cannot be resolved, no default export — leaves the config and its sources working, and the mocks keep serving the definitions that loaded last. Studio's terminal logs why, and a banner above the Mocks view says so until the file loads again. overridesandscenariosare checked againstsource:mocktakes the source's group and endpoint names and each endpoint's response type. Write a scenario withdefineScenario<typeof api>(...)to have it checked the same way; one without the type argument fits every mock. An endpoint declared outside any group is mocked under the collection its path starts with,mock("users", "listUsers"); the types cannot check that name, so a mock under another collection is refused with aMockTargetErrorthat names the right one.scenariosis an object keyed by each scenario's name,{ empty: defineScenario("empty", …) }, which is how Studio and the admin API name them; an array is refused with aScenarioConfigErrorthat spells the object out.- Four scenarios come with every mock, beside yours:
happy-pathputs nothing on top, so every route answers as declared, from its schema or its override;emptyanswers every list with no records;server-errorfails every endpoint with a 500, 503 or 502;slow-networkslows every read by 300–900 ms, with jitter. A mock starts onhappy-path, so switching back to it turns any other off. One scenario is in force at a time: Studio's Scenarios tab switches them, and so doesPOST /__admin/scenarios/<name>on the mock. - Editing
courier.mocks.ts, or a module it imports, restarts a running mock whose declaration changed, or something that declaration uses — a declaration beside it, an import and every module that import reaches, a statement of the file that declares nothing. The other mocks keep running, with their records: adding a mock does not restart the ones already running. A mocks file whose mocks Studio cannot tell apart — its keys spread in or computed, or a CommonJS file — restarts every running mock on each edit.autoStartapplies when Studio first reads the definition, or when an edit turns it on — not again after you stop the mock. - A mock keeps its records in the project, in
.courier/mocks/— one SQLite database per mock, saved shortly after each request and when the mock stops, and read when it starts — so what an app created is still there after the mock restarts, an edit that changes it included, and after Studio restarts. Nothing is set up by hand: the database and its schema are created on the first start, a database from an earlier Courier is brought up to date as it opens, and records are kept as JSON documents, so an API whose shapes change needs no migration. A save writes, in one transaction, only the records that changed. A database that cannot be read is kept beside the new one as<name>.unreadable-<time>. A collection the database holds nothing for starts from its seeds, and one the mock no longer serves is dropped. Reset store empties the store and seeds it again, and that is kept too.persist: falsestarts the mock from its seeds each time. The database is Node's built-in SQLite, in Node 22.13 and later; elsewhere a mock keeps its records only while it runs, and says so.
Mock only some endpoints, or answer with recordings
A mock need not answer everything. endpoints names the endpoints it answers; every other HTTP call goes to the source's own server at its baseURL, as the app calls it — handy while the backend has all but a few endpoints. fixtures answers with recordings of that server in place of generated data: a call the mock has no answer to yet goes to the server once, its answer is written to a HAR file, and the same call is answered from the file from then on.
// courier.mocks.ts
import { defineMocks, defineMockServer } from "@noverax/courier-mock"
import { api } from "./src/api"
export default defineMocks({
refunds: defineMockServer({ source: api, endpoints: ["users.getById"] }),
demo: defineMockServer({ source: api, fixtures: "./fixtures/api.har" }),
})endpointstakesgroup.method, or the method alone for an endpoint outside any group, and is checked against the source: a name it does not have stops the mock with aMockEndpointsErrorthat lists the names it has. Streams and channels the mock always answers itself. An endpoint left to the server is not mocked at all, so an override or a scenario for it does not apply.- A call passed to the server carries the request's own headers, its credentials included, but none of the hop's (
host,origin,content-length,sec-*and the like). The server's answer, a failure included, comes back with its status, its data and its headers. fixturesis relative tocourier.mocks.ts. The file is a HAR log, the format browsers' developer tools open; commit it to give everyone the same answers. A recording is matched by its endpoint and its variables, and keeps no credentials:authorization,cookieandset-cookieare left out. An override or a scenario on an endpoint answers in place of its recordings. A call with no recording while the server cannot be reached is answered with a 502 that says so; delete the file to record everything again. A file that is not a HAR log of recordings stops the mock with aFixtureFileError.- Studio's mock wizard sets both: Answers takes every endpoint or the ones ticked, and Data generated or recorded, kept in
fixtures/<key>.harbeside the mocks file. The Routes tab tags each route by what answers it,your serverorrecordedamong them, and the mock's log says the same of each call.
A proxy caller pointed at the mock
A mock stands in for the API itself, where a direct caller goes, and also for a Courier proxy, where a proxy caller goes. It serves the proxy's three routes:
| Route | For | As a Courier proxy serves it |
|---|---|---|
GET/POST /api/request | an HTTP call | the endpoint named by _s, _g and _m in the query; a read's variables in _v, a write's in the JSON or form body |
GET /api/sse | a stream | the stream's events, the variables in _v |
/api/ws (WebSocket) | a channel | every channel a caller subscribes to, on one socket, each by its topic |
import { createProxyCaller } from "@noverax/courier"
import { api as source } from "./src/api"
const mock = "http://localhost:7001"
const api = createProxyCaller("api", {
source,
proxyPath: { http: `${mock}/api/request`, sse: `${mock}/api/sse`, ws: "ws://localhost:7001/api/ws" },
})On /api/ws, and on a channel's own path where a direct caller connects, a subscription goes as it does on a Courier server. The variables are checked against the channel's schema, and a subscription that fails them ends with code 4422. Its topic is keyed as the channel's .topic() says, with a renamed frame first when that differs from the topic asked for, so subscribers who share a key share the channel's broadcasts. Each message is checked against what the channel .send()s, and one that fails is refused with 4422 while the subscription goes on; the message an override's onMessage receives is typed as that. A channel not mocked in the scenario state a subscription is made in ends with 4404, naming it and the state. ctx.close() ends that one subscription and leaves the socket's others open. An override is called with the upgrade request's headers and those of the client's auth frame — an Authorization a browser could send only there — as a Courier server reads them: none the handshake has, and no cookie, origin or forwarding header, is taken from the frame. A channel's own path serves that channel, and the parameters the path carries reach the override beside the variables a subscription sends. Every socket is pinged every 25 seconds, as a Courier server pings it, and closed with 4408 when its client stops answering, so a quiet channel stays open and a client that went away is let go. A mock built without a source checks nothing and keys each topic by its variables. Studio's mock logs show each subscription as it opens and ends.
Uploads and large bodies
A mock reads a body as a Courier server does, at every path it serves:
- JSON:
application/jsonor any+jsontype. A body sent with no type, or as plain text, is read as JSON when it is JSON, asfetchsends a JSON string it is given no type for. - A form,
multipart/form-dataor URL-encoded: its fields reach the handler as a record, a file as theFileitself and a field sent more than once as a list, in order. The handler'sctx.variablesis typed so —MockFormFields, from@noverax/courier-mock/testing— and a mock client given a form in a test, URL-encoded too, hands it over the same way, so one handler serves both. - A file sent as the whole body, as a direct call made with a
Blob, anArrayBufferor a typed array sends it: the handler gets aBlobof its type. A path that gives values of its own (PUT /files/:id) is refused such a body with 400, since raw bytes have no fields to hold them; send the file in a form there. The proxy route takes JSON or a form only, as a Courier proxy does.
A file's name is kept as it was sent, ../ and all: the mock writes no file. A generated create, update or replace keeps each file in its record as its name, type and size. The mock reads up to 1 MB of JSON and 10 MB of form or upload, as a Courier server does; maxBodyBytes and maxUploadBytes set other limits.
// courier.mocks.ts
import { courier, defineApi, defineGroup } from "@noverax/courier/define"
import { defineMocks, defineMockServer } from "@noverax/courier-mock"
const { endpoint } = courier()
type Stored = { name: string; size: number }
const api = defineApi({
baseURL: "https://api.example.com",
groups: {
files: defineGroup({
prefix: "files",
endpoints: { upload: endpoint.post("").variables<FormData>().response<Stored>() },
}),
},
})
export default defineMocks({
api: defineMockServer({
source: api,
maxUploadBytes: 50 * 1024 * 1024,
overrides: (mock) => {
mock("files", "upload").returns(({ variables }) => {
const file = variables.file
return file instanceof File ? { name: file.name, size: file.size } : { name: "", size: 0 }
})
},
}),
})A body over its limit is answered with 413 before it is all read, and the rest is taken off the connection, so a browser still sending it hears the answer; a form that does not parse is answered 400, and so is one whose client stopped sending it. Each is answered { error }, saying why. Studio's mock log shows such a request refused, with its status and the reason, and a file a request sent by its name, type and size; the admin API's history shows it so too.
To see how an app takes uploads that go wrong — a slow link, a file too large, a type the server does not take, a connection lost partway, any error status — give the mock upload conditions and faults: see Uploads.
Slow and failing answers
network makes a mock answer as a slow or unreliable network would, and take uploads as a server with limits does: it adds latency, caps how fast answers are sent and uploads arrive, refuses an upload over a size or of a type it does not take, and fails a share of calls with any HTTP error status, a timeout, a dropped connection or an upload cut off partway, and its stop conditions end a run on their own. Studio's Network tab sets the same conditions on a running mock. See Network conditions.
// courier.mocks.ts
import { defineMocks, defineMockServer } from "@noverax/courier-mock"
import { api } from "./src/api"
export default defineMocks({
api: defineMockServer({ source: api, network: { latency: "3g", errorRate: 0.1 } }),
})Repeated writes
A write sent again with its idempotency key — a client's retry, a double click — gets the mock's first answer back, marked Idempotent-Replayed: true, and creates nothing twice, as an API that deduplicates by the key answers. The key is read from Idempotency-Key and from every header the source's endpoints name in their idempotencyKey. The same key sent with other variables is refused with 422, and one whose first call is still running with 409; a call that failed is not remembered, so its retry runs again. Keys are kept for a day, the last 1 000 of them.
defineMockServer({ source: api, idempotency: false }) // runs every write, keyed or notAdd mocks from Studio
Generate mocks in the Mocks view opens a wizard: pick the sources to mock — a source a mock already serves says which — set each mock's key, its port — assigned by Studio or fixed — its records per collection, whether it starts with Studio, which endpoints it answers — every one, or those ticked, the rest going to the source's own server — and whether its data is generated or recorded, then review. Studio shows the exact change to courier.mocks.ts as a diff, with what the diff does not show — that it creates the file, which running mocks it restarts, that a port is taken or assigned — and writes it on one click. It creates courier.mocks.ts when there is none (courier.mocks.mts beside a courier.config.mts), formatted as the project formats its code — with its Biome, ESLint and Prettier, as the files of an import are — and changes an existing file only by inserting text, in the file's own style: the new entries in the object defineMocks takes, and the imports they need. It imports the config by its default export — or, for a config of named exports alone, as a namespace (import * as courierConfig) — and imports a courier.config.ts the way the project resolves modules: as ./courier.config.js when the tsconfig, or a project it lists under references, resolves them as Node does (node16 or nodenext) in a "type": "module" package, or when the config's or the mocks file's own relative imports end in .js or .mjs, and as ./courier.config otherwise. A key another mock has, in any letter case, a port that is Studio's own or that another mock declares, or a source the config does not declare is marked on its field instead. A port Studio assigned to another mock may be taken: the review warns that the other mock moves to another port. Generate mocks is turned off, and says why, while Studio's first load of courier.config runs, while the config does not load, and while it is missing. During a later reload it stays on: the wizard waits for the reload before it plans the change, and answers STUDIO_BUSY when the reload outlasts that wait.
Studio then loads the edited files and runs them only if they are the edit it meant: the mocks file loads, each new mock is a defineMockServer() definition serving the config's source, no mock declared before is gone, and the config's sources are unchanged. Otherwise it puts the file back, or removes the file it created, keeps running what it ran, and shows why, with the lines to add by hand once the cause is fixed. If the file was saved in the meantime, Studio keeps that save, and lists the lines it added for you to remove; so it does when it cannot put the file back. A save between the review and the click is caught too: Studio shows the change again, planned against the file as it stands.
When Studio will not edit the file itself, it shows the lines to add by hand — or the whole file to create — with the reason. For the first five reasons below, the wizard plans the change again by itself when the project changes, or on Check again.
| Code | Studio says |
|---|---|
STUDIO_STARTING | Studio is still loading courier.config; try again in a moment. |
STUDIO_BUSY | Studio is still loading courier.config; try again in a moment. On the click, once it has waited 5 s longer than one load may take: Studio is still loading courier.config after 25 s, so it did not change anything. Try again once it has loaded. |
CONFIG_LOAD_FAILING | courier.config does not load right now (error). Fix that first; Studio adds mocks only beside a config it can load. |
MOCKS_LOAD_FAILING | courier.mocks.ts does not load right now (error). Fix that first. |
MOCKS_CHANGED_SINCE_LOAD | courier.mocks.ts changed since Studio loaded it. Studio plans the edit once it has reloaded. |
NO_CONFIG | There is no courier.config in the project. Declare your sources there first. |
CONFIG_SHADOWED | courier.config.js sits beside courier.config.ts, so the import of "./courier.config" in courier.mocks.ts would load courier.config.js, not courier.config.ts, which Studio loads. Delete or move courier.config.js — most likely compiled output — then ask again. |
MOCKS_JAVASCRIPT | courier.mocks.js is JavaScript. Studio writes ES imports named as TypeScript resolves them, which a CommonJS file cannot take and Node does not always resolve, so it edits TypeScript mocks files only. |
MOCKS_IS_LINK | courier.mocks.ts is a symbolic link. Studio saves an edit by replacing the file, which would replace the link with a copy, so Studio edits only a regular file. |
MOCKS_NOT_REGULAR | courier.mocks.ts is a directory, so Studio cannot edit it as a mocks file. |
MOCKS_HARD_LINKED | courier.mocks.ts has 2 hard links. Studio saves an edit by replacing the file, which would part it from its other names, so it leaves the file to you. |
MOCKS_NOT_OWNED | courier.mocks.ts belongs to user 501, but Studio runs as user 502. An edited file would belong to user 502, so Studio leaves it to its owner. |
MOCKS_NOT_WRITABLE | Studio may not write courier.mocks.ts (EACCES). Studio does not edit a file that is read-only to it. |
MOCKS_TOO_LARGE | courier.mocks.ts is n bytes, more than the 1048576 bytes (1 MiB) Studio edits. |
MOCKS_GENERATED | Line n of courier.mocks.ts marks the file as generated, so an edit would be lost when it is generated again. Change what generates it instead. (@generated or DO NOT EDIT in its first five lines.) |
NOT_UTF8 | courier.mocks.ts is not valid UTF-8 text. Studio edits only text it can read in full, so it leaves this file as it is. |
PARSE_ERROR | courier.mocks.ts does not parse: problem |
MOCKS_COMMONJS | courier.mocks.ts exports with module.exports (line n); Studio edits only a mocks file that is an ES module. |
MOCKS_NOT_LITERAL | courier.mocks.ts does not export an object literal Studio can add to. Or it names what the file exports instead: the result of another call than defineMocks, more than one default export, a type, a variable not declared with const, or a getter, setter or method among the mocks. |
MOCKS_KEYS_HIDDEN | courier.mocks.ts (line n) spreads another object into the mocks, so Studio cannot see every key. Or a computed key, __proto__, or keys declared outside the object Studio would add to. |
IMPORT_NAME_TAKEN | courier.mocks.ts already declares defineMockServer on line n, so Studio cannot import defineMockServer from @noverax/courier-mock under that name. |
MOCK_PACKAGE_UNLOADABLE | Studio could not load @noverax/courier-mock in this project: reason. Install it with npm install --save-dev @noverax/courier-mock hono @hono/node-server, then ask again. (The command follows the project's lockfile.) |
WRITE_FAILED | Studio could not write courier.mocks.ts (error). |
INTERNAL_PLAN_REJECTED | Studio could not verify its edit of courier.mocks.ts (problem), so it changed nothing. |
NOT_LOCAL_PEER | Studio edits project files only for a browser on this machine. Add the mocks to your mocks file by hand, or open Studio on the machine it runs on. |