counterfact 2.7.0 → 2.8.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.
Files changed (44) hide show
  1. package/README.md +4 -159
  2. package/bin/counterfact.js +10 -2
  3. package/dist/app.js +74 -20
  4. package/dist/migrate/update-route-types.js +2 -3
  5. package/dist/repl/raw-http-client.js +19 -0
  6. package/dist/repl/repl.js +26 -7
  7. package/dist/repl/route-builder.js +68 -0
  8. package/dist/server/constants.js +8 -0
  9. package/dist/server/context-registry.js +54 -1
  10. package/dist/server/create-koa-app.js +27 -4
  11. package/dist/server/determine-module-kind.js +13 -0
  12. package/dist/server/dispatcher.js +46 -0
  13. package/dist/server/file-discovery.js +20 -9
  14. package/dist/server/is-proxy-enabled-for-path.js +12 -0
  15. package/dist/server/json-to-xml.js +10 -0
  16. package/dist/server/koa-middleware.js +18 -1
  17. package/dist/server/load-openapi-document.js +4 -11
  18. package/dist/server/module-dependency-graph.js +25 -0
  19. package/dist/server/module-loader.js +44 -21
  20. package/dist/server/module-tree.js +36 -0
  21. package/dist/server/openapi-document.js +69 -0
  22. package/dist/server/openapi-middleware.js +34 -5
  23. package/dist/server/registry.js +89 -0
  24. package/dist/server/response-builder.js +15 -0
  25. package/dist/server/scenario-registry.js +26 -0
  26. package/dist/server/tools.js +27 -0
  27. package/dist/server/transpiler.js +23 -9
  28. package/dist/typescript-generator/code-generator.js +117 -4
  29. package/dist/typescript-generator/coder.js +76 -0
  30. package/dist/typescript-generator/operation-coder.js +12 -4
  31. package/dist/typescript-generator/operation-type-coder.js +39 -4
  32. package/dist/typescript-generator/parameters-type-coder.js +2 -4
  33. package/dist/typescript-generator/prune.js +2 -1
  34. package/dist/typescript-generator/repository.js +76 -20
  35. package/dist/typescript-generator/requirement.js +69 -0
  36. package/dist/typescript-generator/{generate.js → scenario-file-generator.js} +98 -81
  37. package/dist/typescript-generator/script.js +70 -7
  38. package/dist/typescript-generator/specification.js +27 -0
  39. package/dist/util/ensure-directory-exists.js +7 -0
  40. package/dist/util/forward-slash-path.js +63 -0
  41. package/dist/util/read-file.js +11 -0
  42. package/dist/util/runtime-can-execute-erasable-ts.js +11 -0
  43. package/dist/util/windows-escape.js +18 -0
  44. package/package.json +4 -4
package/README.md CHANGED
@@ -4,174 +4,19 @@
4
4
 
5
5
  <br>
6
6
 
