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
@@ -1,6 +1,19 @@
1
+ /**
2
+ * A context object that lives at a specific route path and is shared across
3
+ * all requests to that path (and its descendants).
4
+ *
5
+ * Route handlers receive this object as `$.context` and may freely add or
6
+ * modify properties to maintain state between requests.
7
+ */
1
8
  export class Context {
2
9
  constructor() { }
3
10
  }
11
+ /**
12
+ * Returns the parent path of a route path by stripping the last segment.
13
+ *
14
+ * @param path - A route path such as `"/pets/1"`.
15
+ * @returns The parent path (e.g. `"/pets"`), or `"/"` for top-level paths.
16
+ */
4
17
  export function parentPath(path) {
5
18
  return String(path.split("/").slice(0, -1).join("/")) || "/";
6
19
  }
@@ -29,6 +42,19 @@ function cloneForCache(value) {
29
42
  }
30
43
  return clone;
31
44
  }
45
+ /**
46
+ * Registry of per-path {@link Context} objects that persist state across
47
+ * requests.
48
+ *
49
+ * The registry is case-insensitive for path lookups and uses a write-through
50
+ * cache to detect which context properties have changed between hot-reloads.
51
+ * It extends {@link EventTarget} so that listeners can react to structural
52
+ * changes (e.g. to regenerate type files):
53
+ *
54
+ * ```ts
55
+ * contextRegistry.addEventListener("context-changed", () => { ... });
56
+ * ```
57
+ */
32
58
  export class ContextRegistry extends EventTarget {
33
59
  entries = new Map();
34
60
  cache = new Map();
@@ -46,6 +72,13 @@ export class ContextRegistry extends EventTarget {
46
72
  }
47
73
  return undefined;
48
74
  }
