counterfact 2.7.0 → 2.9.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 (57) hide show
  1. package/README.md +5 -160
  2. package/bin/README.md +39 -14
  3. package/bin/counterfact.js +18 -539
  4. package/bin/ts-loader.mjs +1 -0
  5. package/dist/api-runner.js +202 -0
  6. package/dist/app.js +102 -114
  7. package/dist/cli/banner.js +81 -0
  8. package/dist/cli/check-for-updates.js +45 -0
  9. package/dist/cli/run.js +304 -0
  10. package/dist/cli/telemetry.js +50 -0
  11. package/dist/migrate/paths-to-routes.js +1 -0
  12. package/dist/migrate/update-route-types.js +3 -3
  13. package/dist/msw.js +78 -0
  14. package/dist/repl/raw-http-client.js +22 -1
  15. package/dist/repl/repl.js +250 -63
  16. package/dist/repl/route-builder.js +68 -0
  17. package/dist/server/constants.js +8 -0
  18. package/dist/server/context-registry.js +54 -1
  19. package/dist/server/determine-module-kind.js +14 -0
  20. package/dist/server/dispatcher.js +46 -0
  21. package/dist/server/file-discovery.js +21 -9
  22. package/dist/server/is-proxy-enabled-for-path.js +12 -0
  23. package/dist/server/json-to-xml.js +10 -0
  24. package/dist/server/load-openapi-document.js +4 -11
  25. package/dist/server/module-dependency-graph.js +25 -0
  26. package/dist/server/module-loader.js +52 -21
  27. package/dist/server/module-tree.js +36 -0
  28. package/dist/server/openapi-document.js +69 -0
  29. package/dist/server/registry.js +89 -0
  30. package/dist/server/response-builder.js +15 -0
  31. package/dist/server/scenario-registry.js +26 -0
  32. package/dist/server/tools.js +27 -0
  33. package/dist/server/transpiler.js +24 -9
  34. package/dist/server/{admin-api-middleware.js → web-server/admin-api-middleware.js} +19 -9
  35. package/dist/server/web-server/create-koa-app.js +68 -0
  36. package/dist/server/web-server/openapi-middleware.js +34 -0
  37. package/dist/server/{koa-middleware.js → web-server/routes-middleware.js} +26 -6
  38. package/dist/typescript-generator/code-generator.js +118 -4
  39. package/dist/typescript-generator/coder.js +76 -0
  40. package/dist/typescript-generator/operation-coder.js +12 -4
  41. package/dist/typescript-generator/operation-type-coder.js +39 -4
  42. package/dist/typescript-generator/parameters-type-coder.js +2 -4
  43. package/dist/typescript-generator/prune.js +3 -1
  44. package/dist/typescript-generator/repository.js +77 -20
  45. package/dist/typescript-generator/requirement.js +69 -0
  46. package/dist/typescript-generator/{generate.js → scenario-file-generator.js} +99 -81
  47. package/dist/typescript-generator/script.js +70 -7
  48. package/dist/typescript-generator/specification.js +27 -0
  49. package/dist/util/ensure-directory-exists.js +8 -0
  50. package/dist/util/forward-slash-path.js +63 -0
  51. package/dist/util/load-config-file.js +2 -2
  52. package/dist/util/read-file.js +27 -2
  53. package/dist/util/runtime-can-execute-erasable-ts.js +12 -0
  54. package/dist/util/windows-escape.js +18 -0
  55. package/package.json +5 -4
  56. package/dist/server/create-koa-app.js +0 -42
  57. package/dist/server/openapi-middleware.js +0 -19
@@ -1,6 +1,16 @@
1
- import nodePath from "node:path";
1
+ import { pathJoin } from "../util/forward-slash-path.js";
2
2
  import { Coder } from "./coder.js";
3
3
  import { OperationTypeCoder, } from "./operation-type-coder.js";
4
+ /**
5
+ * Generates the default route handler stub for a single OpenAPI operation.
6
+ *
7
+ * The generated stub calls `$.response[statusCode].random()` (or `.empty()`)
8
+ * for the first response defined in the spec. It is only written when no
9
+ * handler file exists yet — users are expected to replace it with real logic.
10
+ *
11
+ * The corresponding TypeScript type is emitted by {@link OperationTypeCoder}
12
+ * into the `types/paths/…` tree.
13
+ */
4
14
  export class OperationCoder extends Coder {
5
15
  requestMethod;
6
16
  securitySchemes;
@@ -38,8 +48,6 @@ export class OperationCoder extends Coder {
38
48
  .split("/")
39
49
  .at(-2)
40
50
  .replaceAll("~1", "/");
41
- return `${nodePath
42
- .join("routes", pathString)
43
- .replaceAll("\\", "/")}.types.ts`;
51
+ return `${pathJoin("routes", pathString)}.types.ts`;
44
52
  }
45
53
  }