7
- **Your backend isn't ready. Your frontend can't wait.**
8
-
9
- **Counterfact turns your OpenAPI spec into a live, stateful API you can program in TypeScript.**
10
-
11
- <br>
12
-
13
7
  ![MIT License](https://img.shields.io/badge/license-MIT-blue) [![TypeScript](./typescript-badge.png)](https://github.com/ellerbrock/typescript-badges/) [![Coverage Status](https://coveralls.io/repos/github/pmcelhaney/counterfact/badge.svg)](https://coveralls.io/github/pmcelhaney/counterfact)
14
8
 
15
9
  </div>
16
10
 
17
- This is a five-minute walkthrough. By the end, you’ll have a **stateful, type-safe, hot-reloading API simulator** running locally—and you’ll understand why it’s different from traditional mock servers.
18
-
19
- Built for frontend developers, test engineers, and AI agents that need a predictable API to work against.
11
+ You've used mock servers. You know where they stop being useful: static responses, no shared state, no way to inject a failure mid-run, no control without restarting. Counterfact picks up where they leave off.
20
12
 
21
-
22
-
23
- ## Minute 1 — Start the server
13
+ Point it at an OpenAPI spec and it generates TypeScript handlers for every endpoint—type-safe, hot-reloading, sharing state across routes. A built-in REPL gives you a live control surface: seed data, trigger error conditions, proxy individual routes to a real backend, all on a running server. Whether you're a frontend developer waiting on a backend, a test engineer who needs clean reproducible state, or an AI agent that needs a stable API to work against, Counterfact is the simulator that doesn't plateau.
24
14
 
25
15
  ```sh
26
16
  npx counterfact@latest https://petstore3.swagger.io/api/v3/openapi.json api
27
17
  ```
28
18
 
29
- > **Requires Node ≥ 22.0.0**
30
-
31
- That’s it.
32
-
33
- Counterfact reads your spec, generates a TypeScript handler for every endpoint, and starts a server at `http://localhost:3100`.
34
-
35
- Open `http://localhost:3100/counterfact/swagger/`.
36
-
37
- Every endpoint is already live, returning random, schema-valid responses. No code written yet.
38
-
39
-
40
-
41
- ## Minute 2 — Make a route return real data
42
-
43
- Open the generated file for `GET /pet/{petId}`:
44
-
45
- ```ts
46
- import type { HTTP_GET } from "../../types/paths/pet/{petId}.types.js";
47
-
48
- export const GET: HTTP_GET = ($) => $.response[200].random();
49
- ```
50
-
51
- Replace `.random()` with your own logic:
52
-
53
- ```ts
54
- export const GET: HTTP_GET = ($) => {
55
- if ($.path.petId === 99) {
56
- return $.response[404].text("Pet not found");
57
- }
58
- return $.response[200].json({
59
- id: $.path.petId,
60
- name: "Fluffy",
61
- status: "available",
62
- photoUrls: []
63
- });
64
- };
65
- ```
66
-
67
- Save the file. The server reloads instantly—no restart, no lost state.
68
-
69
- TypeScript enforces the contract. If your response doesn’t match the spec, you’ll know before you make the request.
70
-
71
- ## Minute 3 — Add state that survives across requests
72
-
73
- Real APIs have memory. Yours should too.
74
-
75
- Create `api/routes/_.context.ts`:
76
-
77
- ```ts
78
- import type { Pet } from "../types/components/pet.types.js";
79
-
80
- export class Context {
81
- private pets = new Map<number, Pet>();
82
- private nextId = 1;
83
-
84
- add(data: Omit<Pet, "id">): Pet {
85
- const pet = { ...data, id: this.nextId++ };
86
- this.pets.set(pet.id, pet);
87
- return pet;
88
- }
89
-
90
- get(id: number): Pet | undefined { return this.pets.get(id); }
91
- list(): Pet[] { return [...this.pets.values()]; }
92
- remove(id: number): void { this.pets.delete(id); }
93
- }
94
- ```
95
-
96
- Use it in your routes:
97
-
98
- ```ts
99
- export const GET: HTTP_GET = ($) => $.response[200].json($.context.list());
100
- export const POST: HTTP_POST = ($) => $.response[200].json($.context.add($.body));
101
- ```
102
-
103
- Now your API behaves like a real system:
104
- - POST creates data
105
- - GET returns it
106
- - DELETE removes it
107
-
108
- State survives hot reloads. Restarting resets everything—perfect for clean test runs.
109
-
110
-
111
-
112
- ## Minute 4 — Control the system at runtime (REPL)
113
-
114
- This is where Counterfact becomes more than a mock.
115
-
116
- The built-in REPL lets you inspect and control the system while it’s running.
117
-
118
- Seed data:
119
-
120
- ```
121
- ⬣> context.add({ name: "Fluffy", status: "available", photoUrls: [] })
122
- ⬣> context.add({ name: "Rex", status: "pending", photoUrls: [] })
123
- ```
124
-
125
- Make requests:
126
-
127
- ```
128
- ⬣> client.get("/pet/1")
129
- ```
130
-
131
- Simulate failures instantly:
132
-
133
- ```
134
- ⬣> context.rateLimitExceeded = true
135
- ⬣> client.get("/pet/1")
136
- { status: 429, body: "Too Many Requests" }
137
- ```
138
-
139
- No HTTP scripts. No restarts. Just direct control.
140
-
141
-
142
-
143
- ## Minute 5 — Proxy to the real backend
144
-
145
- When parts of your backend are ready, forward them through.
146
-
147
- Everything else stays simulated.
148
-
149
- ```sh
150
- npx counterfact@latest openapi.yaml api --proxy-url https://api.example.com
151
- ```
152
-
153
- Toggle paths live:
154
-
155
- ```
156
- ⬣> .proxy on /payments
157
- ⬣> .proxy on /auth
158
- ⬣> .proxy off
159
- ```
160
-
161
-
162
-
163
- ## What you just built
164
-
165
- In five minutes, you turned a static spec into a working system:
166
-
167
- - **Schema-valid responses** from the moment it starts
168
- - **Type-safe handlers** generated from your spec
169
- - **Shared state** across all routes
170
- - **Hot reloading** without losing that state
171
- - A **live control surface (REPL)** for runtime behavior
172
- - **Selective proxying** to real services
173
-
174
-
19
+ > Requires Node ≥ 22.0.0
175
20
 
176
21
  ## Go deeper
177
22
 
@@ -189,4 +34,4 @@ In five minutes, you turned a static spec into a working system:
189
34
 
190
35
  [Changelog](./CHANGELOG.md) · [Contributing](./CONTRIBUTING.md)
191
36
 
192
- </div>
37
+ </div>
@@ -162,6 +162,14 @@ const { loadConfigFile } = await import(
162
162
  )
163
163
  );
