counterfact 2.5.1 → 2.6.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 (34) hide show
  1. package/README.md +1 -0
  2. package/bin/README.md +1 -0
  3. package/bin/counterfact.js +164 -23
  4. package/bin/register-ts-loader.mjs +17 -0
  5. package/bin/ts-loader.mjs +31 -0
  6. package/dist/app.js +23 -12
  7. package/dist/migrate/update-route-types.js +30 -10
  8. package/dist/repl/raw-http-client.js +14 -14
  9. package/dist/repl/repl.js +24 -2
  10. package/dist/repl/route-builder.js +270 -0
  11. package/dist/server/config.js +1 -1
  12. package/dist/server/context-registry.js +27 -3
  13. package/dist/server/counterfact-types/index.ts +11 -1
  14. package/dist/server/determine-module-kind.js +1 -1
  15. package/dist/server/dispatcher.js +21 -10
  16. package/dist/server/file-discovery.js +34 -0
  17. package/dist/server/middleware-detector.js +8 -0
  18. package/dist/server/module-dependency-graph.js +4 -1
  19. package/dist/server/module-loader.js +7 -31
  20. package/dist/server/module-tree.js +26 -23
  21. package/dist/server/openapi-middleware.js +2 -2
  22. package/dist/server/registry.js +2 -2
  23. package/dist/server/request-validator.js +61 -0
  24. package/dist/server/transpiler.js +13 -5
  25. package/dist/typescript-generator/coder.js +3 -0
  26. package/dist/typescript-generator/jsdoc.js +45 -0
  27. package/dist/typescript-generator/operation-type-coder.js +4 -0
  28. package/dist/typescript-generator/parameters-type-coder.js +5 -1
  29. package/dist/typescript-generator/prune.js +2 -1
  30. package/dist/typescript-generator/schema-type-coder.js +7 -1
  31. package/dist/typescript-generator/script.js +5 -3
  32. package/dist/typescript-generator/specification.js +7 -1
  33. package/dist/util/runtime-can-execute-erasable-ts.js +22 -0
  34. package/package.json +7 -6
package/README.md CHANGED
@@ -205,6 +205,7 @@ npx counterfact@latest [openapi.yaml] [destination] [options]
205
205
  | `--spec <path>` | Path or URL to the OpenAPI document |
206
206
  | `--proxy-url <url>` | Forward all requests to this URL by default |
207
207
  | `--prefix <path>` | Base path prefix (e.g. `/api/v1`) |
208
+ | `--no-validate-request` | Disable request validation against the OpenAPI spec |
208
209
 
209
210
  Run `npx counterfact@latest --help` for the full list of options.
210
211
 
package/bin/README.md CHANGED
@@ -41,5 +41,6 @@ npx counterfact@latest openapi.yaml ./api [options]
41
41
  | `--proxy-url <url>` | Forward all unmatched requests to this upstream URL |
42
42
  | `--prefix <path>` | Base path prefix for all routes (e.g. `/api/v1`) |
43
43
  | `--no-update-check` | Disable the npm update check on startup |
44
+ | `--no-validate-request` | Disable request validation against the OpenAPI spec |
44
45
 
45
46
  Run `npx counterfact@latest --help` to see the full option list.
@@ -1,17 +1,43 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ /**
4
+ * bin/counterfact.js — CLI entry point for the `counterfact` command.
5
+ *
6
+ * Responsibilities:
7
+ * 1. Parse CLI arguments and build a `Config` object via Commander.
8
+ * 2. Run any pending migrations (paths → routes directory layout).
9
+ * 3. Delegate to `counterfact()` from `src/app.ts` to start the server,
10
+ * code generator, transpiler, module loader, and optional REPL.
11
+ * 4. Print the startup banner and open the browser when requested.
12
+ * 5. Check for available updates against the npm registry.
13
+ *
14
+ * Architecture (high-level data flow):
15
+ *
16
+ * CLI args ──▶ Commander ──▶ Config
17
+ * │
18
+ * ┌───────────▼───────────┐
19
+ * │ counterfact() │
20
+ * │ (src/app.ts) │
21
+ * │ │
22
+ * │ CodeGenerator │ reads OpenAPI spec, emits .ts route/type files
23
+ * │ Transpiler │ compiles .ts → .cjs and watches for changes
24
+ * │ ModuleLoader │ loads compiled modules into Registry
25
+ * │ Dispatcher + KoaApp │ handles HTTP requests
26
+ * │ REPL (optional) │ interactive terminal session
27
+ * └────────────────────────┘
28
+ */
29
+
3
30
  import fs from "node:fs";