75
+ /**
76
+ * Registers a new context for `path`, replacing any existing one, and
77
+ * dispatches a `"context-changed"` event so listeners can react.
78
+ *
79
+ * @param path - The route path (e.g. `"/pets"`).
80
+ * @param context - The context object to store.
81
+ */
49
82
  add(path, context) {
50
83
  this.entries.set(path, context);
51
84
  this.cache.set(path, cloneForCache(context));
@@ -53,7 +86,7 @@ export class ContextRegistry extends EventTarget {
53
86
  }
54
87
  /**
55
88
  * Removes the context entry for the given path and dispatches a
56
- * "context-changed" event so that listeners (e.g. the scenario-context type
89
+ * "context-changed" event so that listeners (e.g. the _.context type
57
90
  * generator) can regenerate type files in response to the removal.
58
91
  *
59
92
  * @param path - The route path whose context entry should be deleted
@@ -65,10 +98,28 @@ export class ContextRegistry extends EventTarget {
65
98
  this.seen.delete(path);
66
99
  this.dispatchEvent(new Event("context-changed"));
67
100
  }
101
+ /**
102
+ * Finds the context for `path`, walking up the path hierarchy until a
103
+ * context is found. Falls back to `"/"` which always has a context.
104
+ *
105
+ * @param path - The route path to look up.
106
+ * @returns The nearest ancestor context (or the root context).
107
+ */
68
108
  find(path) {
69
109
  return (this.getContextIgnoreCase(this.entries, path) ??
70
110
  this.find(parentPath(path)));
71
111
  }
112
+ /**
113
+ * Merges `updatedContext` into the existing context for `path`.
114
+ *
115
+ * On the first call for a path the context is added directly. On subsequent
116
+ * calls only properties whose values differ from the cached snapshot are
117
+ * applied, preserving live mutations made by route handlers between reloads.
118
+ *
119
+ * @param path - The route path (e.g. `"/pets"`).
120
+ * @param updatedContext - The new context instance (typically freshly
121
+ * constructed from the reloaded `_.context.ts` file).
122
+ */
72
123
  update(path, updatedContext) {
73
124
  if (updatedContext === undefined) {
74
125
  return;
@@ -87,9 +138,11 @@ export class ContextRegistry extends EventTarget {
87
138
  Object.setPrototypeOf(context, Object.getPrototypeOf(updatedContext));
88
139
  this.cache.set(path, cloneForCache(updatedContext));
89
140
  }
141
+ /** Returns all registered route paths as an array. */
90
142
  getAllPaths() {
91
143
  return Array.from(this.entries.keys());
92
144
  }
145
+ /** Returns a plain object mapping every registered path to its context. */
93
146
  getAllContexts() {
94
147
  const result = {};
95
148
  for (const [path, context] of this.entries.entries()) {
@@ -3,11 +3,35 @@ import Koa from "koa";
3
3
  import bodyParser from "koa-bodyparser";
4
4
  import { koaSwagger } from "koa2-swagger-ui";
5
5
  import { adminApiMiddleware } from "./admin-api-middleware.js";
6
+ import { routesMiddleware } from "./koa-middleware.js";
6
7
  import { openapiMiddleware } from "./openapi-middleware.js";
7
8
  const debug = createDebug("counterfact:server:create-koa-app");
8
- export function createKoaApp(registry, koaMiddleware, config, contextRegistry) {
9
+ /**
10
+ * Builds and configures the Koa application with all built-in middleware.
11
+ *
12
+ * The middleware stack (in order) is:
13
+ * 1. OpenAPI document serving at `/counterfact/openapi`
14
+ * 2. Swagger UI at `/counterfact/swagger`
15
+ * 3. Admin API (when `config.startAdminApi` is `true`) at `/_counterfact/api/`
16
+ * 4. Redirect `/counterfact` → `/counterfact/swagger`
17
+ * 5. Body parser
18
+ * 6. JSON serialisation of object bodies
19
+ * 7. Route-dispatching middleware
20
+ *
21
+ * @param config - Server configuration.
22
+ * @param dispatcher - The Dispatcher used to build the route-dispatching middleware.
23
+ * @param registry - The route Registry used to build the admin API middleware.
24
+ * @param contextRegistry - The ContextRegistry used to build the admin API middleware.
25
+ * @returns A configured Koa application (not yet listening).
26
+ */
27
+ export function createKoaApp({ config, contextRegistry, dispatcher, registry, }) {
9
28
  const app = new Koa();
10
- app.use(openapiMiddleware(config.openApiPath, `//localhost:${config.port}${config.routePrefix}`));
29
+ app.use(openapiMiddleware([
30
+ {
31
+ path: config.openApiPath,
32
+ baseUrl: `//localhost:${config.port}${config.routePrefix}`,
33
+ },
34
+ ]));
11
35
  app.use(koaSwagger({
12
36
  routePrefix: "/counterfact/swagger",
13
37
  swaggerOptions: {
@@ -18,7 +42,6 @@ export function createKoaApp(registry, koaMiddleware, config, contextRegistry) {
18
42
  app.use(adminApiMiddleware(registry, contextRegistry, config));
19
43
  }
20
44
  debug("basePath: %s", config.basePath);
21
- debug("routes", registry.routes);
22
45
  app.use(async (ctx, next) => {
23
46
  if (ctx.URL.pathname === "/counterfact") {
24
47
  ctx.redirect("/counterfact/swagger");
@@ -37,6 +60,6 @@ export function createKoaApp(registry, koaMiddleware, config, contextRegistry) {
37
60
  ctx.type = "application/json";
38
61
  }
39
62
  });
40
- app.use(koaMiddleware);
63
+ app.use(routesMiddleware(dispatcher, config));
41
64
  return app;
42
65
  }
@@ -2,6 +2,19 @@ import { existsSync } from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  const DEFAULT_MODULE_KIND = "commonjs";
5
+ /**
6
+ * Determines whether a module file should be treated as CommonJS or ESM.
7
+ *
8
+ * Resolution order (matches Node.js conventions):
9
+ * 1. `.cjs` extension → `"commonjs"`.
10
+ * 2. `.mjs` or `.ts` extension → `"module"`.
11
+ * 3. Walk up the directory tree looking for a `package.json` with a `"type"`
12
+ * field.
13
+ * 4. Falls back to `"commonjs"` at the filesystem root.
14
+ *
15
+ * @param modulePath - Absolute or relative path to the module file.
16
+ * @returns `"commonjs"` or `"module"`.
17
+ */
5
18
  export async function determineModuleKind(modulePath) {
6
19
  if (modulePath.endsWith(".cjs")) {
7
20
  return "commonjs";
@@ -6,6 +6,15 @@ import { validateRequest } from "./request-validator.js";
6
6
  import { validateResponse } from "./response-validator.js";
7
7
  import { Tools } from "./tools.js";
8
8
  const debug = createDebugger("counterfact:server:dispatcher");
9
+ /**
10
+ * Parses the `Cookie` request header into a key/value map.
11
+ *
12
+ * Duplicate keys are silently dropped (first occurrence wins) and values are
13
+ * percent-decoded where possible.
14
+ *
15
+ * @param cookieHeader - The raw `Cookie` header string.
16
+ * @returns A record mapping cookie name to decoded value.
17
+ */
9
18
  function parseCookies(cookieHeader) {
10
19
  const cookies = {};
11
20
  for (const part of cookieHeader.split(";")) {
@@ -27,6 +36,16 @@ function parseCookies(cookieHeader) {
27
36
  }
28
37
  return cookies;
29
38
  }
39
+ /**
40
+ * Core HTTP request dispatcher.
41
+ *
42
+ * Receives incoming requests from the Koa middleware layer, matches them
43
+ * against the {@link Registry}, optionally validates the request and response
44
+ * against the OpenAPI spec, and invokes the matching route-handler function.
45
+ *
46
+ * Content-negotiation (Accept header handling) is performed before returning
47
+ * the response so the caller always receives the most appropriate content type.
48
+ */
30
49
  export class Dispatcher {
31
50
  registry;
32
51
  contextRegistry;
@@ -70,6 +89,14 @@ export class Dispatcher {
70
89
  }
71
90
  return undefined;
72
91
  }
92
+ /**
93
+ * Resolves the OpenAPI operation for `path` and `method`, merging any
94
+ * top-level `produces` array from the document root into the operation.
95
+ *
96
+ * @param path - The matched route path (e.g. `"/pets/{petId}"`).
97
+ * @param method - The HTTP method.
98
+ * @returns The {@link OpenApiOperation} if found, or `undefined`.
99
+ */
73
100
  operationForPathAndMethod(path, method) {
74
101
  const operation = this.findOperation(path, method);
75
102
  if (operation === undefined) {
@@ -108,6 +135,16 @@ export class Dispatcher {
108
135
  "unknown/unknown",
109
136
  };
110
137
  }
138
+ /**
139
+ * Picks the best matching content entry from a multi-type response using the
140
+ * request's `Accept` header preferences.
141
+ *
142
+ * @param acceptHeader - The value of the `Accept` request header.
143
+ * @param content - Array of `{ type, body }` objects representing all
144
+ * available content-type variants.
145
+ * @returns The first entry whose MIME type satisfies the accept preferences,
146
+ * or `undefined` when none match.
147
+ */
111
148
  selectContent(acceptHeader, content) {
112
149
  const preferredMediaTypes = mediaTypes(acceptHeader);
113
150
  for (const mediaType of preferredMediaTypes) {
@@ -132,6 +169,15 @@ export class Dispatcher {
132
169
  }
133
170
  return false;
134
171
  }
172
+ /**
173
+ * Main request handler.
174
+ *
175
+ * Orchestrates base-path stripping, route matching, request validation,
176
+ * handler invocation, content negotiation, and response validation.
177
+ *
178
+ * @param request - The incoming request descriptor.
179
+ * @returns A promise that resolves to a {@link CounterfactResponseObject}.
180
+ */
135
181
  async request({ auth, body, headers = {}, method, path, query, rawBody, req, }) {
136
182
  debug(`request: ${method} ${path}`);
137
183
  debug(`headers: ${JSON.stringify(headers)}`);
@@ -1,32 +1,43 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import fs from "node:fs/promises";
3
- import nodePath from "node:path";
3
+ import { toForwardSlashPath, pathJoin } from "../util/forward-slash-path.js";
4
4
  import { escapePathForWindows } from "../util/windows-escape.js";
5
5
  const JS_EXTENSIONS = new Set(["cjs", "cts", "js", "mjs", "mts", "ts"]);
6
+ /**
7
+ * Recursively discovers JavaScript/TypeScript source files under a base
8
+ * directory.
9
+ *
10
+ * Only files with one of the following extensions are returned:
11
+ * `js`, `mjs`, `cjs`, `ts`, `mts`, `cts`.
12
+ */
6
13
  export class FileDiscovery {
7
14
  basePath;
8
15
  constructor(basePath) {
9
- this.basePath = basePath.replaceAll("\\", "/");
16
+ this.basePath = toForwardSlashPath(basePath);
10
17
  }
18
+ /**
19
+ * Returns an array of absolute file paths for all JS/TS files found
20
+ * recursively under `basePath/directory`.
21
+ *
22
+ * @param directory - Sub-directory relative to `basePath` to start from.
23
+ * Defaults to `""` (the base path itself).
24
+ * @throws When `basePath/directory` does not exist.
25
+ */
11
26
  async findFiles(directory = "") {
12
- const fullDir = nodePath
13
- .join(this.basePath, directory)
14
- .replaceAll("\\", "/");
27
+ const fullDir = pathJoin(this.basePath, directory);
15
28
  if (!existsSync(fullDir)) {
16
29
  throw new Error(`Directory does not exist ${fullDir}`);
17
30
  }
18
31
  const entries = await fs.readdir(fullDir, { withFileTypes: true });
19
32
  const results = await Promise.all(entries.map(async (entry) => {
20
33
  if (entry.isDirectory()) {
21
- return this.findFiles(nodePath.join(directory, entry.name).replaceAll("\\", "/"));
34
+ return this.findFiles(pathJoin(directory, entry.name));
22
35
  }
23
36
  const extension = entry.name.split(".").at(-1);
24
37
  if (!JS_EXTENSIONS.has(extension ?? "")) {
25
38
  return [];
26
39
  }
27
- const fullPath = nodePath
28
- .join(this.basePath, directory, entry.name)
29
- .replaceAll("\\", "/");
40
+ const fullPath = pathJoin(this.basePath, directory, entry.name);
30
41
  return [escapePathForWindows(fullPath)];
31
42
  }));
32
43
  return results.flat();
@@ -1,3 +1,15 @@
1
+ /**
2
+ * Determines whether a given request path should be forwarded to the upstream
3
+ * proxy.
4
+ *
5
+ * The check walks up the path hierarchy until it either finds an explicit
6
+ * `proxyPaths` entry or reaches the root (in which case the proxy is
7
+ * considered disabled).
8
+ *
9
+ * @param path - The request path to check (e.g. `"/pets/1"`).
10
+ * @param config - Object containing the `proxyPaths` map.
11
+ * @returns `true` when the request should be proxied.
12
+ */
1
13
  export function isProxyEnabledForPath(path, config) {
2
14
  if (config.proxyPaths.has(path)) {
3
15
  return config.proxyPaths.get(path) ?? false;
@@ -36,6 +36,16 @@ function objectToXml(json, schema, name) {
36
36
  });
37
37
  return `<${name}${attributes.join("")}>${String(xml.join(""))}</${name}>`;
38
38
  }
39
+ /**
40
+ * Converts a JSON value to an XML string using optional OpenAPI `xml` schema
41
+ * hints (element names, attributes, wrapping).
42
+ *
43
+ * @param json - The value to serialise.
44
+ * @param schema - Optional JSON Schema with an `xml` hint block.
45
+ * @param keyName - Fallback XML element name when the schema does not provide
46
+ * one. Defaults to `"root"`.
47
+ * @returns A well-formed XML string.
48
+ */
39
49
  export function jsonToXml(json, schema, keyName = "root") {
40
50
  const name = schema?.xml?.name ?? keyName;
41
51
  if (Array.isArray(json)) {
@@ -40,7 +40,24 @@ function getAuthObject(ctx) {
40
40
  const [username, password] = user.split(":");
41
41
  return { password, username };
42
42
  }
43
- export function koaMiddleware(dispatcher, config, proxy = koaProxy) {
43
+ /**
44
+ * Builds the Koa middleware function that bridges Koa's request context with
45
+ * the Counterfact {@link Dispatcher}.
46
+ *
47
+ * Responsibilities:
48
+ * - Respects `routePrefix` — requests outside the prefix are passed to `next`.
49
+ * - Adds CORS headers to every response.
50
+ * - Handles `OPTIONS` pre-flight requests (200 with CORS headers, no body).
51
+ * - Proxies the request upstream when proxy is enabled for the path.
52
+ * - Forwards the request to the dispatcher and maps the response back onto
53
+ * the Koa context.
54
+ *
55
+ * @param dispatcher - The {@link Dispatcher} instance that handles requests.
56
+ * @param config - Server configuration (proxy settings, route prefix, etc.).
57
+ * @param proxy - Proxy factory; injectable for testing.
58
+ * @returns A Koa middleware function.
59
+ */
60
+ export function routesMiddleware(dispatcher, config, proxy = koaProxy) {
44
61
  return async function middleware(ctx, next) {
45
62
  const { proxyUrl, routePrefix } = config;
46
63
  debug("middleware running for path: %s", ctx.request.path);
@@ -1,13 +1,6 @@
1
- import { dereference } from "@apidevtools/json-schema-ref-parser";
2
- import createDebug from "debug";
3
- const debug = createDebug("counterfact:server:load-openapi-document");
1
+ import { OpenApiDocument } from "./openapi-document.js";
4
2
  export async function loadOpenApiDocument(source) {
5
- try {
6
- return (await dereference(source));
7
- }
8
- catch (error) {
9
- debug("could not load OpenAPI document from %s: %o", source, error);
10
- const details = error instanceof Error ? error.message : String(error);
11
- throw new Error(`Could not load the OpenAPI spec from "${source}".\n${details}`, { cause: error });
12
- }
3
+ const document = new OpenApiDocument(source);
4
+ await document.load();
5
+ return document;
13
6
  }
@@ -2,6 +2,14 @@ import { dirname, resolve } from "node:path";
2
2
  import createDebug from "debug";
3
3
  import precinct from "precinct";
4
4
  const debug = createDebug("counterfact:server:module-dependency-graph");
5
+ /**
6
+ * Tracks which route files depend on shared modules so that when a shared
7
+ * module changes, all dependent route files can be reloaded.
8
+ *
9
+ * Dependency edges are extracted using [precinct](https://npm.im/precinct)'s
10
+ * static analysis and are stored as a reverse map (`dependency → Set<dependent
11
+ * files>`).
12
+ */
5
13
  export class ModuleDependencyGraph {
6
14
  dependents = new Map();
7
15
  loadDependencies(path) {
@@ -18,6 +26,14 @@ export class ModuleDependencyGraph {
18
26
  group.delete(path);
19
27
  });
20
28
  }
29
+ /**
30
+ * (Re-)indexes the dependency edges for `path`, replacing any previously
31
+ * recorded edges.
32
+ *
33
+ * Only relative imports are tracked; node_modules dependencies are ignored.
34
+ *
35
+ * @param path - Absolute path of the file to analyse.
36
+ */
21
37
  load(path) {
22
38
  this.clearDependents(path);
23
39
  for (const dependency of this.loadDependencies(path)) {
@@ -31,6 +47,15 @@ export class ModuleDependencyGraph {
31
47
  this.dependents.get(key)?.add(path);
32
48
  }
33
49
  }
50
+ /**
51
+ * Returns the transitive set of files that (directly or indirectly) import
52
+ * `path`.
53
+ *
54
+ * Uses a BFS traversal so each dependent is returned exactly once.
55
+ *
56
+ * @param path - Absolute path of the changed dependency.
57
+ * @returns A `Set` of absolute paths of all dependent files.
58
+ */
34
59
  dependentsOf(path) {
35
60
  const marked = new Set();
36
61
  const dependents = new Set();
@@ -1,6 +1,6 @@
1
1
  import { once } from "node:events";
2
2
  import fs from "node:fs/promises";
3
- import nodePath, { basename, dirname } from "node:path";
3
+ import nodePath, { basename } from "node:path";
4
4
  import { watch } from "chokidar";
5
5
  import createDebug from "debug";
6
6
  import { CHOKIDAR_OPTIONS } from "./constants.js";
@@ -10,9 +10,23 @@ import { FileDiscovery } from "./file-discovery.js";
10
10
  import { isContextModule, isMiddlewareModule, } from "./middleware-detector.js";
11
11
  import { ModuleDependencyGraph } from "./module-dependency-graph.js";
12
12
  import { uncachedImport } from "./uncached-import.js";
13
+ import { toForwardSlashPath, pathDirname, pathRelative, } from "../util/forward-slash-path.js";
13
14
  import { unescapePathForWindows } from "../util/windows-escape.js";
14
15
  const { uncachedRequire } = await import("./uncached-require.cjs");
15
16
  const debug = createDebug("counterfact:server:module-loader");
17
+ /**
18
+ * Watches the compiled routes directory and dynamically loads/reloads route
19
+ * modules, context files, and middleware as files are added, changed, or
20
+ * removed.
21
+ *
22
+ * Loaded modules are registered in the {@link Registry} (route handlers) or
23
+ * the {@link ContextRegistry} (context files). An optional
24
+ * {@link ScenarioRegistry} receives scenario modules loaded from a separate
25
+ * `scenarios/` directory.
26
+ *
27
+ * Emits DOM-style events (`"add"`, `"remove"`) so callers can react to module
28
+ * lifecycle changes.
29
+ */
16
30
  export class ModuleLoader extends EventTarget {
17
31
  basePath;
18
32
  registry;
@@ -28,19 +42,29 @@ export class ModuleLoader extends EventTarget {
28
42
  };
29
43
  constructor(basePath, registry, contextRegistry = new ContextRegistry(), scenariosPath, scenarioRegistry) {
30
44
  super();
31
- this.basePath = basePath.replaceAll("\\", "/");
45
+ this.basePath = toForwardSlashPath(basePath);
32
46
  this.registry = registry;
33
47
  this.contextRegistry = contextRegistry;
34
- this.scenariosPath = scenariosPath?.replaceAll("\\", "/");
48
+ this.scenariosPath =
49
+ scenariosPath === undefined
50
+ ? undefined
51
+ : toForwardSlashPath(scenariosPath);
35
52
  this.scenarioRegistry = scenarioRegistry;
36
53
  this.fileDiscovery = new FileDiscovery(this.basePath);
37
54
  }
55
+ /**
56
+ * Starts watching the routes directory (and optionally the scenarios
57
+ * directory) for file-system changes, loading or reloading modules on
58
+ * `"add"` and `"change"` events and deregistering them on `"unlink"`.
59
+ *
60
+ * Resolves once the initial directory scan is complete.
61
+ */
38
62
  async watch() {
39
63
  this.watcher = watch(this.basePath, CHOKIDAR_OPTIONS).on("all", (eventName, pathNameOriginal) => {
40
64
  const JS_EXTENSIONS = ["js", "mjs", "cjs", "ts", "mts", "cts"];
41
65
  if (!JS_EXTENSIONS.some((extension) => pathNameOriginal.endsWith(`.${extension}`)))
42
66
  return;
43
- const pathName = pathNameOriginal.replaceAll("\\", "/");
67
+ const pathName = toForwardSlashPath(pathNameOriginal);
44
68
  if (pathName.includes("$.context") && eventName === "add") {
45
69
  process.stdout.write(`\n\n!!! The file at ${pathName} needs a minor update.\n See https://github.com/pmcelhaney/counterfact/blob/main/docs/faq.md\n\n\n`);
46
70
  return;
@@ -49,14 +73,12 @@ export class ModuleLoader extends EventTarget {
49
73
  return;
50
74
  }
51
75
  const parts = nodePath.parse(pathName.replace(this.basePath, ""));
52
- const url = unescapePathForWindows(`/${parts.dir}/${parts.name}`
53
- .replaceAll("\\", "/")
54
- .replaceAll(/\/+/gu, "/"));
76
+ const url = unescapePathForWindows(toForwardSlashPath(`/${parts.dir}/${parts.name}`).replaceAll(/\/+/gu, "/"));
55
77
  if (eventName === "unlink") {
56
78
  this.registry.remove(url);
57
79
  this.dispatchEvent(new Event("remove"));
58
80
  if (this.isContextFile(pathName)) {
59
- this.contextRegistry.remove(unescapePathForWindows(parts.dir).replaceAll("\\", "/") || "/");
81
+ this.contextRegistry.remove(unescapePathForWindows(toForwardSlashPath(parts.dir)) || "/");
60
82
  }
61
83
  return;
62
84
  }
@@ -75,7 +97,7 @@ export class ModuleLoader extends EventTarget {
75
97
  return;
76
98
  if (!["add", "change", "unlink"].includes(eventName))
77
99
  return;
78
- const pathName = pathNameOriginal.replaceAll("\\", "/");
100
+ const pathName = toForwardSlashPath(pathNameOriginal);
79
101
  if (eventName === "unlink") {
80
102
  const fileKey = this.scenarioFileKey(pathName);
81
103
  this.scenarioRegistry?.remove(fileKey);
@@ -86,6 +108,7 @@ export class ModuleLoader extends EventTarget {
86
108
  await once(this.scenariosWatcher, "ready");
87
109
  }
88
110
  }
111
+ /** Closes both file-system watchers (routes and scenarios). */
89
112
  async stopWatching() {
90
113
  await this.watcher?.close();
91
114
  await this.scenariosWatcher?.close();
@@ -93,6 +116,12 @@ export class ModuleLoader extends EventTarget {
93
116
  isContextFile(pathName) {
94
117
  return basename(pathName).startsWith("_.context.");
95
118
  }
119
+ /**
120
+ * Performs a one-shot load of all modules found under `directory` (relative
121
+ * to the configured base path) and all scenario files.
122
+ *
123
+ * @param directory - Sub-directory to load, defaults to the root (`""`).
124
+ */
96
125
  async load(directory = "") {
97
126
  const files = await this.fileDiscovery.findFiles(directory);
98
127
  await Promise.all(files.map((file) => this.loadEndpoint(file)));
@@ -115,12 +144,10 @@ export class ModuleLoader extends EventTarget {
115
144
  }
116
145
  }
117
146
  scenarioFileKey(pathName) {
118
- const normalizedScenariosPath = (this.scenariosPath ?? "").replaceAll("\\", "/");
119
- const directory = dirname(pathName.slice(normalizedScenariosPath.length)).replaceAll("\\", "/");
147
+ const normalizedScenariosPath = toForwardSlashPath(this.scenariosPath ?? "");
148
+ const directory = pathDirname(pathName.slice(normalizedScenariosPath.length));
120
149
  const name = nodePath.parse(basename(pathName)).name;
121
- const url = unescapePathForWindows(`/${nodePath.join(directory, name)}`
122
- .replaceAll("\\", "/")
123
- .replaceAll(/\/+/gu, "/"));
150
+ const url = unescapePathForWindows(toForwardSlashPath(`/${nodePath.join(directory, name)}`).replaceAll(/\/+/gu, "/"));
124
151
  return url.slice(1); // strip leading "/"
125
152
  }
126
153
  async loadScenarioFile(pathName) {
@@ -142,10 +169,8 @@ export class ModuleLoader extends EventTarget {
142
169
  }
143
170
  async loadEndpoint(pathName) {
144
171
  debug("importing module: %s", pathName);
145
- const directory = dirname(pathName.slice(this.basePath.length)).replaceAll("\\", "/");
146
- const url = unescapePathForWindows(`/${nodePath.join(directory, nodePath.parse(basename(pathName)).name)}`
147
- .replaceAll("\\", "/")
148
- .replaceAll(/\/+/gu, "/"));
172
+ const directory = pathDirname(pathName.slice(this.basePath.length));
173
+ const url = unescapePathForWindows(toForwardSlashPath(`/${nodePath.join(directory, nodePath.parse(basename(pathName)).name)}`).replaceAll(/\/+/gu, "/"));
149
174
  debug(`loading pathName from dependencyGraph: ${pathName}`);
150
175
  this.dependencyGraph.load(pathName);
151
176
  try {
@@ -159,9 +184,7 @@ export class ModuleLoader extends EventTarget {
159
184
  if (importError !== undefined) {
160
185
  const isSyntaxError = importError instanceof SyntaxError ||
161
186
  String(importError).startsWith("SyntaxError:");
162
- const displayPath = nodePath
163
- .relative(process.cwd(), unescapePathForWindows(pathName))
164
- .replaceAll("\\", "/");
187
+ const displayPath = pathRelative(process.cwd(), unescapePathForWindows(pathName));
165
188
  const message = isSyntaxError
166
189
  ? `There is a syntax error in the route file: ${displayPath}`
167
190
  : `There was an error loading the route file: ${displayPath}`;
@@ -1,6 +1,20 @@
1
1
  function isDirectory(test) {
2
2
  return test !== undefined;
3
3
  }
4
+ /**
5
+ * Trie-based tree that maps URL path segments to route-handler modules.
6
+ *
7
+ * Each node in the tree represents one URL segment. Segments whose names are
8
+ * wrapped in curly braces (e.g. `{petId}`) are treated as wildcards and can
9
+ * match any value in that position.
10
+ *
11
+ * The tree supports:
12
+ * - **Exact matches** — literal URL segments take precedence over wildcards.
13
+ * - **Wildcard matches** — `{param}` segments capture the matched value as a
14
+ * path variable.
15
+ * - **Ambiguous-wildcard detection** — when multiple wildcards exist at the
16
+ * same level the match is flagged as `ambiguous` and an error is logged.
17
+ */
4
18
  export class ModuleTree {
5
19
  root = {
6
20
  directories: new Map(),
@@ -58,6 +72,12 @@ export class ModuleTree {
58
72
  }
59
73
  }
60
74
  }
75
+ /**
76
+ * Registers a module at the given URL pattern.
77
+ *
78
+ * @param url - The route URL pattern (e.g. `"/pets/{petId}"`).
79
+ * @param module - The route-handler module to associate with the URL.
80
+ */
61
81
  add(url, module) {
62
82
  this.addModuleToDirectory(this.root, url.split("/").slice(1), module);
63
83
  }
@@ -75,6 +95,11 @@ export class ModuleTree {
75
95
  }
76
96
  this.removeModuleFromDirectory(directory.directories.get(segment.toLowerCase()), remainingSegments);
77
97
  }
98
+ /**
99
+ * Removes the module registered at `url`.
100
+ *
101
+ * @param url - The route URL pattern to deregister.
102
+ */
78
103
  remove(url) {
79
104
  const segments = url.split("/").slice(1);
80
105
  this.removeModuleFromDirectory(this.root, segments);
@@ -168,9 +193,20 @@ export class ModuleTree {
168
193
  }
169
194
  return wildcardMatches[0];
170
195
  }
196
+ /**
197
+ * Finds the best-matching module for `url` and `method`.
198
+ *
199
+ * Traverses the trie, preferring exact matches over wildcards at each
200
+ * segment. Returns `undefined` when no match is found.
201
+ *
202
+ * @param url - The incoming request URL.
203
+ * @param method - The HTTP method (used to validate wildcard matches).
204
+ * @returns A {@link Match} object, or `undefined` when nothing matches.
205
+ */
171
206
  match(url, method) {
172
207
  return this.matchWithinDirectory(this.root, url.split("/").slice(1), {}, "", method);
173
208
  }
209
+ /** Returns all registered routes sorted alphabetically by path. */
174
210
  get routes() {
175
211
  const routes = [];
176
212
  function traverse(directory, path) {