counterfact 2.10.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +2 -1
  2. package/dist/api-runner.js +19 -7
  3. package/dist/app.js +119 -15
  4. package/dist/cli/banner.js +1 -1
  5. package/dist/cli/run.js +42 -9
  6. package/dist/cli/telemetry.js +11 -10
  7. package/dist/migrate/update-route-types.js +1 -0
  8. package/dist/msw.js +1 -0
  9. package/dist/repl/repl.js +5 -4
  10. package/dist/server/counterfact-types/example.ts +5 -1
  11. package/dist/server/counterfact-types/generic-response-builder.ts +4 -0
  12. package/dist/server/counterfact-types/open-api-parameters.ts +8 -1
  13. package/dist/server/counterfact-types/response-builder.ts +5 -0
  14. package/dist/server/counterfact-types/wide-response-builder.ts +1 -0
  15. package/dist/server/dispatcher.js +87 -12
  16. package/dist/server/json-to-xml.js +32 -7
  17. package/dist/server/module-loader.js +5 -0
  18. package/dist/server/openapi-document.js +5 -0
  19. package/dist/server/registry.js +22 -5
  20. package/dist/server/response-builder.js +27 -5
  21. package/dist/server/web-server/admin-api-middleware.js +1 -1
  22. package/dist/server/web-server/create-koa-app.js +3 -1
  23. package/dist/server/web-server/openapi-middleware.js +1 -0
  24. package/dist/server/web-server/routes-middleware.js +43 -1
  25. package/dist/typescript-generator/code-generator.js +17 -6
  26. package/dist/typescript-generator/coder.js +1 -1
  27. package/dist/typescript-generator/jsdoc.js +11 -7
  28. package/dist/typescript-generator/operation-coder.js +23 -1
  29. package/dist/typescript-generator/operation-type-coder.js +184 -11
  30. package/dist/typescript-generator/requirement.js +36 -3
  31. package/dist/typescript-generator/response-type-coder.js +20 -7
  32. package/dist/typescript-generator/responses-type-coder.js +8 -2
  33. package/dist/typescript-generator/schema-coder.js +2 -2
  34. package/dist/typescript-generator/schema-type-coder.js +16 -3
  35. package/dist/typescript-generator/script.js +46 -5
  36. package/dist/typescript-generator/specification.js +3 -1
  37. package/dist/typescript-generator/streaming-content-types.js +16 -0
  38. package/dist/typescript-generator/versions-ts-generator.js +82 -0
  39. package/package.json +24 -26
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <br>
6
6
 
