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,70 +1,10 @@
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
+ import { watch } from "chokidar";
5
+ import { CHOKIDAR_OPTIONS } from "../server/constants.js";
6
+ import { pathRelative } from "../util/forward-slash-path.js";
7
+ import { waitForEvent } from "../util/wait-for-event.js";
68
8
  async function collectContextFiles(destination) {
69
9
  const routesDir = nodePath.join(destination, "routes");
70
10
  const results = [];
@@ -88,9 +28,7 @@ async function walkForContextFiles(routesDir, currentDir, results) {
88
28
  await walkForContextFiles(routesDir, nodePath.join(currentDir, entry.name), results);
89
29
  }
90
30
  else if (entry.name === "_.context.ts") {
91
- const relDir = nodePath
92
- .relative(routesDir, currentDir)
93
- .replaceAll("\\", "/");
31
+ const relDir = pathRelative(routesDir, currentDir);
94
32
  const routePath = relDir === "" ? "/" : `/${relDir}`;
95
33
  const depth = relDir === "" ? 0 : relDir.split("/").length;
96
34
  const importPath = relDir === "" ? "../routes/_.context" : `../routes/${relDir}/_.context`;
@@ -128,7 +66,7 @@ function buildLoadContextOverload(routePath, alias) {
128
66
  .join("/")}`;
129
67
  return ` loadContext(path: \`${templatePath}\`): ${alias};`;
130
68
  }
131
- function buildApplyContextContent(contextFiles) {
69
+ function buildScenarioContextContent(contextFiles) {
132
70
  const rootContext = contextFiles.find((f) => f.routePath === "/");
133
71
  const contextType = rootContext
134
72
  ? rootContext.alias
@@ -141,38 +79,60 @@ function buildApplyContextContent(contextFiles) {
141
79
  "// This file is generated by Counterfact. Do not edit manually.",
142
80
  ...importLines,
143
81
  "",
144
- "export interface ApplyContext {",
145
- ' /** Root context, same as loadContext("/") */',
146
- ` context: ${contextType};`,
147
- " /** Load a context object for a specific path */",
82
+ "interface LoadContextDefinitions {",
83
+ " /* code generator adds additional signatures here */",
148
84
  ...overloadLines,
149
85
  " loadContext(path: string): Record<string, unknown>;",
86
+ "}",
87
+ "",
88
+ "export interface Scenario$ {",
89
+ ' /** Root context, same as loadContext("/") */',
90
+ ` readonly context: ${contextType};`,
91
+ ' readonly loadContext: LoadContextDefinitions["loadContext"];',
150
92
  " /** Named route builders stored in the REPL execution context */",
151
- " routes: Record<string, unknown>;",
93
+ " readonly routes: Record<string, unknown>;",
152
94
  " /** Create a new route builder for a given path */",
153
- " route: (path: string) => unknown;",
95
+ " readonly route: (path: string) => unknown;",
154
96
  "}",
155
97
  "",
156
98
  "/** A scenario function that receives the live REPL environment */",
157
- "export type Scenario = ($: ApplyContext) => Promise<void> | void;",
99
+ "export type Scenario = ($: Scenario$) => Promise<void> | void;",
100
+ "",
101
+ "/** Interface for Context objects defined in _.context.ts files */",
102
+ "export interface Context$ {",
103
+ " /** Load a context object for a specific path */",
104
+ ' readonly loadContext: LoadContextDefinitions["loadContext"];',
105
+ " /** Load a JSON file relative to this file's path */",
106
+ " readonly readJson: (relativePath: string) => Promise<unknown>;",
107
+ "}",
158
108
  "",
159
109
  ];
160
110
  return parts.join("\n");
161
111
  }
162
- export async function writeApplyContextType(destination) {
112
+ /**
113
+ * Writes the `types/_.context.ts` file, which exports the
114
+ * `Scenario$` interface used to type scenario functions.
115
+ *
116
+ * The interface is generated from all `_.context.ts` files found under the
117
+ * `routes/` directory, providing strongly typed `loadContext()` overloads for
118
+ * every route path that has a context file.
119
+ *
120
+ * @param destination - Root output directory.
121
+ */
122
+ async function writeScenarioContextType(destination) {
163
123
  const typesDir = nodePath.join(destination, "types");
164
- const filePath = nodePath.join(typesDir, "scenario-context.ts");
124
+ const filePath = nodePath.join(typesDir, "_.context.ts");
165
125
  const contextFiles = await collectContextFiles(destination);
166
- const content = buildApplyContextContent(contextFiles);
126
+ const content = buildScenarioContextContent(contextFiles);
167
127
  await fs.mkdir(typesDir, { recursive: true });
168
128
  await fs.writeFile(filePath, content, "utf8");
169
129
  }
170
- const DEFAULT_SCENARIOS_INDEX = `import type { Scenario } from "../types/scenario-context.js";
130
+ const DEFAULT_SCENARIOS_INDEX = `import type { Scenario } from "../types/_.context.js";
171
131
 
172
132
  /**
173
133
  * Scenario scripts are plain TypeScript functions that receive the live REPL
174
134
  * environment and can read or mutate server state. Run them from the REPL with:
175
- * .apply <functionName>
135
+ * .scenario <functionName>
176
136
  */
177
137
 
178
138
  /**
@@ -186,9 +146,24 @@ const DEFAULT_SCENARIOS_INDEX = `import type { Scenario } from "../types/scenari
186
146
  * $.routes.myRequest = $.route("/pets").method("get");
187
147
  */
188
148
 
149
+ /**
150
+ * startup() runs automatically when the server initializes, right before the
151
+ * REPL starts. Use it to seed dummy data so the server is ready to use
152
+ * immediately. It receives the same $ argument as all other scenario functions.
153
+ *
154
+ * Tip: delegate to other scenario functions and pass $ along so each function
155
+ * stays focused on a single concern. You can also pass additional arguments to
156
+ * configure them, e.g. addPets($, 20, "dog").
157
+ *
158
+ * If you don't need a startup scenario, delete this function or leave it empty.
159
+ */
160
+ export const startup: Scenario = ($) => {
161
+ void $;
162
+ };
163
+
189
164
  /**
190
165
  * An example scenario. To use it in the REPL, type:
191
- * .apply help
166
+ * .scenario help
192
167
  */
193
168
  export const help: Scenario = ($) => {
194
169
  void $;
@@ -216,3 +191,45 @@ async function writeDefaultScenariosIndex(destination) {
216
191
  await fs.mkdir(scenariosDir, { recursive: true });
217
192
  await fs.writeFile(filePath, DEFAULT_SCENARIOS_INDEX, "utf8");
218
193
  }
194
+ /**
195
+ * Encapsulates the generation of scenario-related files:
196
+ * - `types/_.context.ts` — the typed `Scenario$` interface derived from all
197
+ * `_.context.ts` files found under `routes/`.
198
+ * - `scenarios/index.ts` — the default scenarios entry-point (created only if
199
+ * it does not already exist).
200
+ *
201
+ * When {@link watch} is called, a file-system watcher monitors the `routes/`
202
+ * directory for changes to `_.context.ts` files and automatically regenerates
203
+ * `types/_.context.ts`.
204
+ */
205
+ export class ScenarioFileGenerator {
206
+ destination;
207
+ watcher;
208
+ constructor(destination) {
209
+ this.destination = destination;
210
+ }
211
+ /** Generates both scenario-related files once and resolves when complete. */
212
+ async generate() {
213
+ await writeScenarioContextType(this.destination);
214
+ await writeDefaultScenariosIndex(this.destination);
215
+ }
216
+ /**
217
+ * Starts watching the `routes/` directory for `_.context.ts` changes and
218
+ * regenerates `types/_.context.ts` on every change.
219
+ *
220
+ * Resolves once the watcher is ready.
221
+ */
222
+ async watch() {
223
+ const routesDir = nodePath.join(this.destination, "routes");
224
+ this.watcher = watch(routesDir, CHOKIDAR_OPTIONS).on("all", (_event, filePath) => {
225
+ if (filePath.endsWith("_.context.ts")) {
226
+ void writeScenarioContextType(this.destination);
227
+ }
228
+ });
229
+ await waitForEvent(this.watcher, "ready");
230
+ }
231
+ /** Closes the file-system watcher. */
232
+ async stopWatching() {
233
+ await this.watcher?.close();
234
+ }
235
+ }
@@ -1,8 +1,18 @@
1
- import nodePath from "node:path";
2
1
  import createDebugger from "debug";
3
2
  import { format } from "prettier";
4
3
  import { escapePathForWindows } from "../util/windows-escape.js";
4
+ import { pathJoin, pathRelative, pathDirname, } from "../util/forward-slash-path.js";
5
5
  const debug = createDebugger("counterfact:typescript-generator:script");
6
+ /**
7
+ * Represents a single TypeScript file being assembled by the code generator.
8
+ *
9
+ * A `Script` accumulates exports, imports, and external imports contributed by
10
+ * {@link Coder} instances. Once all coders have resolved, {@link contents}
11
+ * formats the result with Prettier and returns the final source text.
12
+ *
13
+ * Scripts are created and retrieved through a {@link Repository} so that the
14
+ * same module path always maps to the same `Script` instance.
15
+ */
6
16
  export class Script {
7
17
  repository;
8
18
  comments;
@@ -22,6 +32,10 @@ export class Script {
22
32
  this.typeCache = new Map();
23
33
  this.path = path;
24
34
  }
35
+ /**
36
+ * A `"../"` path fragment that points from this script's directory back to
37
+ * the repository root, used to resolve relative import paths.
38
+ */
25
39
  get relativePathToBase() {
26
40
  return this.path
27
41
  .split("/")
@@ -29,6 +43,13 @@ export class Script {
29
43
  .map(() => "..")
30
44
  .join("/");
31
45
  }
46
+ /**
47
+ * Picks the first name from `coder.names()` that is not already used as an
48
+ * import in this script, ensuring export/import name uniqueness.
49
+ *
50
+ * @param coder - The coder needing a name.
51
+ * @throws When all 100 candidate names are already taken.
52
+ */
32
53
  firstUniqueName(coder) {
33
54
  for (const name of coder.names()) {
34
55
  if (!this.imports.has(name)) {
@@ -37,6 +58,17 @@ export class Script {
37
58
  }
38
59
  throw new Error(`could not find a unique name for ${coder.id}`);
39
60
  }
61
+ /**
62
+ * Registers an export for `coder` in this script and returns the export name.
63
+ *
64
+ * If the same coder has already been exported (cache hit), the previously
65
+ * assigned name is returned without creating a duplicate.
66
+ *
67
+ * @param coder - The coder to export.
68
+ * @param isType - Emit `export type` instead of `export const`.
69
+ * @param isDefault - Emit `export default` instead of a named export.
70
+ * @returns The name under which the coder is exported.
71
+ */
40
72
  export(coder, isType = false, isDefault = false) {
41
73
  const cacheKey = isDefault ? "default" : `${coder.id}:${isType}`;
42
74
  if (this.cache.has(cacheKey)) {
@@ -75,6 +107,16 @@ export class Script {
75
107
  exportDefault(coder, isType = false) {
76
108
  this.export(coder, isType, true);
77
109
  }
110
+ /**
111
+ * Registers an import of `coder` from its owning module and returns the
112
+ * local alias used in this script.
113
+ *
114
+ * The coder is also exported from its home module as a side effect.
115
+ *
116
+ * @param coder - The coder to import.
117
+ * @param isType - Use a `import type` declaration.
118
+ * @param isDefault - Import the default export rather than a named export.
119
+ */
78
120
  import(coder, isType = false, isDefault = false) {
79
121
  debug("import coder: %s", coder.id);
80
122
  const modulePath = coder.modulePath();
@@ -103,24 +145,42 @@ export class Script {
103
145
  importDefault(coder, isType = false) {
104
146
  return this.import(coder, isType, true);
105
147
  }
148
+ /**
149
+ * Registers an import from an external npm package or absolute module path
150
+ * (not managed by the repository).
151
+ *
152
+ * @param name - The local binding name.
153
+ * @param modulePath - The module specifier (e.g. `"counterfact-types/index"`).
154
+ * @param isType - Use a `import type` declaration.
155
+ * @returns `name` (for convenience in method chaining).
156
+ */
106
157
  importExternal(name, modulePath, isType = false) {
107
158
  this.externalImport.set(name, { isType, modulePath });
108
159
  return name;
109
160
  }
161
+ /**
162
+ * Convenience wrapper that calls {@link importExternal} with `isType = true`.
163
+ */
110
164
  importExternalType(name, modulePath) {
111
165
  return this.importExternal(name, modulePath, true);
112
166
  }
167
+ /**
168
+ * Imports a type from the shared `counterfact-types/index.ts` module,
169
+ * resolving the path relative to this script's location in the repository.
170
+ *
171
+ * @param name - The type name to import (e.g. `"WideOperationArgument"`).
172
+ */
113
173
  importSharedType(name) {
114
- return this.importExternal(name, nodePath
115
- .join(this.relativePathToBase, "counterfact-types/index.ts")
116
- .replaceAll("\\", "/"), true);
174
+ return this.importExternal(name, pathJoin(this.relativePathToBase, "counterfact-types/index.ts"), true);
117
175
  }
118
176
  exportType(coder) {
119
177
  return this.export(coder, true);
120
178
  }
179
+ /** `true` while at least one export promise is still pending. */
121
180
  isInProgress() {
122
181
  return Array.from(this.exports.values()).some((exportStatement) => !exportStatement.done);
123
182
  }
183
+ /** Returns a promise that resolves when all pending export promises settle. */
124
184
  finished() {
125
185
  return Promise.all(Array.from(this.exports.values(), (value) => value.promise));
126
186
  }
@@ -129,9 +189,7 @@ export class Script {
129
189
  }
130
190
  importStatements() {
131
191
  return Array.from(this.imports, ([name, { isDefault, isType, script }]) => {
132
- const resolvedPath = escapePathForWindows(nodePath
133
- .relative(nodePath.dirname(this.path).replaceAll("\\", "/"), script.path.replace(/\.ts$/u, ".js"))
134
- .replaceAll("\\", "/"));
192
+ const resolvedPath = escapePathForWindows(pathRelative(pathDirname(this.path), script.path.replace(/\.ts$/u, ".js")));
135
193
  return `import${isType ? " type" : ""} ${isDefault ? name : `{ ${name} }`} from "${resolvedPath.includes("../") ? "" : "./"}${resolvedPath}";`;
136
194
  });
137
195
  }
@@ -150,6 +208,11 @@ export class Script {
150
208
  return `${jsdoc}${beforeExport}export ${keyword} ${name ?? ""}${typeAnnotation} = ${code};`;
151
209
  });
152
210
  }
211
+ /**
212
+ * Formats the fully assembled script source with Prettier and returns it.
213
+ *
214
+ * All pending export promises are awaited before formatting.
215
+ */
153
216
  contents() {
154
217
  return format([
155
218
  this.comments.map((comment) => `// ${comment}`).join("\n"),
@@ -2,6 +2,14 @@ import { bundle } from "@apidevtools/json-schema-ref-parser";
2
2
  import createDebug from "debug";
3
3
  import { Requirement } from "./requirement.js";
4
4
  const debug = createDebug("counterfact:typescript-generator:specification");
5
+ /**
6
+ * Represents a fully dereferenced OpenAPI specification as a navigable tree
7
+ * of {@link Requirement} nodes.
8
+ *
9
+ * Use {@link Specification.fromFile} to load a spec from disk or a URL; the
10
+ * static method bundles external `$ref` references into a single in-memory
11
+ * object before constructing the tree.
12
+ */
5
13
  export class Specification {
6
14
  cache;
7
15
  rootRequirement;
@@ -11,15 +19,34 @@ export class Specification {
11
19
  this.rootRequirement = rootRequirement;
12
20
  }
13
21
  }
22
+ /**
23
+ * Loads the OpenAPI document at `urlOrPath`, bundles all external `$ref`
24
+ * references, and returns a fully initialised {@link Specification}.
25
+ *
26
+ * @param urlOrPath - A local file path or HTTP(S) URL.
27
+ * @throws When the document cannot be found or parsed.
28
+ */
14
29
  static async fromFile(urlOrPath) {
15
30
  const specification = new Specification();
16
31
  await specification.load(urlOrPath);
17
32
  return specification;
18
33
  }
34
+ /**
35
+ * Returns the {@link Requirement} at `url` (a JSON Pointer such as
36
+ * `"#/paths"`).
37
+ *
38
+ * @param url - A JSON Pointer string (must start with `"#/"`).
39
+ */
19
40
  getRequirement(url) {
20
41
  debug("getting requirement at %s", url);
21
42
  return this.rootRequirement.select(url.slice(2));
22
43
  }
44
+ /**
45
+ * Loads (or reloads) the specification from `urlOrPath`.
46
+ *
47
+ * @param urlOrPath - A local file path or HTTP(S) URL.
48
+ * @throws When the document cannot be found or parsed.
49
+ */
23
50
  async load(urlOrPath) {
24
51
  try {
25
52
  this.rootRequirement = new Requirement((await bundle(urlOrPath)), urlOrPath, this);
@@ -1,5 +1,12 @@
1
1
  import fs from "node:fs";
2
2
  import nodePath from "node:path";
3
+ /**
4
+ * Synchronously ensures that the directory containing `filePath` exists,
5
+ * creating it (and any missing ancestors) if necessary.
6
+ *
7
+ * @param filePath - Path to a file; the *directory* part of this path is
8
+ * created, not the file itself.
9
+ */
3
10
  export function ensureDirectoryExists(filePath) {
4
11
  const directory = nodePath.dirname(filePath);
5
12
  try {
@@ -0,0 +1,63 @@
1
+ import nodePath from "node:path";
2
+ /**
3
+ * Converts a file-system path to use forward slashes as separators.
4
+ *
5
+ * On Windows, `node:path` methods such as `path.join` and `path.resolve`
6
+ * return paths with backslash separators. Many parts of Counterfact
7
+ * (chokidar watchers, ES module import specifiers, URL routing) require
8
+ * forward slashes. This function centralises that normalisation and returns
9
+ * a {@link ForwardSlashPath} branded type so that call sites that demand a
10
+ * forward-slash path are statically enforced.
11
+ *
12
+ * @param path - Any file-system path string.
13
+ * @returns The same path with every `\` replaced by `/`.
14
+ */
15
+ export function toForwardSlashPath(path) {
16
+ return path.replaceAll("\\", "/");
17
+ }
18
+ /**
19
+ * Joins path segments and returns a {@link ForwardSlashPath} with forward
20
+ * slashes regardless of the host operating system.
21
+ *
22
+ * Equivalent to `toForwardSlashPath(nodePath.join(...paths))`.
23
+ *
24
+ * @param paths - Path segments to join.
25
+ * @returns The joined path normalised to forward slashes.
26
+ */
27
+ export function pathJoin(...paths) {
28
+ return toForwardSlashPath(nodePath.join(...paths));
29
+ }
30
+ /**
31
+ * Returns the relative path from `from` to `to` using forward slashes.
32
+ *
33
+ * Equivalent to `toForwardSlashPath(nodePath.relative(from, to))`.
34
+ *
35
+ * @param from - The starting path.
36
+ * @param to - The destination path.
37
+ * @returns The relative path normalised to forward slashes.
38
+ */
39
+ export function pathRelative(from, to) {
40
+ return toForwardSlashPath(nodePath.relative(from, to));
41
+ }
42
+ /**
43
+ * Returns the directory portion of a path using forward slashes.
44
+ *
45
+ * Equivalent to `toForwardSlashPath(nodePath.dirname(path))`.
46
+ *
47
+ * @param path - The file path.
48
+ * @returns The directory portion normalised to forward slashes.
49
+ */
50
+ export function pathDirname(path) {
51
+ return toForwardSlashPath(nodePath.dirname(path));
52
+ }
53
+ /**
54
+ * Resolves a sequence of paths into an absolute path using forward slashes.
55
+ *
56
+ * Equivalent to `toForwardSlashPath(nodePath.resolve(...paths))`.
57
+ *
58
+ * @param paths - Path segments to resolve.
59
+ * @returns The resolved absolute path normalised to forward slashes.
60
+ */
61
+ export function pathResolve(...paths) {
62
+ return toForwardSlashPath(nodePath.resolve(...paths));
63
+ }
@@ -1,5 +1,16 @@
1
1
  import fs from "node:fs/promises";
2
2
  import nodeFetch from "node-fetch";
3
+ /**
4
+ * Reads the content of a file or URL and returns it as a UTF-8 string.
5
+ *
6
+ * Accepts three kinds of inputs:
7
+ * - **HTTP(S) URLs** — fetches with `node-fetch`.
8
+ * - **`file://` URLs** — reads via the Node.js `fs` module.
9
+ * - **File system paths** — reads via the Node.js `fs` module.
10
+ *
11
+ * @param urlOrPath - A URL string or file-system path.
12
+ * @returns The file contents as a string.
13
+ */
3
14
  export async function readFile(urlOrPath) {
4
15
  if (urlOrPath.startsWith("http")) {
5
16
  const response = await nodeFetch(urlOrPath);
@@ -2,6 +2,17 @@ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
+ /**
6
+ * Probes the current Node.js runtime to determine whether it can execute
7
+ * TypeScript source files directly (via `--experimental-strip-types` or
8
+ * equivalent).
9
+ *
10
+ * The check works by writing a tiny TypeScript module to a temporary directory
11
+ * and attempting to import it. If the import succeeds and returns the
12
+ * expected value, the runtime supports native TypeScript execution.
13
+ *
14
+ * @returns `true` when the runtime can execute `.ts` files natively.
15
+ */
5
16
  export async function runtimeCanExecuteErasableTs() {
6
17
  const dir = mkdtempSync(join(tmpdir(), "ts-probe-"));
7
18
  // helper.ts is imported via .js extension — the TypeScript convention used
@@ -1,5 +1,16 @@
1
1
  const UNICODE_RATIO_SYMBOL = "∶"; // U+2236
2
2
  const REGULAR_COLON = ":";
3
+ /**
4
+ * Escapes a Windows absolute path for use in an ES module import specifier.
5
+ *
6
+ * On Windows, drive letters produce colons (e.g. `C:\path`) which are invalid
7
+ * in URL-like import paths. The drive separator colon and any additional
8
+ * colons in the path are replaced with the Unicode ratio symbol `∶` (U+2236),
9
+ * which is visually identical but safe in import specifiers.
10
+ *
11
+ * @param path - The file-system path to escape.
12
+ * @returns An escaped path safe for use in an import specifier.
13
+ */
3
14
  export function escapePathForWindows(path) {
4
15
  if (path.at(1) === ":") {
5
16
  return (path.slice(0, 2) +
@@ -7,6 +18,13 @@ export function escapePathForWindows(path) {
7
18
  }
8
19
  return path.replaceAll(REGULAR_COLON, UNICODE_RATIO_SYMBOL);
9
20
  }
21
+ /**
22
+ * Reverses the transformation applied by {@link escapePathForWindows},
23
+ * converting `∶` back to `:`.
24
+ *
25
+ * @param path - A previously escaped path.
26
+ * @returns The original unescaped path.
27
+ */
10
28
  export function unescapePathForWindows(path) {
11
29
  return path.replaceAll(UNICODE_RATIO_SYMBOL, REGULAR_COLON);
12
30
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "counterfact",
3
- "version": "2.7.0",
3
+ "version": "2.8.1",
4
4
  "description": "Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.",
5
5
  "type": "module",
6
6
  "main": "./dist/app.js",
@@ -21,9 +21,9 @@
21
21
  "license": "MIT",
22
22
  "repository": {
23
23
  "type": "git",
24
- "url": "git+https://github.com/pmcelhaney/counterfact.git"
24
+ "url": "git+https://github.com/counterfact/api-simulator.git"
25
25
  },
26
- "bugs": "https://github.com/pmcelhaney/counterfact/issues",
26
+ "bugs": "https://github.com/counterfact/api-simulator/issues",
27
27
  "homepage": "https://counterfact.dev",
28
28
  "funding": {
29
29
  "type": "github",
@@ -76,7 +76,7 @@
76
76
  "lint:quickfix": "eslint --fix . eslint --fix demo-ts --rule=\"import/namespace: 0,etc/no-deprecated:0,import/no-cycle:0,no-explicit-type-exports/no-explicit-type-exports:0,import/no-deprecated:0,import/no-self-import:0,import/default:0,import/no-named-as-default:0\" --ignore-pattern dist --ignore-pattern out",
77
77
  "go:petstore": "yarn build && yarn counterfact https://petstore3.swagger.io/api/v3/openapi.json out",
78
78
  "go:petstore2": "yarn build && yarn counterfact https://petstore.swagger.io/v2/swagger.json out",
79
- "go:example": "yarn build && node ./bin/counterfact.js ./openapi-example.yaml out",
79
+ "go:example": "yarn build && node ./bin/counterfact.js ./test/fixtures/openapi-example.yaml out",
80
80
  "counterfact": "./bin/counterfact.js",
81
81
  "postinstall": "patch-package"
82
82
  },