4
31
  import { readFile } from "node:fs/promises";
32
+ import { tmpdir } from "node:os";
5
33
  import nodePath from "node:path";
6
- import { fileURLToPath } from "node:url";
34
+ import { fileURLToPath, pathToFileURL } from "node:url";
35
+ import { randomUUID } from "node:crypto";
7
36
 
8
37
  import { program } from "commander";
9
38
  import createDebug from "debug";
10
39
  import open from "open";
11
-
12
- import { counterfact } from "../dist/app.js";
13
- import { pathsToRoutes } from "../dist/migrate/paths-to-routes.js";
14
- import { updateRouteTypes } from "../dist/migrate/update-route-types.js";
40
+ import { PostHog } from "posthog-node";
15
41
 
16
42
  const MIN_NODE_VERSION = 17;
17
43
 
@@ -23,28 +49,110 @@ if (Number.parseInt(process.versions.node.split("."), 10) < MIN_NODE_VERSION) {
23
49
  process.exit(1);
24
50
  }
25
51
 
52
+ const __binDir = nodePath.dirname(fileURLToPath(import.meta.url));
53
+
26
54
  const packageJson = JSON.parse(
27
- await readFile(
28
- nodePath.join(
29
- nodePath.dirname(fileURLToPath(import.meta.url)),
30
- "../package.json",
31
- ),
32
- "utf8",
33
- ),
55
+ await readFile(nodePath.join(__binDir, "../package.json"), "utf8"),
34
56
  );
35
57
 
36
58
  const CURRENT_VERSION = packageJson.version;
37
59
 
60
+ // Telemetry — fire-and-forget, never blocks startup
61
+ const POSTHOG_API_KEY = "phc_msXmBxiL8FVugNMLCx9bnPQGqfEMqmyBjnVkKhHkN3m7";
62
+ const POSTHOG_HOST = "https://us.i.posthog.com";
63
+
64
+ const telemetryKey = process.env.POSTHOG_API_KEY ?? POSTHOG_API_KEY;
65
+ const telemetryHost = process.env.POSTHOG_HOST ?? POSTHOG_HOST;
66
+
67
+ const isCI = Boolean(process.env.CI);
68
+ const isBeforeRollout = new Date() < new Date("2026-05-01");
69
+ const telemetryDisabledEnv = process.env.COUNTERFACT_TELEMETRY_DISABLED;
70
+
71
+ const isTelemetryDisabled =
72
+ isCI ||
73
+ telemetryDisabledEnv === "true" ||
74
+ (isBeforeRollout && telemetryDisabledEnv !== "false");
75
+
76
+ if (!isTelemetryDisabled) {
77
+ try {
78
+ const posthog = new PostHog(telemetryKey, { host: telemetryHost });
79
+
80
+ posthog.capture({
81
+ distinctId: randomUUID(),
82
+ event: "counterfact_started",
83
+ properties: {
84
+ version: CURRENT_VERSION,
85
+ nodeVersion: process.version,
86
+ platform: process.platform,
87
+ arch: process.arch,
88
+ source: "counterfact-cli",
89
+ },
90
+ });
91
+
92
+ posthog.shutdownAsync().catch(() => {
93
+ // ignore errors — telemetry is best-effort
94
+ });
95
+ } catch {
96
+ // ignore errors — telemetry must never surface to the user
97
+ }
98
+ }
99
+
38
100
  const taglinesFile = await readFile(
39
- nodePath.join(
40
- nodePath.dirname(fileURLToPath(import.meta.url)),
41
- "taglines.txt",
42
- ),
101
+ nodePath.join(__binDir, "taglines.txt"),
43
102
  "utf8",
44
103
  );
45
104
 
46
105
  const taglines = taglinesFile.split("\n").slice(0, -1);