@@ -1,4 +1,4 @@
1
- import nodePath from "node:path";
1
+ import { pathJoin } from "../util/forward-slash-path.js";
2
2
  import { CONTEXT_FILE_TOKEN } from "./context-file-token.js";
3
3
  import { buildJsDoc } from "./jsdoc.js";
4
4
  import { ParameterExportTypeCoder } from "./parameter-export-type-coder.js";
@@ -24,6 +24,15 @@ function sanitizeIdentifier(value) {
24
24
  }
25
25
  return result || "_";
26
26
  }
27
+ /**
28
+ * Generates the TypeScript type for a single OpenAPI operation.
29
+ *
30
+ * The emitted type describes the function signature that a Counterfact route
31
+ * handler must satisfy, including strongly-typed `query`, `path`, `headers`,
32
+ * `cookie`, `body`, `context`, `response`, and `user` arguments.
33
+ *
34
+ * Output is written to `types/paths/<route>.types.ts`.
35
+ */
27
36
  export class OperationTypeCoder extends TypeCoder {
28
37
  requestMethod;
29
38
  securitySchemes;
@@ -35,6 +44,10 @@ export class OperationTypeCoder extends TypeCoder {
35
44
  this.requestMethod = requestMethod;
36
45
  this.securitySchemes = securitySchemes;
37
46
  }
47
+ /**
48
+ * Returns the base identifier for this operation, derived from its
49
+ * `operationId` (sanitised) or falling back to `HTTP_<METHOD>`.
50
+ */
38
51
  getOperationBaseName() {
39
52
  const operationId = this.requirement.get("operationId")?.data;
40
53
  return operationId
@@ -47,6 +60,19 @@ export class OperationTypeCoder extends TypeCoder {
47
60
  names() {
48
61
  return super.names(this.getOperationBaseName());
49
62
  }
63
+ /**
64
+ * Generates and exports a named parameter type (e.g. `ListPets_Query`) from
65
+ * `modulePath` and returns the exported type name.
66
+ *
67
+ * Returns `"never"` without creating an export when `inlineType` is
68
+ * `"never"`.
69
+ *
70
+ * @param script - The script being assembled.
71
+ * @param parameterKind - `"query"`, `"path"`, `"headers"`, or `"cookie"`.
72
+ * @param inlineType - The inline TypeScript type string to export.
73
+ * @param baseName - The base identifier prefix for the exported type name.
74
+ * @param modulePath - The repository-relative path of the type file.
75
+ */
50
76
  exportParameterType(script, parameterKind, inlineType, baseName, modulePath) {
51
77
  if (inlineType === "never") {
52
78
  return "never";
@@ -57,6 +83,11 @@ export class OperationTypeCoder extends TypeCoder {
57
83
  coder._modulePath = modulePath;
58
84
  return script.export(coder, true);
59
85
  }
86
+ /**
87
+ * Returns the union of all possible response type shapes for this operation.
88
+ *
89
+ * @param script - The script being assembled (used to resolve imports).
90
+ */
60
91
  responseTypes(script) {
61
92
  return this.requirement
62
93
  .get("responses")
@@ -96,10 +127,14 @@ export class OperationTypeCoder extends TypeCoder {
96
127
  .split("/")
97
128
  .at(-2)
98
129
  .replaceAll("~1", "/");
99
- return `${nodePath
100
- .join("types/paths", pathString === "/" ? "/index" : pathString)
101
- .replaceAll("\\", "/")}.types.ts`;
130
+ return `${pathJoin("types/paths", pathString === "/" ? "/index" : pathString)}.types.ts`;
102
131
  }
132
+ /**
133
+ * Returns the TypeScript type for the `user` argument.
134
+ *
135
+ * When the operation is protected by HTTP Basic auth, the type is
136
+ * `{username?: string, password?: string}`. Otherwise it is `"never"`.
137
+ */
103
138
  userType() {
104
139
  if (this.securitySchemes.some(({ scheme, type }) => type === "http" && scheme === "basic")) {
105
140
  return "{username?: string, password?: string}";
@@ -1,4 +1,4 @@
1
- import nodePath from "node:path";
1
+ import { pathJoin } from "../util/forward-slash-path.js";
2
2
  import { buildJsDoc } from "./jsdoc.js";
3
3
  import { SchemaTypeCoder } from "./schema-type-coder.js";
4
4
  import { TypeCoder } from "./type-coder.js";
@@ -39,8 +39,6 @@ export class ParametersTypeCoder extends TypeCoder {
39
39
  .split("/")
40
40
  .at(-2)
41
41
  .replaceAll("~1", "/");
42
- return `${nodePath
43
- .join("parameters", pathString)
44
- .replaceAll("\\", "/")}.types.ts`;
42
+ return `${pathJoin("parameters", pathString)}.types.ts`;
45
43
  }
46
44
  }
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import nodePath from "node:path";
3
+ /* eslint-disable security/detect-non-literal-fs-filename -- pruning only traverses and removes files under destination/routes. */
3
4
  import createDebug from "debug";
5
+ import { toForwardSlashPath } from "../util/forward-slash-path.js";
4
6
  const debug = createDebug("counterfact:typescript-generator:prune");
5
7
  /**
6
8
  * Collects all .ts route files in a directory recursively.
@@ -87,7 +89,7 @@ export async function pruneRoutes(destination, openApiPaths) {
87
89
  debug("actual route files: %o", actualFiles);
88
90
  let prunedCount = 0;
89
91
  for (const file of actualFiles) {
90
- const normalizedFile = file.replaceAll("\\", "/");
92
+ const normalizedFile = toForwardSlashPath(file);
91
93
  if (!expectedFiles.has(normalizedFile)) {
92
94
  const fullPath = nodePath.join(routesDir, file);
93
95
  debug("pruning %s", fullPath);
@@ -2,19 +2,36 @@ import { existsSync } from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import nodePath, { dirname } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ /* eslint-disable security/detect-non-literal-fs-filename -- repository writes and stats generated files only inside destination output directories. */
5
6
  import createDebug from "debug";
6
7
  import { ensureDirectoryExists } from "../util/ensure-directory-exists.js";
8
+ import { toForwardSlashPath, pathJoin, pathRelative, pathDirname, } from "../util/forward-slash-path.js";
7
9
  import { CONTEXT_FILE_TOKEN } from "./context-file-token.js";
8
10
  import { Script } from "./script.js";
9
11
  import { escapePathForWindows } from "../util/windows-escape.js";
10
12
  const debug = createDebug("counterfact:server:repository");
11
- const __dirname = dirname(fileURLToPath(import.meta.url)).replaceAll("\\", "/");
13
+ const __dirname = toForwardSlashPath(dirname(fileURLToPath(import.meta.url)));
12
14
  debug("dirname is %s", __dirname);
15
+ /**
16
+ * Collection of {@link Script} objects keyed by their repository-relative
17
+ * path.
18
+ *
19
+ * Coders call {@link get} to obtain (or create) the script where they should
20
+ * export their generated TypeScript. After all coders have been registered,
21
+ * {@link writeFiles} waits for every script to finish and writes the output to
22
+ * disk.
23
+ */
13
24
  export class Repository {
14
25
  scripts;
15
26
  constructor() {
16
27
  this.scripts = new Map();
17
28
  }
29
+ /**
30
+ * Returns the {@link Script} for `path`, creating it if it does not yet
31
+ * exist.
32
+ *
33
+ * @param path - Repository-relative path (e.g. `"routes/pets.ts"`).
34
+ */
18
35
  get(path) {
19
36
  debug("getting script at %s", path);
20
37
  if (this.scripts.has(path)) {
@@ -26,12 +43,22 @@ export class Repository {
26
43
  this.scripts.set(path, script);
27
44
  return script;
28
45
  }
46
+ /** Waits until all scripts have resolved all of their pending export promises. */
29
47
  async finished() {
30
48
  while (Array.from(this.scripts.values()).some((script) => script.isInProgress())) {
31
49
  debug("waiting for %i scripts to finish", this.scripts.size);
32
50
  await Promise.all(Array.from(this.scripts.values(), (script) => script.finished()));
33
51
  }
34
52
  }
53
+ /**
54
+ * Copies the compiled `counterfact-types` directory from the Counterfact
55
+ * distribution into the generated output tree.
56
+ *
57
+ * Returns `false` when the source directory does not exist (e.g. running
58
+ * from source without a prior build).
59
+ *
60
+ * @param destination - The root of the generated output tree.
61
+ */
35
62
  async copyCoreFiles(destination) {
36
63
  const sourcePath = nodePath.join(__dirname, "../../dist/server/counterfact-types");
37
64
  const destinationPath = nodePath.join(destination, "counterfact-types");
@@ -40,13 +67,23 @@ export class Repository {
40
67
  }
41
68
  return fs.cp(sourcePath, destinationPath, { recursive: true });
42
69
  }
70
+ /**
71
+ * Waits for all scripts to finish, then writes each one to disk.
72
+ *
73
+ * Route files (`routes/…`) are never overwritten if they already exist on
74
+ * disk, preserving user edits. Type files (`types/…`) are always
75
+ * overwritten.
76
+ *
77
+ * @param destination - Absolute path to the output root directory.
78
+ * @param options - Controls which artefacts are written.
79
+ */
43
80
  async writeFiles(destination, { routes, types }) {
44
81
  debug("waiting for %i or more scripts to finish before writing files", this.scripts.size);
45
82
  await this.finished();
46
83
  debug("all %i scripts are finished", this.scripts.size);
47
84
  const writeFiles = Array.from(this.scripts.entries(), async ([path, script]) => {
48
85
  const contents = await script.contents();
49
- const fullPath = escapePathForWindows(nodePath.join(destination, path).replaceAll("\\", "/"));
86
+ const fullPath = escapePathForWindows(pathJoin(destination, path));
50
87
  await ensureDirectoryExists(fullPath);
51
88
  const shouldWriteRoutes = routes && path.startsWith("routes");
52
89
  const shouldWriteTypes = types && !path.startsWith("routes");
@@ -70,37 +107,57 @@ export class Repository {
70
107
  await this.createDefaultContextFile(destination);
71
108
  }
72
109
  }
110
+ /**
111
+ * Creates the default `routes/_.context.ts` file if it does not already
112
+ * exist.
113
+ *
114
+ * @param destination - Absolute path to the output root directory.
115
+ */
73
116
  async createDefaultContextFile(destination) {
74
117
  const contextFilePath = nodePath.join(destination, "routes", "_.context.ts");
75
118
  if (existsSync(contextFilePath)) {
76
119
  return;
77
120
  }
78
121
  await ensureDirectoryExists(contextFilePath);
79
- await fs.writeFile(contextFilePath, `/**
80
- * This is the default context for Counterfact.
81
- *
82
- * It defines the context object in the REPL
83
- * and the $.context object in the code.
84
- *
85
- * Add properties and methods to suit your needs.
86
- *
87
- * See https://counterfact.dev/docs/usage.html#working-with-state-the-codecontextcode-object-and-codecontexttscode
88
- */
89
- export class Context {
122
+ await fs.writeFile(contextFilePath, `import type { Context$ } from "../types/_.context.js";
123
+
124
+ /**
125
+ * This is the default context for Counterfact.
126
+ *
127
+ * It defines the context object in the REPL
128
+ * and the $.context object in the code.
129
+ *
130
+ * Add properties and methods to suit your needs.
131
+ *
132
+ * See https://github.com/counterfact/api-simulator/blob/main/docs/features/state.md
133
+ */
90
134
 
135
+ export class Context {
136
+ constructor($: Context$) {
137
+ void $;
138
+ }
91
139
  }
92
140
  `);
93
141
  }
142
+ /**
143
+ * Returns the path of the `_.context.ts` file that is nearest to `path` in
144
+ * the directory hierarchy, relative to the script's output directory.
145
+ *
146
+ * @param destination - Output root directory.
147
+ * @param path - Repository-relative path of the script being generated.
148
+ */
94
149
  findContextPath(destination, path) {
95
- return nodePath
96
- .relative(nodePath.join(destination, nodePath.dirname(path)), this.nearestContextFile(destination, path))
97
- .replaceAll("\\", "/");
150
+ return pathRelative(nodePath.join(destination, nodePath.dirname(path)), this.nearestContextFile(destination, path));
98
151
  }
152
+ /**
153
+ * Walks up the directory tree from `path` to find the nearest
154
+ * `_.context.ts` file, falling back to `routes/_.context.ts` at the root.
155
+ *
156
+ * @param destination - Output root directory.
157
+ * @param path - Repository-relative path to start from.
158
+ */
99
159
  nearestContextFile(destination, path) {
100
- const directory = nodePath
101
- .dirname(path)
102
- .replaceAll("\\", "/")
103
- .replace("types/paths", "routes");
160
+ const directory = pathDirname(path).replace("types/paths", "routes");
104
161
  const candidate = nodePath.join(destination, directory, "_.context.ts");
105
162
  if (directory.length <= 1) {
106
163
  // No _context.ts was found so import the one that should be in the root
@@ -1,3 +1,11 @@
1
+ /**
2
+ * A node in the dereferenced OpenAPI spec tree.
3
+ *
4
+ * A `Requirement` wraps a raw JSON object (`data`) together with its location
5
+ * (`url`, a JSON Pointer) and a back-reference to the owning
6
+ * {@link Specification}. Navigation methods (`get`, `select`, `find`, …)
7
+ * transparently follow `$ref` pointers.
8
+ */
1
9
  export class Requirement {
2
10
  data;
3
11
  url;
@@ -7,18 +15,36 @@ export class Requirement {
7
15
  this.url = url;
8
16
  this.specification = specification;
9
17
  }
18
+ /** `true` when this node is a JSON Reference (`$ref`) rather than inline data. */
10
19
  get isReference() {
11
20
  return this.data["$ref"] !== undefined;
12
21
  }
22
+ /**
23
+ * Resolves the `$ref` and returns the target {@link Requirement}.
24
+ *
25
+ * @throws When `isReference` is `false` or the specification is not set.
26
+ */
13
27
  reference() {
14
28
  return this.specification.getRequirement(this.data["$ref"]);
15
29
  }
30
+ /**
31
+ * Returns `true` when this node has a child property named `item`.
32
+ *
33
+ * Transparently follows `$ref` references.
34
+ *
35
+ * @param item - The property key to check.
36
+ */
16
37
  has(item) {
17
38
  if (this.isReference) {
18
39
  return this.reference().has(item);
19
40
  }
20
41
  return item in this.data;
21
42
  }
43
+ /**
44
+ * Returns the child {@link Requirement} for `item`, or `undefined`.
45
+ *
46
+ * @param item - The property key (string) or array index (number).
47
+ */
22
48
  get(item) {
23
49
  if (this.isReference) {
24
50
  return this.reference().get(item);
@@ -29,6 +55,16 @@ export class Requirement {
29
55
  }
30
56
  return new Requirement(this.data[key], `${this.url}/${this.escapeJsonPointer(key)}`, this.specification);
31
57
  }
58
+ /**
59
+ * Navigates to a descendant node using a slash-delimited JSON Pointer path.
60
+ *
61
+ * Tilde-escaped characters (`~0` → `~`, `~1` → `/`) and percent-encoded
62
+ * characters are unescaped during traversal.
63
+ *
64
+ * @param path - A slash-delimited path (e.g. `"responses/200/content"`).
65
+ * @returns The target {@link Requirement}, or `undefined` if the path does
66
+ * not exist.
67
+ */
32
68
  select(path) {
33
69
  const parts = path
34
70
  .split("/")
@@ -54,19 +90,42 @@ export class Requirement {
54
90
  }
55
91
  return result;
56
92
  }
93
+ /**
94
+ * Iterates over all child properties and calls `callback` with each child
95
+ * requirement and its key.
96
+ *
97
+ * @param callback - Called for each child with `(child, key)`.
98
+ */
57
99
  forEach(callback) {
58
100
  Object.keys(this.data).forEach((key) => {
59
101
  callback(this.select(this.escapeJsonPointer(key)), key);
60
102
  });
61
103
  }
104
+ /**
105
+ * Maps over all child properties and returns the collected results.
106
+ *
107
+ * @param callback - Transformation function called with `(child, key)`.
108
+ */
62
109
  map(callback) {
63
110
  const result = [];
64
111
  this.forEach((value, key) => result.push(callback(value, key)));
65
112
  return result;
66
113
  }
114
+ /**
115
+ * Maps and flattens over all child properties.
116
+ *
117
+ * @param callback - Transformation function that may return a value or an
118
+ * array of values.
119
+ */
67
120
  flatMap(callback) {
68
121
  return this.map(callback).flat();
69
122
  }
123
+ /**
124
+ * Returns the first child for which `callback` returns `true`, or
125
+ * `undefined` when nothing matches.
126
+ *
127
+ * @param callback - Predicate called with `(child, key)`.
128
+ */
70
129
  find(callback) {
71
130
  let result;
72
131
  this.forEach((value, key) => {
@@ -76,11 +135,21 @@ export class Requirement {
76
135
  });
77
136
  return result;
78
137
  }
138
+ /**
139
+ * Escapes a JSON Pointer token: `~` → `~0`, `/` → `~1`.
140
+ *
141
+ * @param value - The token to escape.
142
+ */
79
143
  escapeJsonPointer(value) {
80
144
  if (typeof value !== "string")
81
145
  return value;
82
146
  return value.replaceAll("~", "~0").replaceAll("/", "~1");
83
147
  }
148
+ /**
149
+ * Unescapes a JSON Pointer token: `~1` → `/`, `~0` → `~`.
150
+ *
151
+ * @param pointer - The token to unescape.
152
+ */
84
153
  unescapeJsonPointer(pointer) {
85
154
  if (typeof pointer !== "string")
86
155
  return pointer;
@@ -1,70 +1,11 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import nodePath from "node:path";
4
- import createDebug from "debug";
5
- import { ensureDirectoryExists } from "../util/ensure-directory-exists.js";
6
- import { OperationCoder } from "./operation-coder.js";
7
- import { pruneRoutes } from "./prune.js";
8
- import { Repository } from "./repository.js";
9
- import { Specification } from "./specification.js";
10
- const debug = createDebug("counterfact:typescript-generator:generate");
11
- async function buildCacheDirectory(destination) {
12
- const gitignorePath = nodePath.join(destination, ".gitignore");
13
- const cacheReadmePath = nodePath.join(destination, ".cache", "README.md");
14
- debug("ensuring the directory containing .gitgnore exists");
15
- await ensureDirectoryExists(gitignorePath);
16
- debug("creating the .gitignore file if it doesn't already exist");
17
- if (!existsSync(gitignorePath)) {
18
- await fs.writeFile(gitignorePath, ".cache\n", "utf8");
19
- }
20
- debug("creating the .cache/README.md file");
21
- ensureDirectoryExists(cacheReadmePath);
22
- await fs.writeFile(cacheReadmePath, "This directory contains compiled JS files from the paths directory. Do not edit these files directly.\n", "utf8");
23
- }
24
- async function getPathsFromSpecification(specification) {
25
- try {
26
- return specification.getRequirement("#/paths") ?? new Set();
27
- }
28
- catch (error) {
29
- process.stderr.write(`Could not find #/paths in the specification.\n${error}\n`);
30
- return undefined;
31
- }
32
- }
33
- export async function generate(source, destination, generateOptions, repository = new Repository()) {
34
- debug("generating code from %s to %s", source, destination);
35
- debug("initializing the .cache directory");
36
- await buildCacheDirectory(destination);
37
- debug("done initializing the .cache directory");
38
- debug("creating specification from %s", source);
39
- const specification = await Specification.fromFile(source);
40
- debug("created specification: $o", specification);
41
- debug("reading the #/paths from the specification");
42
- const paths = await getPathsFromSpecification(specification);
43
- debug("got %i paths", paths?.map?.length ?? 0);
44
- if (generateOptions.prune && generateOptions.routes) {
45
- debug("pruning defunct route files");
46
- await pruneRoutes(destination, paths.map((_v, key) => key));
47
- debug("done pruning");
48
- }
49
- const securityRequirement = specification.getRequirement("#/components/securitySchemes");
50
- const securitySchemes = Object.values(securityRequirement?.data ?? {});
51
- paths.forEach((pathDefinition, key) => {
52
- debug("processing path %s", key);
53
- const path = key === "/" ? "/index" : key;
54
- pathDefinition.forEach((operation, requestMethod) => {
55
- repository
56
- .get(`routes${path}.ts`)
57
- .export(new OperationCoder(operation, requestMethod, securitySchemes));
58
- });
59
- });
60
- debug("telling the repository to write the files to %s", destination);
61
- await repository.writeFiles(destination, generateOptions);
62
- debug("finished writing the files");
63
- if (generateOptions.types) {
64
- await writeApplyContextType(destination);
65
- await writeDefaultScenariosIndex(destination);
66
- }
67
- }
4
+ /* eslint-disable security/detect-non-literal-fs-filename -- scenario files are discovered and generated under the configured destination tree. */
5
+ import { watch } from "chokidar";
6
+ import { CHOKIDAR_OPTIONS } from "../server/constants.js";
7
+ import { pathRelative } from "../util/forward-slash-path.js";
8
+ import { waitForEvent } from "../util/wait-for-event.js";
68
9
  async function collectContextFiles(destination) {
69
10
  const routesDir = nodePath.join(destination, "routes");
70
11
  const results = [];
@@ -88,9 +29,7 @@ async function walkForContextFiles(routesDir, currentDir, results) {
88
29
  await walkForContextFiles(routesDir, nodePath.join(currentDir, entry.name), results);
89
30
  }
90
31
  else if (entry.name === "_.context.ts") {
91
- const relDir = nodePath
92
- .relative(routesDir, currentDir)
93
- .replaceAll("\\", "/");
32
+ const relDir = pathRelative(routesDir, currentDir);
94
33
  const routePath = relDir === "" ? "/" : `/${relDir}`;
95
34
  const depth = relDir === "" ? 0 : relDir.split("/").length;
96
35
  const importPath = relDir === "" ? "../routes/_.context" : `../routes/${relDir}/_.context`;
@@ -128,7 +67,7 @@ function buildLoadContextOverload(routePath, alias) {
128
67
  .join("/")}`;
129
68
  return ` loadContext(path: \`${templatePath}\`): ${alias};`;
130
69
  }
131
- function buildApplyContextContent(contextFiles) {
70
+ function buildScenarioContextContent(contextFiles) {
132
71
  const rootContext = contextFiles.find((f) => f.routePath === "/");
133
72
  const contextType = rootContext
134
73
  ? rootContext.alias
@@ -141,38 +80,60 @@ function buildApplyContextContent(contextFiles) {
141
80
  "// This file is generated by Counterfact. Do not edit manually.",
142
81
  ...importLines,
143
82
  "",
144
- "export interface ApplyContext {",
145
- ' /** Root context, same as loadContext("/") */',
146
- ` context: ${contextType};`,
147
- " /** Load a context object for a specific path */",
83
+ "interface LoadContextDefinitions {",
84
+ " /* code generator adds additional signatures here */",
148
85
  ...overloadLines,
149
86
  " loadContext(path: string): Record<string, unknown>;",
87
+ "}",
88
+ "",
89
+ "export interface Scenario$ {",
90
+ ' /** Root context, same as loadContext("/") */',
91
+ ` readonly context: ${contextType};`,
92
+ ' readonly loadContext: LoadContextDefinitions["loadContext"];',
150
93
  " /** Named route builders stored in the REPL execution context */",
151
- " routes: Record<string, unknown>;",
94
+ " readonly routes: Record<string, unknown>;",
152
95
  " /** Create a new route builder for a given path */",
153
- " route: (path: string) => unknown;",
96
+ " readonly route: (path: string) => unknown;",
154
97
  "}",
155
98
  "",
156
99
  "/** A scenario function that receives the live REPL environment */",
157
- "export type Scenario = ($: ApplyContext) => Promise<void> | void;",
100
+ "export type Scenario = ($: Scenario$) => Promise<void> | void;",
101
+ "",
102
+ "/** Interface for Context objects defined in _.context.ts files */",
103
+ "export interface Context$ {",
104
+ " /** Load a context object for a specific path */",
105
+ ' readonly loadContext: LoadContextDefinitions["loadContext"];',
106
+ " /** Load a JSON file relative to this file's path */",
107
+ " readonly readJson: (relativePath: string) => Promise<unknown>;",
108
+ "}",
158
109
  "",
159
110
  ];
160
111
  return parts.join("\n");
161
112
  }
162
- export async function writeApplyContextType(destination) {
113
+ /**
114
+ * Writes the `types/_.context.ts` file, which exports the
115
+ * `Scenario$` interface used to type scenario functions.
116
+ *
117
+ * The interface is generated from all `_.context.ts` files found under the
118
+ * `routes/` directory, providing strongly typed `loadContext()` overloads for
119
+ * every route path that has a context file.
120
+ *
121
+ * @param destination - Root output directory.
122
+ */
123
+ async function writeScenarioContextType(destination) {
163
124
  const typesDir = nodePath.join(destination, "types");
164
- const filePath = nodePath.join(typesDir, "scenario-context.ts");
125
+ const filePath = nodePath.join(typesDir, "_.context.ts");
165
126
  const contextFiles = await collectContextFiles(destination);
166
- const content = buildApplyContextContent(contextFiles);
127
+ const content = buildScenarioContextContent(contextFiles);
167
128
  await fs.mkdir(typesDir, { recursive: true });
168
129
  await fs.writeFile(filePath, content, "utf8");
169
130
  }
170
- const DEFAULT_SCENARIOS_INDEX = `import type { Scenario } from "../types/scenario-context.js";
131
+ const DEFAULT_SCENARIOS_INDEX = `import type { Scenario } from "../types/_.context.js";
171
132
 
172
133
  /**
173
134
  * Scenario scripts are plain TypeScript functions that receive the live REPL
174
135
  * environment and can read or mutate server state. Run them from the REPL with:
175
- * .apply <functionName>
136
+ * .scenario <functionName>
176
137
  */
177
138
 
178
139
  /**
@@ -186,9 +147,24 @@ const DEFAULT_SCENARIOS_INDEX = `import type { Scenario } from "../types/scenari
186
147
  * $.routes.myRequest = $.route("/pets").method("get");
187
148
  */
188
149
 
150
+ /**
151
+ * startup() runs automatically when the server initializes, right before the
152
+ * REPL starts. Use it to seed dummy data so the server is ready to use
153
+ * immediately. It receives the same $ argument as all other scenario functions.
154
+ *
155
+ * Tip: delegate to other scenario functions and pass $ along so each function
156
+ * stays focused on a single concern. You can also pass additional arguments to
157
+ * configure them, e.g. addPets($, 20, "dog").
158
+ *
159
+ * If you don't need a startup scenario, delete this function or leave it empty.
160
+ */
161
+ export const startup: Scenario = ($) => {
162
+ void $;
163
+ };
164
+
189
165
  /**
190
166
  * An example scenario. To use it in the REPL, type:
191
- * .apply help
167
+ * .scenario help
192
168
  */
193
169
  export const help: Scenario = ($) => {
194
170
  void $;
@@ -216,3 +192,45 @@ async function writeDefaultScenariosIndex(destination) {
216
192
  await fs.mkdir(scenariosDir, { recursive: true });
217
193
  await fs.writeFile(filePath, DEFAULT_SCENARIOS_INDEX, "utf8");
218
194
  }
195
+ /**
196
+ * Encapsulates the generation of scenario-related files:
197
+ * - `types/_.context.ts` — the typed `Scenario$` interface derived from all
198
+ * `_.context.ts` files found under `routes/`.
199
+ * - `scenarios/index.ts` — the default scenarios entry-point (created only if
200
+ * it does not already exist).
201
+ *
202
+ * When {@link watch} is called, a file-system watcher monitors the `routes/`
203
+ * directory for changes to `_.context.ts` files and automatically regenerates
204
+ * `types/_.context.ts`.
205
+ */
206
+ export class ScenarioFileGenerator {
207
+ destination;
208
+ watcher;
209
+ constructor(destination) {
210
+ this.destination = destination;
211
+ }
212
+ /** Generates both scenario-related files once and resolves when complete. */
213
+ async generate() {
214
+ await writeScenarioContextType(this.destination);
215
+ await writeDefaultScenariosIndex(this.destination);
216
+ }
217
+ /**
218
+ * Starts watching the `routes/` directory for `_.context.ts` changes and
219
+ * regenerates `types/_.context.ts` on every change.
220
+ *
221
+ * Resolves once the watcher is ready.
222
+ */
223
+ async watch() {
224
+ const routesDir = nodePath.join(this.destination, "routes");
225
+ this.watcher = watch(routesDir, CHOKIDAR_OPTIONS).on("all", (_event, filePath) => {
226
+ if (filePath.endsWith("_.context.ts")) {
227
+ void writeScenarioContextType(this.destination);
228
+ }
229
+ });
230
+ await waitForEvent(this.watcher, "ready");
231
+ }
232
+ /** Closes the file-system watcher. */
233
+ async stopWatching() {
234
+ await this.watcher?.close();
235
+ }
236
+ }