7
- ![MIT License](https://img.shields.io/badge/license-MIT-blue) [![Coverage Status](https://coveralls.io/repos/github/counterfact/api-simulator/badge.svg)](https://coveralls.io/github/pmcelhaney/counterfact) ![friction 0%](https://img.shields.io/badge/friction-0%25-brightgreen)
7
+ ![MIT License](https://img.shields.io/badge/license-MIT-blue) [![Coverage Status](https://coveralls.io/repos/github/counterfact/api-simulator/badge.svg)](https://coveralls.io/github/pmcelhaney/counterfact) ![friction 0%](https://img.shields.io/badge/friction-0%25-brightgreen) ![Swagger 2.0](https://img.shields.io/badge/Swagger-2.0-85EA2D) ![OpenAPI 3.0-3.2](https://img.shields.io/badge/OpenAPI-3.x-6BA539)
8
8
 
9
9
  </div>
10
10
 
@@ -17,6 +17,7 @@ Mock servers make it easy to get started, but hard to keep going.<br>
17
17
  Counterfact is an API simulator without those limits.
18
18
 
19
19
  Point it at an [OpenAPI](https://www.openapis.org) document and get a live, stateful API in seconds.
20
+ Supports Swagger 2.0 and OpenAPI 3.0, 3.1, and 3.2.
20
21
  - Type-safe TypeScript handlers for every endpoint
21
22
  - Hot reloading as you edit
22
23
  - Shared state across routes
@@ -64,6 +64,11 @@ export class ApiRunner {
64
64
  * Defaults to `""` (no subdirectory).
65
65
  */
66
66
  group;
67
+ /**
68
+ * Optional version label for this runner's spec (e.g. `"v1"`, `"v2"`).
69
+ * Defaults to `""` (unversioned).
70
+ */
71
+ version;
67
72
  /**
68
73
  * The subdirectory path segment derived from {@link group}.
69
74
  * Returns `""` when `group` is empty, otherwise `"/${group}"`.
@@ -72,8 +77,9 @@ export class ApiRunner {
72
77
  return this.group ? `/${this.group}` : "";
73
78
  }
74
79
  config;
75
- constructor(config, nativeTs, openApiDocument, group) {
80
+ constructor(config, nativeTs, openApiDocument, group, version = "", versions = []) {
76
81
  this.group = group;
82
+ this.version = version;
77
83
  const modulesPath = this.group
78
84
  ? pathJoin(config.basePath, this.group)
79
85
  : config.basePath;
@@ -87,8 +93,8 @@ export class ApiRunner {
87
93
  this.contextRegistry = new ContextRegistry();
88
94
  this.scenarioRegistry = new ScenarioRegistry();
89
95
  this.scenarioFileGenerator = new ScenarioFileGenerator(modulesPath);
90
- this.codeGenerator = new CodeGenerator(this.openApiPath, config.basePath + this.subdirectory, config.generate);
91
- this.dispatcher = new Dispatcher(this.registry, this.contextRegistry, openApiDocument, config);
96
+ this.codeGenerator = new CodeGenerator(this.openApiPath, config.basePath + this.subdirectory, config.generate, version);
97
+ this.dispatcher = new Dispatcher(this.registry, this.contextRegistry, openApiDocument, config, version, versions);
92
98
  this.transpiler = new Transpiler(pathJoin(modulesPath, "routes"), compiledPathsDirectory, "commonjs");
93
99
  this.moduleLoader = new ModuleLoader(compiledPathsDirectory, this.registry, this.contextRegistry, pathJoin(modulesPath, "scenarios"), this.scenarioRegistry);
94
100
  }
@@ -101,8 +107,10 @@ export class ApiRunner {
101
107
  *
102
108
  * @param config - Runtime configuration for this runner instance.
103
109
  * @param group - Optional group name placing generated code in a subdirectory (default `""`).
110
+ * @param version - Optional version label for this spec (e.g. `"v1"`, `"v2"`).
111
+ * @param versions - Optional ordered list of all version labels in this group (oldest first).
104
112
  */
105
- static async create(config, group = "") {
113
+ static async create(config, group = "", version = "", versions = []) {
106
114
  const nativeTs = await runtimeCanExecuteErasableTs();
107
115
  const modulesPath = group
108
116
  ? pathJoin(config.basePath, group)
@@ -114,7 +122,7 @@ export class ApiRunner {
114
122
  const openApiDocument = config.openApiPath === "_"
115
123
  ? undefined
116
124
  : await loadOpenApiDocument(config.openApiPath);
117
- return new ApiRunner(config, nativeTs, openApiDocument, group);
125
+ return new ApiRunner(config, nativeTs, openApiDocument, group, version, versions);
118
126
  }
119
127
  /**
120
128
  * Generates TypeScript route stubs and type files from the OpenAPI spec.
@@ -123,11 +131,15 @@ export class ApiRunner {
123
131
  * - Routes and types are only generated when `config.openApiPath` is not `"_"`.
124
132
  * - The scenario context type file is always generated when
125
133
  * `config.generate.types` is `true`, even without a spec.
134
+ *
135
+ * @param repository - Optional shared repository. Pass a shared instance
136
+ * when multiple versioned specs in the same group should merge their types
137
+ * into the same output tree.
126
138
  */
127
- async generate() {
139
+ async generate(repository) {
128
140
  if (this.config.openApiPath !== "_" &&
129
141
  (this.config.generate.routes || this.config.generate.types)) {
130
- await this.codeGenerator.generate();
142
+ await this.codeGenerator.generate(repository);
131
143
  }
132
144
  if (this.config.generate.types) {
133
145
  await this.scenarioFileGenerator.generate();
package/dist/app.js CHANGED
@@ -1,8 +1,13 @@
1
+ import fs from "node:fs/promises";
2
+ import nodePath from "node:path";
1
3
  import { createHttpTerminator } from "http-terminator";
2
4
  import { ApiRunner } from "./api-runner.js";
3
5
  import { startRepl as startReplServer } from "./repl/repl.js";
4
6
  import { createRouteFunction } from "./repl/route-builder.js";
5
7
  import { createKoaApp } from "./server/web-server/create-koa-app.js";
8
+ import { Repository } from "./typescript-generator/repository.js";
9
+ import { ensureDirectoryExists } from "./util/ensure-directory-exists.js";
10
+ import { generateVersionsTsContent } from "./typescript-generator/versions-ts-generator.js";
6
11
  export { loadOpenApiDocument } from "./server/load-openapi-document.js";
7
12
  export { createMswHandlers, handleMswRequest, } from "./msw.js";
8
13
  export async function runStartupScenario(scenarioRegistry, contextRegistry, config, openApiDocument) {
@@ -18,19 +23,47 @@ export async function runStartupScenario(scenarioRegistry, contextRegistry, conf
18
23
  };
19
24
  await indexModule["startup"](scenario$);
20
25
  }
26
+ /**
27
+ * Derives the URL prefix for a spec entry.
28
+ *
29
+ * Applies the following precedence rules:
30
+ * 1. Explicit `prefix` (even `""`) → returned as-is.
31
+ * 2. `group` + `version` both present → `/<group>/<version>`.
32
+ * 3. `group` present (no `version`) → `/<group>`.
33
+ * 4. Neither → `""` (root).
34
+ */
35
+ function derivePrefix(spec) {
36
+ if (spec.prefix !== undefined) {
37
+ return spec.prefix;
38
+ }
39
+ if (spec.group && spec.version) {
40
+ return `/${spec.group}/${spec.version}`;
41
+ }
42
+ if (spec.group) {
43
+ return `/${spec.group}`;
44
+ }
45
+ return "";
46
+ }
21
47
  /**
22
48
  * Normalises the spec configuration to an array.
23
49
  *
24
- * When `specs` is provided it is returned as-is. When it is omitted, a
25
- * single-entry array is constructed from `config.openApiPath`,
26
- * `config.prefix`, and `group = ""` so that the rest of the code never
27
- * needs to branch on single-vs-multiple specs.
50
+ * When `specs` is provided, each entry's `prefix` is resolved via
51
+ * {@link derivePrefix} so the rest of the code can assume `prefix` is always
52
+ * a string. When `specs` is omitted, a single-entry array is constructed from
53
+ * `config.openApiPath`, `config.prefix`, and `group = ""`.
28
54
  */
29
55
  function normalizeSpecs(config, specs) {
30
56
  if (specs !== undefined) {
31
- return specs;
57
+ return specs.map((spec) => ({ ...spec, prefix: derivePrefix(spec) }));
32
58
  }
33
- return [{ source: config.openApiPath, prefix: config.prefix, group: "" }];
59
+ return [
60
+ {
61
+ source: config.openApiPath,
62
+ prefix: config.prefix,
63
+ group: "",
64
+ version: "",
65
+ },
66
+ ];
34
67
  }
35
68
  function validateSpecGroups(specs) {
36
69
  if (specs.length <= 1) {
@@ -41,20 +74,26 @@ function validateSpecGroups(specs) {
41
74
  .filter(({ group }) => group === "")
42
75
  .map(({ index }) => String(index + 1));
43
76
  if (invalidSpecNumbers.length === 0) {
44
- const seenGroups = new Set();
45
- const duplicateGroupNames = new Set();
77
+ const seenKeys = new Set();
78
+ const duplicateKeys = new Set();
46
79
  for (const spec of specs) {
47
80
  const group = spec.group.trim();
48
- if (seenGroups.has(group)) {
49
- duplicateGroupNames.add(group);
81
+ const version = spec.version?.trim() ?? "";
82
+ // Use group@version as the uniqueness key so that the same group can
83
+ // appear with different versions (e.g. v1 and v2 of the same API).
84
+ // The empty-group case is already rejected above, so `group` is always
85
+ // non-empty here and the `@version` suffix remains unambiguous.
86
+ const key = version ? `${group}@${version}` : group;
87
+ if (seenKeys.has(key)) {
88
+ duplicateKeys.add(key);
50
89
  continue;
51
90
  }
52
- seenGroups.add(group);
91
+ seenKeys.add(key);
53
92
  }
54
- if (duplicateGroupNames.size === 0) {
93
+ if (duplicateKeys.size === 0) {
55
94
  return;
56
95
  }
57
- throw new Error(`Each spec must define a unique group when multiple APIs are configured (duplicate groups: ${[...duplicateGroupNames].join(", ")}).`);
96
+ throw new Error(`Each spec must define a unique group (and version) when multiple APIs are configured (duplicates: ${[...duplicateKeys].join(", ")}).`);
58
97
  }
59
98
  throw new Error(`Each spec must define a non-empty group when multiple APIs are configured (invalid spec entries: ${invalidSpecNumbers.join(", ")}).`);
60
99
  }
@@ -81,7 +120,18 @@ function validateSpecGroups(specs) {
81
120
  export async function counterfact(config, specs) {
82
121
  const normalizedSpecs = normalizeSpecs({ openApiPath: config.openApiPath, prefix: config.prefix }, specs);
83
122
  validateSpecGroups(normalizedSpecs);
84
- const runners = await Promise.all(normalizedSpecs.map((spec) => ApiRunner.create({ ...config, openApiPath: spec.source, prefix: spec.prefix }, spec.group)));
123
+ // Compute the ordered versions per group (oldest first, as declared in specs).
124
+ // This list is passed to each runner so that $.minVersion() can compare
125
+ // version positions at runtime.
126
+ const versionsByGroup = new Map();
127
+ for (const spec of normalizedSpecs) {
128
+ const version = spec.version ?? "";
129
+ if (version) {
130
+ const existing = versionsByGroup.get(spec.group) ?? [];
131
+ versionsByGroup.set(spec.group, [...existing, version]);
132
+ }
133
+ }
134
+ const runners = await Promise.all(normalizedSpecs.map((spec) => ApiRunner.create({ ...config, openApiPath: spec.source, prefix: spec.prefix }, spec.group, spec.version ?? "", versionsByGroup.get(spec.group) ?? [])));
85
135
  const koaApp = createKoaApp({
86
136
  runners,
87
137
  config,
@@ -89,7 +139,61 @@ export async function counterfact(config, specs) {
89
139
  // The REPL is configured using the first runner.
90
140
  const primaryRunner = runners[0];
91
141
  async function start(options) {
92
- await Promise.all(runners.map((runner) => runner.generate()));
142
+ // Serialize generate() calls within each group to avoid concurrent writes
143
+ // to the same output directory. Runners that share a group share the same
144
+ // basePath subdirectory (and therefore the same counterfact-types
145
+ // destination), so running them in parallel would cause a race when both
146
+ // try to create that directory at startup. Different groups are still
147
+ // generated in parallel.
148
+ //
149
+ // When multiple versioned specs share the same group, they also share a
150
+ // single Repository instance so that the shared `types/paths/…` files
151
+ // accumulate all versions into a merged Versioned<…> type instead of each
152
+ // overwriting the previous version's types.
153
+ const runnersByGroup = new Map();
154
+ for (const runner of runners) {
155
+ const bucket = runnersByGroup.get(runner.group) ?? [];
156
+ bucket.push(runner);
157
+ runnersByGroup.set(runner.group, bucket);
158
+ }
159
+ await Promise.all(Array.from(runnersByGroup.values()).map(async (bucket) => {
160
+ const sharedRepository = bucket.length > 1 ? new Repository() : undefined;
161
+ for (const runner of bucket) {
162
+ await runner.generate(sharedRepository);
163
+ }
164
+ }));
165
+ if (options.generate?.types) {
166
+ // Build a per-group map of unique non-empty version strings in
167
+ // declaration order. new Set() preserves insertion order so the first
168
+ // occurrence of each version is kept and duplicates are dropped without
169
+ // reordering.
170
+ const versionsByGroup = new Map();
171
+ for (const spec of normalizedSpecs) {
172
+ const group = spec.group;
173
+ const version = (spec.version ?? "").trim();
174
+ if (version === "") {
175
+ continue;
176
+ }
177
+ const existing = versionsByGroup.get(group) ?? [];
178
+ if (!existing.includes(version)) {
179
+ existing.push(version);
180
+ }
181
+ versionsByGroup.set(group, existing);
182
+ }
183
+ // Write <basePath>/<group>/types/versions.ts for every group that has
184
+ // at least one versioned spec. When the group is empty the path
185
+ // collapses to <basePath>/types/versions.ts (the single-spec case).
186
+ await Promise.all(Array.from(versionsByGroup.entries()).map(async ([group, versions]) => {
187
+ const content = await generateVersionsTsContent(versions);
188
+ const versionsFilePath = group
189
+ ? nodePath.join(config.basePath, group, "types", "versions.ts")
190
+ : nodePath.join(config.basePath, "types", "versions.ts");
191
+ /* eslint-disable security/detect-non-literal-fs-filename -- path is derived from the caller-supplied basePath and fixed suffixes. */
192
+ await ensureDirectoryExists(versionsFilePath);
193
+ await fs.writeFile(versionsFilePath, content, "utf8");
194
+ /* eslint-enable security/detect-non-literal-fs-filename */
195
+ }));
196
+ }
93
197
  await Promise.all(runners.map((runner) => runner.watch()));
94
198
  await Promise.all(runners.map((runner) => runner.start(options)));
95
199
  let httpTerminator;
@@ -67,7 +67,7 @@ export function createIntroduction(params) {
67
67
  ` API Base URL ${url}`,
68
68
  source === "_" ? undefined : ` Swagger UI ${swaggerUrl}`,
69
69
  "",
70
- " Instructions https://counterfact.dev/docs/usage.html",
70
+ " Instructions https://github.com/counterfact/api-simulator/blob/main/docs/usage.md",
71
71
  " Help/feedback https://github.com/pmcelhaney/counterfact/issues",
72
72
  "",
73
73
  ...telemetryWarning,
package/dist/cli/run.js CHANGED
@@ -13,7 +13,7 @@ import { pathResolve } from "../util/forward-slash-path.js";
13
13
  import { loadConfigFile } from "../util/load-config-file.js";
14
14
  import { createIntroduction } from "./banner.js";
15
15
  import { checkForUpdates } from "./check-for-updates.js";
16
- import { isTelemetryEnabled, sendTelemetry } from "./telemetry.js";
16
+ import { hashTelemetryLocation, isTelemetryEnabled, sendTelemetry, } from "./telemetry.js";
17
17
  const debug = createDebug("counterfact:cli:run");
18
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
19
19
  const DEFAULT_PORT = 3100;
@@ -22,18 +22,23 @@ const DEFAULT_PORT = 3100;
22
22
  * CLI flag) into an array of {@link SpecConfig} objects, or `undefined` when
23
23
  * the option is a plain string (single OpenAPI document path).
24
24
  *
25
- * - **Array**: each entry is mapped to `{source, prefix, group}` with defaults.
25
+ * - **Array**: each entry is mapped to `{source, prefix, group, version}` with defaults.
26
26
  * - **Object**: wrapped in a single-element array.
27
27
  * - **String / undefined**: returns `undefined` — caller handles the string
28
28
  * case (it shifts the positional argument) and the `undefined` case
29
29
  * (single spec derived from config).
30
+ *
31
+ * Note: `prefix` is intentionally left `undefined` when not supplied so that
32
+ * `normalizeSpecs` (in `app.ts`) can derive it automatically from
33
+ * `group`/`version`.
30
34
  */
31
35
  export function normalizeSpecOption(specOption) {
32
36
  if (Array.isArray(specOption)) {
33
37
  return specOption.map((entry) => ({
34
38
  source: entry.source,
35
- prefix: entry.prefix ?? "",
39
+ prefix: entry.prefix,
36
40
  group: entry.group ?? "",
41
+ version: entry.version,
37
42
  }));
38
43
  }
39
44
  if (typeof specOption === "object" &&
@@ -42,13 +47,43 @@ export function normalizeSpecOption(specOption) {
42
47
  return [
43
48
  {
44
49
  source: specOption.source,
45
- prefix: specOption.prefix ?? "",
50
+ prefix: specOption.prefix,
46
51
  group: specOption.group ?? "",
52
+ version: specOption.version,
47
53
  },
48
54
  ];
49
55
  }
50
56
  return undefined;
51
57
  }
58
+ export function buildStartupTelemetryProperties(options, source, version, specs) {
59
+ const apiSources = specs?.map((spec) => spec.source) ?? [source];
60
+ const apiFileLocationHashes = apiSources
61
+ .filter((apiSource) => apiSource !== "_")
62
+ .map((apiSource) => hashTelemetryLocation(apiSource));
63
+ return {
64
+ alwaysFakeOptionals: Boolean(options.alwaysFakeOptionals),
65
+ apiFileLocationHashes,
66
+ buildCache: Boolean(options.buildCache),
67
+ generateRoutes: Boolean(options.generate) || Boolean(options.generateRoutes),
68
+ generateTypes: Boolean(options.generate) || Boolean(options.generateTypes),
69
+ mode: specs !== undefined
70
+ ? "multi-spec"
71
+ : source === "_"
72
+ ? "without-openapi"
73
+ : "single-spec",
74
+ openBrowser: Boolean(options.open),
75
+ port: options.port,
76
+ prune: Boolean(options.prune),
77
+ repl: Boolean(options.repl),
78
+ serve: Boolean(options.serve),
79
+ updateCheck: Boolean(options.updateCheck),
80
+ validateRequest: Boolean(options.validateRequest),
81
+ validateResponse: Boolean(options.validateResponse),
82
+ version,
83
+ watchRoutes: Boolean(options.watch) || Boolean(options.watchRoutes),
84
+ watchTypes: Boolean(options.watch) || Boolean(options.watchTypes),
85
+ };
86
+ }
52
87
  /**
53
88
  * Builds the Commander program with all CLI options and the action handler.
54
89
  * Factored out of `runCli` so it is easy to test or extend.
@@ -110,6 +145,7 @@ function buildProgram(version, taglines) {
110
145
  debug("options: %o", options);
111
146
  debug("source: %s", source);
112
147
  debug("destination: %s", destination);
148
+ const startupTelemetryProperties = buildStartupTelemetryProperties(options, source, version, specs);
113
149
  const openBrowser = options.open;
114
150
  const url = `http://localhost:${options.port}${options.prefix}`;
115
151
  const guiUrl = `${url}/counterfact/`;
@@ -207,6 +243,7 @@ function buildProgram(version, taglines) {
207
243
  process.exit(1);
208
244
  }
209
245
  debug("started server");
246
+ sendTelemetry("counterfact_started", startupTelemetryProperties);
210
247
  await updateCheckPromise;
211
248
  if (config.startRepl) {
212
249
  startRepl();
@@ -253,7 +290,7 @@ function buildProgram(version, taglines) {
253
290
  .option("--watch-routes", "generate + watch routes for changes")
254
291
  .option("-s, --serve", "start the server")
255
292
  .option("-b, --build-cache", "builds the cache of compiled routes and types")
256
- .option("--no-admin-api", "disable the admin API at /_counterfact/api/*")
293
+ .option("--admin-api", "enable the admin API at /_counterfact/api/*")
257
294
  .option("-r, --repl", "start the REPL")
258
295
  .option("--proxy-url <string>", "proxy URL")
259
296
  .option("--admin-api-token <string>", "bearer token required for /_counterfact/api/* endpoints (defaults to COUNTERFACT_ADMIN_API_TOKEN)")
@@ -294,10 +331,6 @@ export async function runCli(argv) {
294
331
  catch {
295
332
  taglines = ["counterfact — mock API server"];
296
333
  }
297
- // Fire telemetry once on startup — fire-and-forget, never blocks.
298
- if (isTelemetryEnabled()) {
299
- sendTelemetry(version);
300
- }
301
334
  debug("running counterfact CLI v%s", version);
302
335
  const program = buildProgram(version, taglines);
303
336
  await program.parseAsync(argv);
@@ -1,13 +1,11 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { PostHog } from "posthog-node";
3
3
  const POSTHOG_API_KEY = "phc_msXmBxiL8FVugNMLCx9bnPQGqfEMqmyBjnVkKhHkN3m7";
4
4
  const POSTHOG_HOST = "https://us.i.posthog.com";
5
5
  /**
6
6
  * Returns `true` when telemetry should be sent.
7
7
  *
8
- * Telemetry is disabled in CI, when `COUNTERFACT_TELEMETRY_DISABLED=true`,
9
- * or before the May 2026 rollout date unless the user has explicitly opted
10
- * in with `COUNTERFACT_TELEMETRY_DISABLED=false`.
8
+ * Telemetry is disabled in CI or when `COUNTERFACT_TELEMETRY_DISABLED=true`.
11
9
  */
12
10
  export function isTelemetryEnabled() {
13
11
  if (process.env["CI"])
@@ -15,29 +13,32 @@ export function isTelemetryEnabled() {
15
13
  const telemetryDisabledEnv = process.env["COUNTERFACT_TELEMETRY_DISABLED"];
16
14
  if (telemetryDisabledEnv === "true")
17
15
  return false;
18
- const isBeforeRollout = new Date() < new Date("2026-05-01");
19
- if (isBeforeRollout && telemetryDisabledEnv !== "false")
20
- return false;
21
16
  return true;
22
17
  }
18
+ export function hashTelemetryLocation(location) {
19
+ return createHash("sha256").update(location).digest("hex");
20
+ }
23
21
  /**
24
22
  * Fires a telemetry event to PostHog. Fire-and-forget — never blocks
25
23
  * startup and never surfaces errors to the user.
26
24
  */
27
- export function sendTelemetry(version) {
25
+ export function sendTelemetry(event, properties = {}) {
26
+ if (!isTelemetryEnabled()) {
27
+ return;
28
+ }
28
29
  const telemetryKey = process.env["POSTHOG_API_KEY"] ?? POSTHOG_API_KEY;
29
30
  const telemetryHost = process.env["POSTHOG_HOST"] ?? POSTHOG_HOST;
30
31
  try {
31
32
  const posthog = new PostHog(telemetryKey, { host: telemetryHost });
32
33
  posthog.capture({
33
34
  distinctId: randomUUID(),
34
- event: "counterfact_started",
35
+ event,
35
36
  properties: {
36
- version,
37
37
  nodeVersion: process.version,
38
38
  platform: process.platform,
39
39
  arch: process.arch,
40
40
  source: "counterfact-cli",
41
+ ...properties,
41
42
  },
42
43
  });
43
44
  posthog.flush().catch(() => {
@@ -14,6 +14,7 @@ const HTTP_METHODS = [
14
14
  "PATCH",
15
15
  "HEAD",
16
16
  "OPTIONS",
17
+ "QUERY",
17
18
  ];
18
19
  // Pre-compile regex patterns derived from HTTP_METHODS
19
20
  const HTTP_METHOD_ALTERNATION = HTTP_METHODS.join("|");
package/dist/msw.js CHANGED
@@ -14,6 +14,7 @@ const allowedMethods = [
14
14
  "delete",
15
15
  "patch",
16
16
  "options",
17
+ "query",
17
18
  ];
18
19
  const mswHandlers = {};
19
20
  /**
package/dist/repl/repl.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import repl from "node:repl";
2
+ import { sendTelemetry } from "../cli/telemetry.js";
2
3
  import { RawHttpClient } from "./raw-http-client.js";
3
4
  import { createRouteFunction } from "./route-builder.js";
4
5
  function printToStdout(line) {
@@ -194,9 +195,6 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
194
195
  }
195
196
  seenGroups.add(binding.key);
196
197
  }
197
- if (duplicateGroups.size > 0) {
198
- throw new Error(`Duplicate API groups are not allowed when multiple APIs are configured (duplicate groups: ${[...duplicateGroups].join(", ")}).`);
199
- }
200
198
  }
201
199
  const rootBinding = groupedBindings[0];
202
200
  if (rootBinding === undefined) {
@@ -261,6 +259,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
261
259
  : undefined);
262
260
  replServer.defineCommand("counterfact", {
263
261
  action() {
262
+ sendTelemetry("repl_command_used", { command: "counterfact" });
264
263
  print("This is a read-eval-print loop (REPL), the same as the one you get when you run node with no arguments.");
265
264
  print("Except that it's connected to the running server, which you can access with the following globals:");
266
265
  print("");
@@ -268,7 +267,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
268
267
  print("- context: the root context ( same as loadContext('/') )");
269
268
  print("- route('/some/path'): create a request builder for the given path");
270
269
  print("");
271
- print("For more information, see https://counterfact.dev/docs/usage.html");
270
+ print("For more information, see https://github.com/counterfact/api-simulator/blob/main/docs/usage.md");
272
271
  print("");
273
272
  this.clearBufferedCommand();
274
273
  this.displayPrompt();
@@ -277,6 +276,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
277
276
  });
278
277
  replServer.defineCommand("proxy", {
279
278
  action(text) {
279
+ sendTelemetry("repl_command_used", { command: "proxy" });
280
280
  if (text === "help" || text === "") {
281
281
  print(".proxy [on|off] - turn the proxy on/off at the root level");
282
282
  print(".proxy [on|off] <path-prefix> - turn the proxy on for a path");
@@ -316,6 +316,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
316
316
  : {};
317
317
  replServer.defineCommand("scenario", {
318
318
  async action(text) {
319
+ sendTelemetry("repl_command_used", { command: "scenario" });
319
320
  const trimmedText = text.trim();
320
321
  const parsedArgs = trimmedText.split(/\s+/u).filter(Boolean);
321
322
  const usage = isMultiApi
@@ -2,9 +2,13 @@
2
2
  * Represents a named example defined in an OpenAPI document.
3
3
  * Examples can be referenced by route handlers via the `.example(name)` method
4
4
  * on the response builder.
5
+ *
6
+ * OpenAPI 3.2 adds `dataValue` as a structured alternative to `value`.
7
+ * When present, `dataValue` is preferred over `value`.
5
8
  */
6
9
  export interface Example {
10
+ dataValue?: unknown;
7
11
  description: string;
8
12
  summary: string;
9
- value: unknown;
13
+ value?: unknown;
10
14
  }
@@ -130,6 +130,10 @@ export type GenericResponseBuilderInner<
130
130
  : (name: ExampleNames<Response>) => COUNTERFACT_RESPONSE;
131
131
  text: MaybeShortcut<["text/plain"], Response>;
132
132
  xml: MaybeShortcut<["application/xml", "text/xml"], Response>;
133
+ stream: MaybeShortcut<
134
+ ["text/event-stream", "application/jsonl", "application/json-seq"],
135
+ Response
136
+ >;
133
137
  }>;
134
138
 
135
139
  /**
@@ -6,7 +6,14 @@
6
6
  */
7
7
  export interface OpenApiParameters {
8
8
  explode?: boolean;
9
- in: "body" | "cookie" | "formData" | "header" | "path" | "query";
9
+ in:
10
+ | "body"
11
+ | "cookie"
12
+ | "formData"
13
+ | "header"
14
+ | "path"
15
+ | "query"
16
+ | "querystring";
10
17
  name: string;
11
18
  required?: boolean;
12
19
  schema?: {
@@ -26,6 +26,11 @@ export interface ResponseBuilder {
26
26
  random: () => MaybePromise<ResponseBuilder>;
27
27
  randomLegacy: () => MaybePromise<ResponseBuilder>;
28
28
  status?: number;
29
+ stream: (iterable: AsyncIterable<unknown>) => {
30
+ body: AsyncIterable<unknown>;
31
+ contentType: string;
32
+ status?: number;
33
+ };
29
34
  text: (body: unknown) => ResponseBuilder;
30
35
  xml: (body: unknown) => ResponseBuilder;
31
36
  }
@@ -23,4 +23,5 @@ export interface WideResponseBuilder {
23
23
  random: () => MaybePromise<WideResponseBuilder>;
24
24
  text: (body: unknown) => WideResponseBuilder;
25
25
  xml: (body: unknown) => WideResponseBuilder;
26
+ stream: (body: AsyncIterable<unknown>) => WideResponseBuilder;
26
27
  }