47
106
 
107
+ // Probe whether the current runtime can natively execute TypeScript with
108
+ // erasable type annotations AND resolve .js imports to .ts files (tsx-style).
109
+ async function runtimeCanExecuteErasableTs() {
110
+ const dir = fs.mkdtempSync(nodePath.join(tmpdir(), "ts-probe-"));
111
+ // helper.ts is imported via .js extension — the TypeScript convention used
112
+ // throughout this codebase. If the runtime resolves helper.js → helper.ts,
113
+ // it is fully capable of running the TypeScript source tree.
114
+ fs.writeFileSync(
115
+ nodePath.join(dir, "helper.ts"),
116
+ 'export const value: string = "ok";\n',
117
+ "utf8",
118
+ );
119
+ fs.writeFileSync(
120
+ nodePath.join(dir, "main.ts"),
121
+ 'import { value } from "./helper.js"; export default value;\n',
122
+ "utf8",
123
+ );
124
+ try {
125
+ const mod = await import(pathToFileURL(nodePath.join(dir, "main.ts")).href);
126
+ return mod?.default === "ok";
127
+ } catch {
128
+ return false;
129
+ } finally {
130
+ fs.rmSync(dir, { recursive: true, force: true });
131
+ }
132
+ }
133
+
134
+ const nativeTs = await runtimeCanExecuteErasableTs();
135
+
136
+ const resolve = (rel) => pathToFileURL(nodePath.join(__binDir, rel)).href;
137
+
138
+ const { counterfact } = await import(
139
+ resolve(nativeTs ? "../src/app.ts" : "../dist/app.js")
140
+ );
141
+ const { pathsToRoutes } = await import(
142
+ resolve(
143
+ nativeTs
144
+ ? "../src/migrate/paths-to-routes.js"
145
+ : "../dist/migrate/paths-to-routes.js",
146
+ )
147
+ );
148
+ const { updateRouteTypes } = await import(
149
+ resolve(
150
+ nativeTs
151
+ ? "../src/migrate/update-route-types.js"
152
+ : "../dist/migrate/update-route-types.js",
153
+ )
154
+ );
155
+
48
156
  const DEFAULT_PORT = 3100;
49
157
 
50
158
  const debug = createDebug("counterfact:bin:counterfact");