164
164
 
165
+ const { pathResolve } = await import(
166
+ resolve(
167
+ nativeTs
168
+ ? "../src/util/forward-slash-path.js"
169
+ : "../dist/util/forward-slash-path.js",
170
+ )
171
+ );
172
+
165
173
  const DEFAULT_PORT = 3100;
166
174
 
167
175
  const debug = createDebug("counterfact:bin:counterfact");
@@ -314,9 +322,9 @@ async function main(source, destination) {
314
322
  source = options.spec;
315
323
  }
316
324
 
317
- const destinationPath = nodePath.resolve(destination).replaceAll("\\", "/");
325
+ const destinationPath = pathResolve(destination);
318
326
 
319
- const basePath = nodePath.resolve(destinationPath).replaceAll("\\", "/");
327
+ const basePath = pathResolve(destinationPath);
320
328
 
321
329
  // If no action-related option is provided, default to all options
322
330
 
package/dist/app.js CHANGED
@@ -1,21 +1,33 @@
1
1
  import fs, { rm } from "node:fs/promises";
2
- import nodePath from "node:path";
3
2
  import { createHttpTerminator } from "http-terminator";
4
3
  import { startRepl as startReplServer } from "./repl/repl.js";
4
+ import { createRouteFunction } from "./repl/route-builder.js";
5
5
  import { ContextRegistry } from "./server/context-registry.js";
6
6
  import { createKoaApp } from "./server/create-koa-app.js";
7
7
  import { Dispatcher } from "./server/dispatcher.js";
8
- import { koaMiddleware } from "./server/koa-middleware.js";
9
8
  import { loadOpenApiDocument } from "./server/load-openapi-document.js";
10
9
  import { ModuleLoader } from "./server/module-loader.js";
11
- import { OpenApiWatcher } from "./server/openapi-watcher.js";
12
10
  import { Registry } from "./server/registry.js";
13
11
  import { ScenarioRegistry } from "./server/scenario-registry.js";
14
12
  import { Transpiler } from "./server/transpiler.js";
15
13
  import { CodeGenerator } from "./typescript-generator/code-generator.js";
16
- import { writeApplyContextType } from "./typescript-generator/generate.js";
14
+ import { ScenarioFileGenerator } from "./typescript-generator/scenario-file-generator.js";
17
15
  import { runtimeCanExecuteErasableTs } from "./util/runtime-can-execute-erasable-ts.js";
16
+ import { pathJoin } from "./util/forward-slash-path.js";
18
17
  export { loadOpenApiDocument } from "./server/load-openapi-document.js";