@@ -234,6 +342,7 @@ async function main(source, destination) {
234
342
  startRepl: options.repl,
235
343
  startServer: options.serve,
236
344
  buildCache: options.buildCache || false,
345
+ validateRequests: options.validateRequest !== false,
237
346
 
238
347
  watch: {
239
348
  routes: options.watch || options.watchRoutes,
@@ -248,6 +357,14 @@ async function main(source, destination) {
248
357
 
249
358
  debug("loading counterfact (%o)", configForLogging);
250
359
 
360
+ if (config.startAdminApi && !config.adminApiToken) {
361
+ process.stderr.write(
362
+ "⚠️ WARNING: The admin API is enabled without an authentication token.\n" +
363
+ " Any process on this machine can read and modify server state via /_counterfact/api/*.\n" +
364
+ " Set --admin-api-token or COUNTERFACT_ADMIN_API_TOKEN to restrict access.\n\n",
365
+ );
366
+ }
367
+
251
368
  let didMigrate = false;
252
369
  let didMigrateRouteTypes;
253
370
 
@@ -266,7 +383,16 @@ async function main(source, destination) {
266
383
  didMigrate = true;
267
384
  }
268
385
 
269
- const { start, startRepl } = await counterfact(config);
386
+ let start;
387
+ let startRepl;
388
+ try {
389
+ ({ start, startRepl } = await counterfact(config));
390
+ } catch (error) {
391
+ process.stderr.write(
392
+ `\n❌ ${error instanceof Error ? error.message : String(error)}\n\n`,
393
+ );
394
+ process.exit(1);
395
+ }
270
396
 
271
397
  debug("loaded counterfact", configForLogging);
272
398
 
@@ -280,6 +406,14 @@ async function main(source, destination) {
280
406
 
281
407
  const watchMessage = createWatchMessage(config);
282
408
 
409
+ const telemetryWarning = isTelemetryDisabled
410
+ ? []
411
+ : [
412
+ "⚠️ Telemetry will be enabled by default starting May 1, 2026.",
413
+ " Learn more and how to disable: https://counterfact.dev/telemetry-discussion",
414
+ "",
415
+ ];
416
+
283
417
  const introduction = [
284
418
  " ____ ____ _ _ _ _ ___ ____ ____ ____ ____ ____ ___",
285
419
  String.raw` |___ [__] |__| |\| | |=== |--< |--- |--| |___ | `,
@@ -292,11 +426,7 @@ async function main(source, destination) {
292
426
  " Instructions https://counterfact.dev/docs/usage.html",
293
427
  " Help/feedback https://github.com/pmcelhaney/counterfact/issues",
294
428
  "",
295
- "",
296
- "🔔 PLEASE READ: Feedback, Telemetry, and Privacy Discussion (10 March 2026)",
297
- " https://counterfact.dev/telemetry-discussion",
298
- "",
299
- "",
429
+ ...telemetryWarning,
300
430
  watchMessage,
301
431
  config.startServer ? " Starting server" : undefined,
302
432
  config.startRepl
@@ -311,7 +441,14 @@ async function main(source, destination) {
311
441
  process.stdout.write("\n\n");
312
442
 
313
443
  debug("starting server");
314
- await start(config);
444
+ try {
445
+ await start(config);
446
+ } catch (error) {
447
+ process.stderr.write(
448
+ `\n❌ ${error instanceof Error ? error.message : String(error)}\n\n`,
449
+ );
450
+ process.exit(1);
451
+ }
315
452
  debug("started server");
316
453
 
317
454
  await updateCheckPromise;
@@ -409,5 +546,9 @@ program
409
546
  "path or URL to OpenAPI document (alternative to the positional [openapi.yaml] argument)",
410
547
  )
411
548
  .option("--no-update-check", "disable the npm update check on startup")
549
+ .option(
550
+ "--no-validate-request",
551
+ "disable request validation against the OpenAPI spec",
552
+ )
412
553
  .action(main)
413
554
  .parse(process.argv);
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Register the ts-loader hook using Node's modern module.register() API.
3
+ *
4
+ * Usage (replaces deprecated --loader flag):
5
+ * node --experimental-strip-types --import ./bin/register-ts-loader.mjs bin/counterfact.js ...
6
+ */
7
+
8
+ // module.register() was added in Node 20.6 / 22; this file is only used when
9
+ // running counterfact under a TypeScript-capable Node runtime (22.6+).
10
+ // eslint-disable-next-line n/no-unsupported-features/node-builtins
11
+ import { register } from "node:module";
12
+ import { dirname, join } from "node:path";
13
+ import { fileURLToPath, pathToFileURL } from "node:url";
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+
17
+ register("./ts-loader.mjs", pathToFileURL(join(__dirname, "/")));
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Node.js custom loader that remaps .js import specifiers to .ts when a
3
+ * corresponding .ts file exists alongside the importer.
4
+ *
5
+ * Usage:
6
+ * node --experimental-strip-types --loader ./bin/ts-loader.mjs bin/counterfact.js ...
7
+ *
8
+ * Why: Node's built-in --experimental-strip-types handles type annotation
9
+ * removal, but it does not remap .js → .ts import specifiers. This codebase
10
+ * uses the TypeScript convention of writing .js extensions in import paths
11
+ * (which resolve to .ts files at authoring time). This loader bridges that gap.
12
+ */
13
+
14
+ import { existsSync } from "node:fs";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ export function resolve(specifier, context, nextResolve) {
18
+ if (specifier.endsWith(".js") && context.parentURL) {
19
+ const tsSpecifier = specifier.slice(0, -3) + ".ts";
20
+ try {
21
+ const resolved = new URL(tsSpecifier, context.parentURL);
22
+ if (existsSync(fileURLToPath(resolved))) {
23
+ return nextResolve(tsSpecifier, context);
24
+ }
25
+ } catch {
26
+ // If URL construction fails, fall through to default resolution
27
+ }
28
+ }
29
+
30
+ return nextResolve(specifier, context);
31
+ }
package/dist/app.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs, { rm } from "node:fs/promises";
2
2
  import nodePath from "node:path";
3
3
  import { dereference } from "@apidevtools/json-schema-ref-parser";
4
+ import createDebug from "debug";
4
5
  import { createHttpTerminator } from "http-terminator";
5
6
  import { startRepl as startReplServer } from "./repl/repl.js";
6
7
  import { ContextRegistry } from "./server/context-registry.js";
@@ -11,6 +12,8 @@ import { ModuleLoader } from "./server/module-loader.js";
11
12
  import { Registry } from "./server/registry.js";
12
13
  import { Transpiler } from "./server/transpiler.js";
13
14
  import { CodeGenerator } from "./typescript-generator/code-generator.js";
15
+ import { runtimeCanExecuteErasableTs } from "./util/runtime-can-execute-erasable-ts.js";
16
+ const debug = createDebug("counterfact:app");
14
17
  const allowedMethods = [
15
18
  "all",
16
19
  "head",
@@ -25,8 +28,10 @@ export async function loadOpenApiDocument(source) {
25
28
  try {
26
29
  return (await dereference(source));
27
30
  }
28
- catch {
29
- return undefined;
31
+ catch (error) {
32
+ debug("could not load OpenAPI document from %s: %o", source, error);
33
+ const details = error instanceof Error ? error.message : String(error);
34
+ throw new Error(`Could not load the OpenAPI spec from "${source}".\n${details}`, { cause: error });
30
35
  }
31
36
  }
32
37
  const mswHandlers = {};
@@ -44,9 +49,6 @@ export async function createMswHandlers(config, ModuleLoaderClass = ModuleLoader
44
49
  // If we "pre-read" the file here it works. This is a workaround to avoid the issue.
45
50
  await fs.readFile(config.openApiPath);
46
51
  const openApiDocument = await loadOpenApiDocument(config.openApiPath);
47
- if (openApiDocument === undefined) {
48
- throw new Error(`Could not load OpenAPI document from ${config.openApiPath}`);
49
- }
50
52
  const modulesPath = config.basePath;
51
53
  const compiledPathsDirectory = nodePath
52
54
  .join(modulesPath, ".cache")
@@ -75,29 +77,37 @@ export async function createMswHandlers(config, ModuleLoaderClass = ModuleLoader
75
77
  }
76
78
  export async function counterfact(config) {
77
79
  const modulesPath = config.basePath;
80
+ const nativeTs = await runtimeCanExecuteErasableTs();
78
81
  const compiledPathsDirectory = nodePath
79
- .join(modulesPath, ".cache")
82
+ .join(modulesPath, nativeTs ? "routes" : ".cache")
80
83
  .replaceAll("\\", "/");
81
- await rm(compiledPathsDirectory, { force: true, recursive: true });
84
+ if (!nativeTs) {
85
+ await rm(compiledPathsDirectory, { force: true, recursive: true });
86
+ }
82
87
  const registry = new Registry();
83
88
  const contextRegistry = new ContextRegistry();
84
89
  const codeGenerator = new CodeGenerator(config.openApiPath, config.basePath, config.generate);
85
- const dispatcher = new Dispatcher(registry, contextRegistry, await loadOpenApiDocument(config.openApiPath), config);
90
+ const openApiDocument = config.openApiPath === "_"
91
+ ? undefined
92
+ : await loadOpenApiDocument(config.openApiPath);
93
+ const dispatcher = new Dispatcher(registry, contextRegistry, openApiDocument, config);
86
94
  const transpiler = new Transpiler(nodePath.join(modulesPath, "routes").replaceAll("\\", "/"), compiledPathsDirectory, "commonjs");
87
95
  const moduleLoader = new ModuleLoader(compiledPathsDirectory, registry, contextRegistry);
88
96
  const middleware = koaMiddleware(dispatcher, config);
89
97
  const koaApp = createKoaApp(registry, middleware, config, contextRegistry);
90
98
  async function start(options) {
91
99
  const { generate, startServer, watch, buildCache } = options;
92
- if (generate.routes || generate.types) {
100
+ if (config.openApiPath !== "_" && (generate.routes || generate.types)) {
93
101
  await codeGenerator.generate();
94
102
  }
95
- if (watch.routes || watch.types) {
103
+ if (config.openApiPath !== "_" && (watch.routes || watch.types)) {
96
104
  await codeGenerator.watch();
97
105
  }
98
106
  let httpTerminator;
99
107
  if (startServer) {
100
- await transpiler.watch();
108
+ if (!nativeTs) {
109
+ await transpiler.watch();
110
+ }
101
111
  await moduleLoader.load();
102
112
  await moduleLoader.watch();
103
113
  const server = koaApp.listen({
@@ -127,6 +137,7 @@ export async function counterfact(config) {
127
137
  koaMiddleware: middleware,
128
138
  registry,
129
139
  start,
130
- startRepl: () => startReplServer(contextRegistry, registry, config),
140
+ startRepl: () => startReplServer(contextRegistry, registry, config, undefined, // use the default print function (stdout)
141
+ openApiDocument),
131
142
  };
132
143
  }
@@ -13,6 +13,23 @@ const HTTP_METHODS = [
13
13
  "HEAD",
14
14
  "OPTIONS",
15
15
  ];
16
+ // Pre-compile regex patterns derived from HTTP_METHODS
17
+ const HTTP_METHOD_ALTERNATION = HTTP_METHODS.join("|");
18
+ // eslint-disable-next-line security/detect-non-literal-regexp
19
+ const NEEDS_MIGRATION_REGEX = new RegExp(`import\\s+type\\s+\\{[^}]*HTTP_(?:${HTTP_METHOD_ALTERNATION})[^}]*\\}`, "iu");
20
+ // eslint-disable-next-line security/detect-non-literal-regexp
21
+ const HTTP_TYPE_NAME_REGEX = new RegExp(`^HTTP_(?<method>${HTTP_METHOD_ALTERNATION})$`, "u");
22
+ // Pre-build import/export replacement patterns for each HTTP method type name
23
+ const IMPORT_REPLACE_PATTERNS = new Map(HTTP_METHODS.map((method) => [
24
+ `HTTP_${method}`,
25
+ // eslint-disable-next-line security/detect-non-literal-regexp
26
+ new RegExp(`(import\\s+type\\s+\\{[^}]*\\b)HTTP_${method}(\\b[^}]*\\}\\s+from)`, "g"),
27
+ ]));
28
+ const EXPORT_REPLACE_PATTERNS = new Map(HTTP_METHODS.map((method) => [
29
+ `HTTP_${method}`,
30
+ // eslint-disable-next-line security/detect-non-literal-regexp
31
+ new RegExp(`(export\\s+const\\s+${method}\\s*:\\s*)HTTP_${method}(\\b)`, "g"),
32
+ ]));
16
33
  /**
17
34
  * Converts an OpenAPI path to a file system path
18
35
  * e.g., "/hello/{name}" -> "hello/{name}"
@@ -71,9 +88,7 @@ async function buildTypeNameMapping(specification) {
71
88
  * @param content - The file content
72
89
  */
73
90
  function needsMigration(content) {
74
- const methodAlternation = HTTP_METHODS.map((method) => method.toUpperCase()).join("|");
75
- const pattern = new RegExp(`import\\s+type\\s+\\{[^}]*HTTP_(?:${methodAlternation})[^}]*\\}`, "iu");
76
- return pattern.test(content);
91
+ return NEEDS_MIGRATION_REGEX.test(content);
77
92
  }
78
93
  /**
79
94
  * Updates a single route file with the correct type names
@@ -102,7 +117,7 @@ async function updateRouteFile(filePath, methodToTypeName) {
102
117
  .filter((t) => t.length > 0);
103
118
  for (const importedType of importedTypes) {
104
119
  // Check if this is an HTTP_ type
105
- const httpMethodMatch = importedType.match(new RegExp(`^HTTP_(?<method>${HTTP_METHODS.join("|")})$`, "u"));
120
+ const httpMethodMatch = importedType.match(HTTP_TYPE_NAME_REGEX);
106
121
  if (httpMethodMatch) {
107
122
  const method = httpMethodMatch.groups?.["method"] ?? "";
108
123
  const newTypeName = methodToTypeName.get(method);
@@ -120,15 +135,20 @@ async function updateRouteFile(filePath, methodToTypeName) {
120
135
  // Apply replacements
121
136
  for (const [oldName, newName] of replacements.entries()) {
122
137
  // Replace in import statement
123
- const importPattern = new RegExp(`(import\\s+type\\s+\\{[^}]*\\b)${oldName}(\\b[^}]*\\}\\s+from)`, "g");
124
- content = content.replace(importPattern, `$1${newName}$2`);
138
+ const importPattern = IMPORT_REPLACE_PATTERNS.get(oldName);
139
+ if (importPattern) {
140
+ importPattern.lastIndex = 0;
141
+ content = content.replace(importPattern, `$1${newName}$2`);
142
+ }
125
143
  // Replace in export statement (e.g., "export const GET: HTTP_GET")
126
144
  // Match the method from the old type name
127
- const methodMatch = oldName.match(new RegExp(`^HTTP_(?<method>${HTTP_METHODS.join("|")})$`, "u"));
145
+ const methodMatch = oldName.match(HTTP_TYPE_NAME_REGEX);
128
146
  if (methodMatch) {
129
- const method = methodMatch.groups?.["method"] ?? "";
130
- const exportPattern = new RegExp(`(export\\s+const\\s+${method}\\s*:\\s*)${oldName}(\\b)`, "g");
131
- content = content.replace(exportPattern, `$1${newName}$2`);
147
+ const exportPattern = EXPORT_REPLACE_PATTERNS.get(oldName);
148
+ if (exportPattern) {
149
+ exportPattern.lastIndex = 0;
150
+ content = content.replace(exportPattern, `$1${newName}$2`);
151
+ }
132
152
  }
133
153
  modified = true;
134
154
  }
@@ -11,8 +11,8 @@ const colors = {
11
11
  blue: "\x1b[34m",
12
12
  };
13
13
  function isLikelyJson(headersBlock, body) {
14
- const m = headersBlock.match(/^content-type:\s*([^\r\n;]+)/im);
15
- const ct = (m?.[1] ?? "").toLowerCase();
14
+ const m = headersBlock.match(/^content-type:\s*(?<contentType>[^\r\n;]+)/im);
15
+ const ct = (m?.groups?.["contentType"] ?? "").toLowerCase();
16
16
  if (ct.includes("application/json") || ct.includes("+json"))
17
17
  return true;
18
18
  const s = body.trim();
@@ -30,7 +30,7 @@ function highlightJson(text) {
30
30
  return text;
31
31
  }
32
32
  const pretty = JSON.stringify(obj, null, 2);
33
- return pretty.replace(/("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g, (match, str, colon, boolOrNull) => {
33
+ return pretty.replace(/(?<str>"(?:\\.|[^"\\])*")(?<colon>\s*:)?|\b(?<boolOrNull>true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g, (match, str, colon, boolOrNull) => {
34
34
  if (str) {
35
35
  if (colon)
36
36
  return `${colors.blue}${str}${colors.reset}${colon}`;
@@ -60,31 +60,31 @@ export class RawHttpClient {
60
60
  this.port = port;
61
61
  }
62
62
  get(path, headers = {}) {
63
- this.#send("GET", path, "", headers);
63
+ return this.#send("GET", path, "", headers);
64
64
  }
65
65
  head(path, headers = {}) {
66
- this.#send("HEAD", path, "", headers);
66
+ return this.#send("HEAD", path, "", headers);
67
67
  }
68
68
  post(path, body = "", headers = {}) {
69
- this.#send("POST", path, body, headers);
69
+ return this.#send("POST", path, body, headers);
70
70
  }
71
71
  put(path, body = "", headers = {}) {
72
- this.#send("PUT", path, body, headers);
72
+ return this.#send("PUT", path, body, headers);
73
73
  }
74
74
  delete(path, headers = {}) {
75
- this.#send("DELETE", path, "", headers);
75
+ return this.#send("DELETE", path, "", headers);
76
76
  }
77
77
  connect(path, headers = {}) {
78
- this.#send("CONNECT", path, "", headers);
78
+ return this.#send("CONNECT", path, "", headers);
79
79
  }
80
80
  options(path, headers = {}) {
81
- this.#send("OPTIONS", path, "", headers);
81
+ return this.#send("OPTIONS", path, "", headers);
82
82
  }
83
83
  trace(path, headers = {}) {
84
- this.#send("TRACE", path, "", headers);
84
+ return this.#send("TRACE", path, "", headers);
85
85
  }
86
86
  patch(path, body = "", headers = {}) {
87
- this.#send("PATCH", path, body, headers);
87
+ return this.#send("PATCH", path, body, headers);
88
88
  }
89
89
  #send(method, path, bodyAsStringOrObject, headers) {
90
90
  const requestNumber = ++this.requestNumber;
@@ -146,9 +146,9 @@ export class RawHttpClient {
146
146
  const lines = head.split("\r\n");
147
147
  const statusLine = lines[0] ?? "";
148
148
  let statusColor = colors.green;
149
- const match = statusLine.match(/HTTP\/\d+\.\d+\s+(\d+)/);
149
+ const match = statusLine.match(/HTTP\/\d+\.\d+\s+(?<statusCode>\d+)/);
150
150
  if (match) {
151
- const code = Number(match[1]);
151
+ const code = Number(match.groups?.["statusCode"]);
152
152
  if (code >= 400)
153
153
  statusColor = colors.red;
154
154
  else if (code >= 300)
package/dist/repl/repl.js CHANGED
@@ -1,11 +1,31 @@
1
1
  import repl from "node:repl";
2
2
  import { RawHttpClient } from "./raw-http-client.js";
3
+ import { createRouteFunction } from "./route-builder.js";
3
4
  function printToStdout(line) {
4
5
  process.stdout.write(`${line}\n`);
5
6
  }
7
+ const ROUTE_BUILDER_METHODS = [
8
+ "body(",
9
+ "headers(",
10
+ "help(",
11
+ "method(",
12
+ "missing(",
13
+ "path(",
14
+ "query(",
15
+ "ready(",
16
+ "send(",
17
+ ];
6
18
  export function createCompleter(registry, fallback) {
7
19
  return (line, callback) => {
8
- const match = line.match(/client\.(?:get|post|put|patch|delete)\("(?<partial>[^"]*)$/u);
20
+ // Check for RouteBuilder method completion: route("..."). or chained calls
21
+ const builderMatch = line.match(/route\(.*\)\.(?<partial>[a-zA-Z]*)$/u);
22
+ if (builderMatch) {
23
+ const partial = builderMatch.groups?.["partial"] ?? "";
24
+ const matches = ROUTE_BUILDER_METHODS.filter((m) => m.startsWith(partial));
25
+ callback(null, [matches, partial]);
26
+ return;
27
+ }
28
+ const match = line.match(/(?:client\.(?:get|post|put|patch|delete)|route)\("(?<partial>[^"]*)$/u);
9
29
  if (!match) {
10
30
  if (fallback) {
11
31
  fallback(line, callback);
@@ -21,7 +41,7 @@ export function createCompleter(registry, fallback) {
21
41
  callback(null, [matches, partial]);
22
42
  };
23
43
  }
24
- export function startRepl(contextRegistry, registry, config, print = printToStdout) {
44
+ export function startRepl(contextRegistry, registry, config, print = printToStdout, openApiDocument) {
25
45
  function printProxyStatus() {
26
46
  if (config.proxyUrl === "") {
27
47
  print("The proxy URL is not set.");
@@ -73,6 +93,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
73
93
  print("");
74
94
  print("- loadContext('/some/path'): to access the context object for a given path");
75
95
  print("- context: the root context ( same as loadContext('/') )");
96
+ print("- route('/some/path'): create a request builder for the given path");
76
97
  print("");
77
98
  print("For more information, see https://counterfact.dev/docs/usage.html");
78
99
  print("");
@@ -107,5 +128,6 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
107
128
  replServer.context.context = replServer.context.loadContext("/");
108
129
  replServer.context.client = new RawHttpClient("localhost", config.port);
109
130
  replServer.context.RawHttpClient = RawHttpClient;
131
+ replServer.context.route = createRouteFunction(config.port, "localhost", openApiDocument);
110
132
  return replServer;
111
133
  }