18
+ export async function runStartupScenario(scenarioRegistry, contextRegistry, config, openApiDocument) {
19
+ const indexModule = scenarioRegistry.getModule("index");
20
+ if (!indexModule || typeof indexModule["startup"] !== "function") {
21
+ return;
22
+ }
23
+ const scenario$ = {
24
+ context: contextRegistry.find("/"),
25
+ loadContext: (path) => contextRegistry.find(path),
26
+ route: createRouteFunction(config.port, "localhost", openApiDocument),
27
+ routes: {},
28
+ };
29
+ await indexModule["startup"](scenario$);
30
+ }
19
31
  const allowedMethods = [
20
32
  "all",
21
33
  "head",
@@ -27,6 +39,16 @@ const allowedMethods = [
27
39
  "options",
28
40
  ];
29
41
  const mswHandlers = {};
42
+ /**
43
+ * Dispatches a single MSW (Mock Service Worker) intercepted request to the
44
+ * matching Counterfact route handler registered via {@link createMswHandlers}.
45
+ *
46
+ * @param request - The intercepted request, including the HTTP method, path,
47
+ * headers, query, body, and a `rawPath` that preserves the original URL
48
+ * before base-path stripping.
49
+ * @returns The response produced by the matching handler, or a 404 object when
50
+ * no handler has been registered for the given method and path.
51
+ */
30
52
  export async function handleMswRequest(request) {
31
53
  const { method, rawPath } = request;
32
54
  const handler = mswHandlers[`${method}:${rawPath}`];
@@ -36,15 +58,25 @@ export async function handleMswRequest(request) {
36
58
  console.warn(`No handler found for ${method} ${rawPath}`);
37
59
  return { error: `No handler found for ${method} ${rawPath}`, status: 404 };
38
60
  }
61
+ /**
62
+ * Loads an OpenAPI document, registers all routes from it as MSW handlers, and
63
+ * returns the list of registered routes so callers (e.g. Vitest Browser mode)
64
+ * can mount them on their own request-interception layer.
65
+ *
66
+ * @param config - Counterfact configuration; `openApiPath` and `basePath` are
67
+ * the most important fields for this function.
68
+ * @param ModuleLoaderClass - Injectable module-loader constructor, primarily
69
+ * used in tests to substitute a test-friendly implementation.
70
+ * @returns An array of `{ method, path }` objects describing every registered
71
+ * MSW handler.
72
+ */
39
73
  export async function createMswHandlers(config, ModuleLoaderClass = ModuleLoader) {
40
74
  // TODO: For some reason the Vitest Custom Commands needed by Vitest Browser mode fail on fs.readFile when they are called from the nested loadOpenApiDocument function.
41
75
  // If we "pre-read" the file here it works. This is a workaround to avoid the issue.
42
76
  await fs.readFile(config.openApiPath);
43
77
  const openApiDocument = await loadOpenApiDocument(config.openApiPath);
44
78
  const modulesPath = config.basePath;
45
- const compiledPathsDirectory = nodePath
46
- .join(modulesPath, ".cache")
47
- .replaceAll("\\", "/");
79
+ const compiledPathsDirectory = pathJoin(modulesPath, ".cache");
48
80
  const registry = new Registry();
49
81
  const contextRegistry = new ContextRegistry();
50
82
  const dispatcher = new Dispatcher(registry, contextRegistry, openApiDocument, config);
@@ -67,47 +99,69 @@ export async function createMswHandlers(config, ModuleLoaderClass = ModuleLoader
67
99
  });
68
100
  return handlers;
69
101
  }
102
+ /**
103
+ * Creates and configures a full Counterfact server instance.
104
+ *
105
+ * Sets up the route registry, context registry, scenario registry, code
106
+ * generator, transpiler, module loader, Koa application, and OpenAPI watcher.
107
+ * The returned object exposes handles for starting the server, stopping it, and
108
+ * launching the interactive REPL.
109
+ *
110
+ * @param config - Runtime configuration (port, paths, feature flags, etc.).
111
+ * @returns An object containing the configured sub-systems and two entry-point
112
+ * functions:
113
+ * - `start(options)` — generates/watches code and optionally starts the HTTP
114
+ * server; returns a `stop()` handle.
115
+ * - `startRepl()` — launches the interactive Node.js REPL connected to the
116
+ * live server state.
117
+ */
70
118
  export async function counterfact(config) {
71
119
  const modulesPath = config.basePath;
72
120
  const nativeTs = await runtimeCanExecuteErasableTs();
73
- const compiledPathsDirectory = nodePath
74
- .join(modulesPath, nativeTs ? "routes" : ".cache")
75
- .replaceAll("\\", "/");
121
+ const compiledPathsDirectory = pathJoin(modulesPath, nativeTs ? "routes" : ".cache");
76
122
  if (!nativeTs) {
77
123
  await rm(compiledPathsDirectory, { force: true, recursive: true });
78
124
  }
79
125
  const registry = new Registry();
80
126
  const contextRegistry = new ContextRegistry();
81
127
  const scenarioRegistry = new ScenarioRegistry();
128
+ const scenarioFileGenerator = new ScenarioFileGenerator(modulesPath);
82
129
  const codeGenerator = new CodeGenerator(config.openApiPath, config.basePath, config.generate);
83
130
  const openApiDocument = config.openApiPath === "_"
84
131
  ? undefined
85
132
  : await loadOpenApiDocument(config.openApiPath);
86
133
  const dispatcher = new Dispatcher(registry, contextRegistry, openApiDocument, config);
87
- const transpiler = new Transpiler(nodePath.join(modulesPath, "routes").replaceAll("\\", "/"), compiledPathsDirectory, "commonjs");
88
- const moduleLoader = new ModuleLoader(compiledPathsDirectory, registry, contextRegistry, nodePath.join(modulesPath, "scenarios").replaceAll("\\", "/"), scenarioRegistry);
89
- contextRegistry.addEventListener("context-changed", () => {
90
- void writeApplyContextType(modulesPath);
134
+ const transpiler = new Transpiler(pathJoin(modulesPath, "routes"), compiledPathsDirectory, "commonjs");
135
+ const moduleLoader = new ModuleLoader(compiledPathsDirectory, registry, contextRegistry, pathJoin(modulesPath, "scenarios"), scenarioRegistry);
136
+ const koaApp = createKoaApp({
137
+ config,
138
+ contextRegistry,
139
+ dispatcher,
140
+ registry,
91
141
  });
92
- const middleware = koaMiddleware(dispatcher, config);
93
- const koaApp = createKoaApp(registry, middleware, config, contextRegistry);
94
- const openApiWatcher = new OpenApiWatcher(config.openApiPath, dispatcher);
95
142
  async function start(options) {
96
143
  const { generate, startServer, watch, buildCache } = options;
97
144
  if (config.openApiPath !== "_" && (generate.routes || generate.types)) {
98
145
  await codeGenerator.generate();
99
146
  }
147
+ if (generate.types) {
148
+ await scenarioFileGenerator.generate();
149
+ }
100
150
  if (config.openApiPath !== "_" && (watch.routes || watch.types)) {
101
151
  await codeGenerator.watch();
102
152
  }
153
+ if (watch.types) {
154
+ await scenarioFileGenerator.watch();
155
+ }
103
156
  let httpTerminator;
104
157
  if (startServer) {
105
- await openApiWatcher.watch();
158
+ await openApiDocument?.watch();
106
159
  if (!nativeTs) {
107
160
  await transpiler.watch();
108
161
  }
109
162
  await moduleLoader.load();
110
163
  await moduleLoader.watch();
164
+ await runStartupScenario(scenarioRegistry, contextRegistry, config, openApiDocument);
111
165
  const server = koaApp.listen({
112
166
  port: config.port,
113
167
  });
@@ -123,9 +177,10 @@ export async function counterfact(config) {
123
177
  return {
124
178
  async stop() {
125
179
  await codeGenerator.stopWatching();
180
+ await scenarioFileGenerator.stopWatching();
126
181
  await transpiler.stopWatching();
127
182
  await moduleLoader.stopWatching();
128
- await openApiWatcher.stopWatching();
183
+ await openApiDocument?.stopWatching();
129
184
  await httpTerminator?.terminate();
130
185
  },
131
186
  };
@@ -133,7 +188,6 @@ export async function counterfact(config) {
133
188
  return {
134
189
  contextRegistry,
135
190
  koaApp,
136
- koaMiddleware: middleware,
137
191
  registry,
138
192
  start,
139
193
  startRepl: () => startReplServer(contextRegistry, registry, config, undefined, // use the default print function (stdout)
@@ -1,6 +1,7 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import createDebug from "debug";
4
+ import { toForwardSlashPath } from "../util/forward-slash-path.js";
4
5
  import { OperationTypeCoder, } from "../typescript-generator/operation-type-coder.js";
5
6
  import { Specification } from "../typescript-generator/specification.js";
6
7
  const debug = createDebug("counterfact:migrate:update-route-types");
@@ -180,9 +181,7 @@ async function processRouteDirectory(routesDir, currentPath, mapping) {
180
181
  }
181
182
  else if (entry.name.endsWith(".ts") && entry.name !== "_.context.ts") {
182
183
  // Process TypeScript route files (skip context files)
183
- const routePath = relativePath
184
- .replace(/\.ts$/, "")
185
- .replaceAll("\\", "/");
184
+ const routePath = toForwardSlashPath(relativePath.replace(/\.ts$/, ""));
186
185
  const methodMap = mapping.get(routePath);
187
186
  if (methodMap) {
188
187
  const wasUpdated = await updateRouteFile(absolutePath, methodMap);
@@ -51,6 +51,16 @@ function stringifyBody(body) {
51
51
  }
52
52
  return JSON.stringify(body);
53
53
  }
54
+ /**
55
+ * A minimal HTTP/1.1 client that communicates over a raw TCP socket.
56
+ *
57
+ * Used in the Counterfact REPL (`client.*`) to send requests to the local mock
58
+ * server and pretty-print the request and response to `stdout` with ANSI
59
+ * colours.
60
+ *
61
+ * Unlike `fetch` or Axios, `RawHttpClient` does not buffer or parse the
62
+ * response — the raw HTTP response string is returned from every method.
63
+ */
54
64
  export class RawHttpClient {
55
65
  host;
56
66
  port;
@@ -59,30 +69,39 @@ export class RawHttpClient {
59
69
  this.host = host;
60
70
  this.port = port;
61
71
  }
72
+ /** Sends a `GET` request and returns the raw HTTP response string. */
62
73
  get(path, headers = {}) {
63
74
  return this.#send("GET", path, "", headers);
64
75
  }
76
+ /** Sends a `HEAD` request and returns the raw HTTP response string. */
65
77
  head(path, headers = {}) {
66
78
  return this.#send("HEAD", path, "", headers);
67
79
  }
80
+ /** Sends a `POST` request with `body` and returns the raw HTTP response string. */
68
81
  post(path, body = "", headers = {}) {
69
82
  return this.#send("POST", path, body, headers);
70
83
  }
84
+ /** Sends a `PUT` request with `body` and returns the raw HTTP response string. */
71
85
  put(path, body = "", headers = {}) {
72
86
  return this.#send("PUT", path, body, headers);
73
87
  }
88
+ /** Sends a `DELETE` request and returns the raw HTTP response string. */
74
89
  delete(path, headers = {}) {
75
90
  return this.#send("DELETE", path, "", headers);
76
91
  }
92
+ /** Sends a `CONNECT` request and returns the raw HTTP response string. */
77
93
  connect(path, headers = {}) {
78
94
  return this.#send("CONNECT", path, "", headers);
79
95
  }
96
+ /** Sends an `OPTIONS` request and returns the raw HTTP response string. */
80
97
  options(path, headers = {}) {
81
98
  return this.#send("OPTIONS", path, "", headers);
82
99
  }
100
+ /** Sends a `TRACE` request and returns the raw HTTP response string. */
83
101
  trace(path, headers = {}) {
84
102
  return this.#send("TRACE", path, "", headers);
85
103
  }
104
+ /** Sends a `PATCH` request with `body` and returns the raw HTTP response string. */
86
105
  patch(path, body = "", headers = {}) {
87
106
  return this.#send("PATCH", path, body, headers);
88
107
  }
package/dist/repl/repl.js CHANGED
@@ -20,13 +20,13 @@ const ROUTE_BUILDER_METHODS = [
20
20
  *
21
21
  * @param registry - The route registry used to complete path arguments for `route()` and `client.*()` calls.
22
22
  * @param fallback - Optional fallback completer (e.g. the Node.js built-in completer) invoked when no custom completion matches.
23
- * @param scenarioRegistry - When provided, enables tab completion for `.apply` commands by enumerating
23
+ * @param scenarioRegistry - When provided, enables tab completion for `.scenario` commands by enumerating
24
24
  * exported function names and file-key prefixes from the loaded scenario modules.
25
25
  */
26
26
  export function createCompleter(registry, fallback, scenarioRegistry) {
27
27
  return (line, callback) => {
28
- // Check for .apply completion: .apply <partial>
29
- const applyMatch = line.match(/^\.apply\s+(?<partial>\S*)$/u);
28
+ // Check for .scenario completion: .scenario <partial>
29
+ const applyMatch = line.match(/^\.scenario\s+(?<partial>\S*)$/u);
30
30
  if (applyMatch) {
31
31
  const partial = applyMatch.groups?.["partial"] ?? "";
32
32
  if (scenarioRegistry !== undefined) {
@@ -84,6 +84,25 @@ export function createCompleter(registry, fallback, scenarioRegistry) {
84
84
  callback(null, [matches, partial]);
85
85
  };
86
86
  }
87
+ /**
88
+ * Launches the interactive Counterfact REPL.
89
+ *
90
+ * The REPL is a standard Node.js REPL augmented with:
91
+ * - `context` / `loadContext(path)` globals wired to the {@link ContextRegistry}.
92
+ * - `client` — a {@link RawHttpClient} pre-configured for `localhost`.
93
+ * - `route(path)` — creates a {@link RouteBuilder} for the given path.
94
+ * - `.counterfact` — help command.
95
+ * - `.proxy` — proxy configuration command.
96
+ * - `.scenario` — runs a named scenario function from the scenarios directory.
97
+ *
98
+ * @param contextRegistry - The live context registry.
99
+ * @param registry - The route registry (used for tab completion).
100
+ * @param config - Server configuration.
101
+ * @param print - Output function; defaults to writing to `stdout`.
102
+ * @param openApiDocument - Optional OpenAPI document for tab completion.
103
+ * @param scenarioRegistry - Optional scenario registry for `.scenario` support.
104
+ * @returns The configured Node.js REPL server instance.
105
+ */
87
106
  export function startRepl(contextRegistry, registry, config, print = printToStdout, openApiDocument, scenarioRegistry) {
88
107
  function printProxyStatus() {
89
108
  if (config.proxyUrl === "") {
@@ -123,7 +142,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
123
142
  }
124
143
  }
125
144
  const replServer = repl.start({
126
- prompt: "⬣> ",
145
+ prompt: "\x1b[38;2;0;113;181m⬣> \x1b[0m",
127
146
  });
128
147
  const builtinCompleter = replServer.completer;
129
148
  // completer is typed as readonly in @types/node but is writable at runtime
@@ -173,11 +192,11 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
173
192
  replServer.context.RawHttpClient = RawHttpClient;
174
193
  replServer.context.route = createRouteFunction(config.port, "localhost", openApiDocument);
175
194
  replServer.context.routes = {};
176
- replServer.defineCommand("apply", {
195
+ replServer.defineCommand("scenario", {
177
196
  async action(text) {
178
197
  const parts = text.trim().split("/").filter(Boolean);
179
198
  if (parts.length === 0) {
180
- print("usage: .apply <path>");
199
+ print("usage: .scenario <path>");
181
200
  this.clearBufferedCommand();
182
201
  this.displayPrompt();
183
202
  return;
@@ -220,7 +239,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
220
239
  this.clearBufferedCommand();
221
240
  this.displayPrompt();
222
241
  },
223
- help: 'apply a scenario script (".apply <path>" calls the named export from scenarios/)',
242
+ help: 'apply a scenario script (".scenario <path>" calls the named export from scenarios/)',
224
243
  });
225
244
  return replServer;
226
245
  }
@@ -1,4 +1,17 @@
1
1
  import { RawHttpClient } from "./raw-http-client.js";
2
+ /**
3
+ * Immutable fluent builder for constructing and sending HTTP requests from the
4
+ * Counterfact REPL.
5
+ *
6
+ * Each builder method returns a **new** `RouteBuilder` instance with the
7
+ * updated field — the original is never mutated. When all required parameters
8
+ * are set, call {@link send} to execute the request.
9
+ *
10
+ * ```ts
11
+ * // Inside the REPL:
12
+ * route("/pets/{petId}").method("get").path({ petId: 1 }).send();
13
+ * ```
14
+ */
2
15
  export class RouteBuilder {
3
16
  routePath;
4
17
  _body;
@@ -47,29 +60,63 @@ export class RouteBuilder {
47
60
  queryParams: overrides.queryParams ?? this._queryParams,
48
61
  });
49
62
  }
63
+ /**
64
+ * Returns a new builder with the HTTP method set.
65
+ *
66
+ * @param method - HTTP method name (case-insensitive, e.g. `"get"`, `"POST"`).
67
+ */
50
68
  method(method) {
51
69
  return this.clone({ method: method.toUpperCase() });
52
70
  }
71
+ /**
72
+ * Returns a new builder with additional path parameters merged in.
73
+ *
74
+ * @param params - Key/value map of path variable names to values.
75
+ */
53
76
  path(params) {
54
77
  return this.clone({ pathParams: { ...this._pathParams, ...params } });
55
78
  }
79
+ /**
80
+ * Returns a new builder with additional query parameters merged in.
81
+ *
82
+ * @param params - Key/value map of query parameter names to values.
83
+ */
56
84
  query(params) {
57
85
  return this.clone({ queryParams: { ...this._queryParams, ...params } });
58
86
  }
87
+ /**
88
+ * Returns a new builder with additional request headers merged in.
89
+ *
90
+ * @param params - Key/value map of header names to values.
91
+ */
59
92
  headers(params) {
60
93
  return this.clone({ headerParams: { ...this._headerParams, ...params } });
61
94
  }
95
+ /**
96
+ * Returns a new builder with the request body set.
97
+ *
98
+ * @param body - The request body (will be serialised to JSON or sent as-is).
99
+ */
62
100
  body(body) {
63
101
  return this.clone({ body });
64
102
  }
65
103
  getOperation() {
66
104
  return this._operation;
67
105
  }
106
+ /**
107
+ * Returns `true` when a method is set and no required parameters are
108
+ * missing.
109
+ */
68
110
  ready() {
69
111
  if (!this._method)
70
112
  return false;
71
113
  return this.missing() === undefined;
72
114
  }
115
+ /**
116
+ * Returns a {@link MissingParams} object describing all required parameters
117
+ * that have not yet been set, or `undefined` when nothing is missing (or
118
+ * when the operation has no parameters).
119
+ */
73
120
  missing() {
74
121
  const operation = this.getOperation();
75
122
  if (!operation?.parameters)
@@ -98,6 +145,10 @@ export class RouteBuilder {
98
145
  return undefined;
99
146
  return missingParams;
100
147
  }
148
+ /**
149
+ * Returns a human-readable help string describing the operation, its
150
+ * parameters, and the expected responses.
151
+ */
101
152
  help() {
102
153
  const method = this._method ?? "[no method set]";
103
154
  const operation = this.getOperation();
@@ -168,6 +219,13 @@ export class RouteBuilder {
168
219
  }
169
220
  return lines.join("\n");
170
221
  }
222
+ /**
223
+ * Executes the HTTP request and returns the parsed response body.
224
+ *
225
+ * @throws When no HTTP method has been set.
226
+ * @throws When required parameters are missing.
227
+ * @throws When an unsupported HTTP method is used.
228
+ */
171
229
  async send() {
172
230
  if (!this._method) {
173
231
  throw new Error('No HTTP method set. Use .method("get") to set the method.');
@@ -265,6 +323,16 @@ export class RouteBuilder {
265
323
  return lines.join("\n");
266
324
  }
267
325
  }
326
+ /**
327
+ * Creates a factory function that constructs a {@link RouteBuilder} for a
328
+ * given route path, pre-configured with the server's host, port, and OpenAPI
329
+ * document.
330
+ *
331
+ * @param port - The port the Counterfact server is listening on.
332
+ * @param host - The server hostname (default `"localhost"`).
333
+ * @param openApiDocument - Optional OpenAPI document for parameter introspection.
334
+ * @returns A function `(routePath: string) => RouteBuilder`.
335
+ */
268
336
  export function createRouteFunction(port, host, openApiDocument) {
269
337
  return (routePath) => new RouteBuilder(routePath, { host, openApiDocument, port });
270
338
  }
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Default options passed to every chokidar watcher in Counterfact.
3
+ *
4
+ * - `ignoreInitial: true` — suppresses the initial `"add"` events emitted for
5
+ * files already present when the watcher starts.
6
+ * - `usePolling: true` on Windows — chokidar's native FSEvents are unreliable
7
+ * on Windows; polling is more reliable there.
8
+ */
1
9
  export const CHOKIDAR_OPTIONS = {
2
10
  ignoreInitial: true,
3
11
  usePolling: process.platform === "win32",