counterfact 2.6.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 (98) hide show
  1. package/README.md +14 -207
  2. package/bin/README.md +24 -4
  3. package/bin/counterfact.js +54 -3
  4. package/dist/app.js +81 -28
  5. package/dist/counterfact-types/cookie-options.js +1 -0
  6. package/dist/counterfact-types/counterfact-response.js +7 -0
  7. package/dist/counterfact-types/example-names.js +1 -0
  8. package/dist/counterfact-types/example.js +1 -0
  9. package/dist/counterfact-types/generic-response-builder.js +1 -0
  10. package/dist/counterfact-types/http-status-code.js +1 -0
  11. package/dist/counterfact-types/if-has-key.js +1 -0
  12. package/dist/counterfact-types/index.js +0 -1
  13. package/dist/counterfact-types/maybe-promise.js +1 -0
  14. package/dist/counterfact-types/media-type.js +1 -0
  15. package/dist/counterfact-types/omit-all.js +1 -0
  16. package/dist/counterfact-types/omit-value-when-never.js +1 -0
  17. package/dist/counterfact-types/open-api-content.js +1 -0
  18. package/dist/counterfact-types/open-api-operation.js +1 -0
  19. package/dist/counterfact-types/open-api-parameters.js +1 -0
  20. package/dist/counterfact-types/open-api-response.js +1 -0
  21. package/dist/counterfact-types/random-function.js +1 -0
  22. package/dist/counterfact-types/response-builder-factory.js +1 -0
  23. package/dist/counterfact-types/response-builder.js +1 -0
  24. package/dist/counterfact-types/wide-operation-argument.js +1 -0
  25. package/dist/counterfact-types/wide-response-builder.js +1 -0
  26. package/dist/migrate/update-route-types.js +2 -3
  27. package/dist/repl/raw-http-client.js +19 -0
  28. package/dist/repl/repl.js +116 -4
  29. package/dist/repl/route-builder.js +68 -0
  30. package/dist/server/constants.js +8 -0
  31. package/dist/server/context-registry.js +70 -1
  32. package/dist/server/counterfact-types/cookie-options.ts +14 -0
  33. package/dist/server/counterfact-types/counterfact-response.ts +15 -0
  34. package/dist/server/counterfact-types/example-names.ts +13 -0
  35. package/dist/server/counterfact-types/example.ts +10 -0
  36. package/dist/server/counterfact-types/generic-response-builder.ts +164 -0
  37. package/dist/server/counterfact-types/http-status-code.ts +62 -0
  38. package/dist/server/counterfact-types/if-has-key.ts +19 -0
  39. package/dist/server/counterfact-types/index.ts +20 -338
  40. package/dist/server/counterfact-types/maybe-promise.ts +6 -0
  41. package/dist/server/counterfact-types/media-type.ts +6 -0
  42. package/dist/server/counterfact-types/omit-all.ts +11 -0
  43. package/dist/server/counterfact-types/omit-value-when-never.ts +11 -0
  44. package/dist/server/counterfact-types/open-api-content.ts +8 -0
  45. package/dist/server/counterfact-types/open-api-operation.ts +36 -0
  46. package/dist/server/counterfact-types/open-api-parameters.ts +16 -0
  47. package/dist/server/counterfact-types/open-api-response.ts +22 -0
  48. package/dist/server/counterfact-types/random-function.ts +9 -0
  49. package/dist/server/counterfact-types/response-builder-factory.ts +16 -0
  50. package/dist/server/counterfact-types/response-builder.ts +31 -0
  51. package/dist/server/counterfact-types/wide-operation-argument.ts +17 -0
  52. package/dist/server/counterfact-types/wide-response-builder.ts +26 -0
  53. package/dist/server/create-koa-app.js +28 -24
  54. package/dist/server/determine-module-kind.js +13 -0
  55. package/dist/server/dispatcher.js +64 -5
  56. package/dist/server/file-discovery.js +20 -9
  57. package/dist/server/is-proxy-enabled-for-path.js +12 -0
  58. package/dist/server/json-to-xml.js +11 -1
  59. package/dist/server/koa-middleware.js +25 -2
  60. package/dist/server/load-openapi-document.js +6 -0
  61. package/dist/server/module-dependency-graph.js +25 -0
  62. package/dist/server/module-loader.js +112 -17
  63. package/dist/server/module-tree.js +36 -0
  64. package/dist/server/openapi-document.js +69 -0
  65. package/dist/server/openapi-middleware.js +34 -5
  66. package/dist/server/openapi-watcher.js +35 -0
  67. package/dist/server/registry.js +89 -0
  68. package/dist/server/request-validator.js +3 -7
  69. package/dist/server/response-builder.js +18 -0
  70. package/dist/server/response-validator.js +58 -0
  71. package/dist/server/scenario-registry.js +55 -0
  72. package/dist/server/tools.js +29 -2
  73. package/dist/server/transpiler.js +23 -9
  74. package/dist/typescript-generator/code-generator.js +117 -4
  75. package/dist/typescript-generator/coder.js +80 -2
  76. package/dist/typescript-generator/operation-coder.js +13 -5
  77. package/dist/typescript-generator/operation-type-coder.js +40 -53
  78. package/dist/typescript-generator/parameters-type-coder.js +2 -4
  79. package/dist/typescript-generator/prune.js +2 -1
  80. package/dist/typescript-generator/read-only-comments.js +1 -1
  81. package/dist/typescript-generator/repository.js +76 -20
  82. package/dist/typescript-generator/requirement.js +77 -1
  83. package/dist/typescript-generator/reserved-words.js +50 -0
  84. package/dist/typescript-generator/scenario-file-generator.js +235 -0
  85. package/dist/typescript-generator/script.js +70 -7
  86. package/dist/typescript-generator/specification.js +27 -0
  87. package/dist/util/ensure-directory-exists.js +7 -0
  88. package/dist/util/forward-slash-path.js +63 -0
  89. package/dist/util/load-config-file.js +44 -0
  90. package/dist/util/read-file.js +11 -0
  91. package/dist/util/runtime-can-execute-erasable-ts.js +11 -0
  92. package/dist/util/windows-escape.js +18 -0
  93. package/package.json +9 -10
  94. package/dist/client/README.md +0 -14
  95. package/dist/client/index.html.hbs +0 -244
  96. package/dist/client/rapi-doc.html.hbs +0 -36
  97. package/dist/server/page-middleware.js +0 -23
  98. package/dist/typescript-generator/generate.js +0 -63
@@ -51,6 +51,16 @@ function stringifyBody(body) {
51
51
  }
52
52
  return JSON.stringify(body);
53
53
  }
54
+ /**
55
+ * A minimal HTTP/1.1 client that communicates over a raw TCP socket.
56
+ *
57
+ * Used in the Counterfact REPL (`client.*`) to send requests to the local mock
58
+ * server and pretty-print the request and response to `stdout` with ANSI
59
+ * colours.
60
+ *
61
+ * Unlike `fetch` or Axios, `RawHttpClient` does not buffer or parse the
62
+ * response — the raw HTTP response string is returned from every method.
63
+ */
54
64
  export class RawHttpClient {
55
65
  host;
56
66
  port;
@@ -59,30 +69,39 @@ export class RawHttpClient {
59
69
  this.host = host;
60
70
  this.port = port;
61
71
  }
72
+ /** Sends a `GET` request and returns the raw HTTP response string. */
62
73
  get(path, headers = {}) {
63
74
  return this.#send("GET", path, "", headers);
64
75
  }
76
+ /** Sends a `HEAD` request and returns the raw HTTP response string. */
65
77
  head(path, headers = {}) {
66
78
  return this.#send("HEAD", path, "", headers);
67
79
  }
80
+ /** Sends a `POST` request with `body` and returns the raw HTTP response string. */
68
81
  post(path, body = "", headers = {}) {
69
82
  return this.#send("POST", path, body, headers);
70
83
  }
84
+ /** Sends a `PUT` request with `body` and returns the raw HTTP response string. */
71
85
  put(path, body = "", headers = {}) {
72
86
  return this.#send("PUT", path, body, headers);
73
87
  }
88
+ /** Sends a `DELETE` request and returns the raw HTTP response string. */
74
89
  delete(path, headers = {}) {
75
90
  return this.#send("DELETE", path, "", headers);
76
91
  }
92
+ /** Sends a `CONNECT` request and returns the raw HTTP response string. */
77
93
  connect(path, headers = {}) {
78
94
  return this.#send("CONNECT", path, "", headers);
79
95
  }
96
+ /** Sends an `OPTIONS` request and returns the raw HTTP response string. */
80
97
  options(path, headers = {}) {
81
98
  return this.#send("OPTIONS", path, "", headers);
82
99
  }
100
+ /** Sends a `TRACE` request and returns the raw HTTP response string. */
83
101
  trace(path, headers = {}) {
84
102
  return this.#send("TRACE", path, "", headers);
85
103
  }
104
+ /** Sends a `PATCH` request with `body` and returns the raw HTTP response string. */
86
105
  patch(path, body = "", headers = {}) {
87
106
  return this.#send("PATCH", path, body, headers);
88
107
  }
package/dist/repl/repl.js CHANGED
@@ -15,8 +15,51 @@ const ROUTE_BUILDER_METHODS = [
15
15
  "ready(",
16
16
  "send(",
17
17
  ];
18
- export function createCompleter(registry, fallback) {
18
+ /**
19
+ * Creates a tab-completion function for the REPL.
20
+ *
21
+ * @param registry - The route registry used to complete path arguments for `route()` and `client.*()` calls.
22
+ * @param fallback - Optional fallback completer (e.g. the Node.js built-in completer) invoked when no custom completion matches.
23
+ * @param scenarioRegistry - When provided, enables tab completion for `.scenario` commands by enumerating
24
+ * exported function names and file-key prefixes from the loaded scenario modules.
25
+ */
26
+ export function createCompleter(registry, fallback, scenarioRegistry) {
19
27
  return (line, callback) => {
28
+ // Check for .scenario completion: .scenario <partial>
29
+ const applyMatch = line.match(/^\.scenario\s+(?<partial>\S*)$/u);
30
+ if (applyMatch) {
31
+ const partial = applyMatch.groups?.["partial"] ?? "";
32
+ if (scenarioRegistry !== undefined) {
33
+ const slashIdx = partial.lastIndexOf("/");
34
+ if (slashIdx === -1) {
35
+ // No slash: complete exports from "index" key + top-level file prefixes
36
+ const indexFunctions = scenarioRegistry.getExportedFunctionNames("index");
37
+ const fileKeys = scenarioRegistry
38
+ .getFileKeys()
39
+ .filter((k) => k !== "index");
40
+ const topLevelPrefixes = [
41
+ ...new Set(fileKeys.map((k) => k.split("/")[0] + "/")),
42
+ ];
43
+ const allOptions = [...indexFunctions, ...topLevelPrefixes];
44
+ const matches = allOptions.filter((c) => c.startsWith(partial));
45
+ callback(null, [matches, partial]);
46
+ }
47
+ else {
48
+ // Has slash: complete exports from the named file key
49
+ const fileKey = partial.slice(0, slashIdx);
50
+ const funcPartial = partial.slice(slashIdx + 1);
51
+ const functions = scenarioRegistry.getExportedFunctionNames(fileKey);
52
+ const matches = functions
53
+ .filter((e) => e.startsWith(funcPartial))
54
+ .map((e) => `${fileKey}/${e}`);
55
+ callback(null, [matches, partial]);
56
+ }
57
+ }
58
+ else {
59
+ callback(null, [[], partial]);
60
+ }
61
+ return;
62
+ }
20
63
  // Check for RouteBuilder method completion: route("..."). or chained calls
21
64
  const builderMatch = line.match(/route\(.*\)\.(?<partial>[a-zA-Z]*)$/u);
22
65
  if (builderMatch) {
@@ -41,7 +84,26 @@ export function createCompleter(registry, fallback) {
41
84
  callback(null, [matches, partial]);
42
85
  };
43
86
  }
44
- export function startRepl(contextRegistry, registry, config, print = printToStdout, openApiDocument) {
87
+ /**
88
+ * Launches the interactive Counterfact REPL.
89
+ *
90
+ * The REPL is a standard Node.js REPL augmented with:
91
+ * - `context` / `loadContext(path)` globals wired to the {@link ContextRegistry}.
92
+ * - `client` — a {@link RawHttpClient} pre-configured for `localhost`.
93
+ * - `route(path)` — creates a {@link RouteBuilder} for the given path.
94
+ * - `.counterfact` — help command.
95
+ * - `.proxy` — proxy configuration command.
96
+ * - `.scenario` — runs a named scenario function from the scenarios directory.
97
+ *
98
+ * @param contextRegistry - The live context registry.
99
+ * @param registry - The route registry (used for tab completion).
100
+ * @param config - Server configuration.
101
+ * @param print - Output function; defaults to writing to `stdout`.
102
+ * @param openApiDocument - Optional OpenAPI document for tab completion.
103
+ * @param scenarioRegistry - Optional scenario registry for `.scenario` support.
104
+ * @returns The configured Node.js REPL server instance.
105
+ */
106
+ export function startRepl(contextRegistry, registry, config, print = printToStdout, openApiDocument, scenarioRegistry) {
45
107
  function printProxyStatus() {
46
108
  if (config.proxyUrl === "") {
47
109
  print("The proxy URL is not set.");
@@ -80,12 +142,12 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
80
142
  }
81
143
  }
82
144
  const replServer = repl.start({
83
- prompt: "⬣> ",
145
+ prompt: "\x1b[38;2;0;113;181m⬣> \x1b[0m",
84
146
  });
85
147
  const builtinCompleter = replServer.completer;
86
148
  // completer is typed as readonly in @types/node but is writable at runtime
87
149
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
88
- replServer.completer = createCompleter(registry, builtinCompleter);
150
+ replServer.completer = createCompleter(registry, builtinCompleter, scenarioRegistry);
89
151
  replServer.defineCommand("counterfact", {
90
152
  action() {
91
153
  print("This is a read-eval-print loop (REPL), the same as the one you get when you run node with no arguments.");
@@ -129,5 +191,55 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
129
191
  replServer.context.client = new RawHttpClient("localhost", config.port);
130
192
  replServer.context.RawHttpClient = RawHttpClient;
131
193
  replServer.context.route = createRouteFunction(config.port, "localhost", openApiDocument);
194
+ replServer.context.routes = {};
195
+ replServer.defineCommand("scenario", {
196
+ async action(text) {
197
+ const parts = text.trim().split("/").filter(Boolean);
198
+ if (parts.length === 0) {
199
+ print("usage: .scenario <path>");
200
+ this.clearBufferedCommand();
201
+ this.displayPrompt();
202
+ return;
203
+ }
204
+ if (parts.some((part) => part === ".." || part === ".")) {
205
+ print("Error: Path must not contain '.' or '..' segments");
206
+ this.clearBufferedCommand();
207
+ this.displayPrompt();
208
+ return;
209
+ }
210
+ const functionName = parts[parts.length - 1] ?? "";
211
+ const fileKey = parts.length === 1 ? "index" : parts.slice(0, -1).join("/");
212
+ const module = scenarioRegistry?.getModule(fileKey);
213
+ if (module === undefined) {
214
+ print(`Error: Could not find scenario file "${fileKey}"`);
215
+ this.clearBufferedCommand();
216
+ this.displayPrompt();
217
+ return;
218
+ }
219
+ const fn = module[functionName];
220
+ if (typeof fn !== "function") {
221
+ print(`Error: "${functionName}" is not a function exported from "${fileKey}"`);
222
+ this.clearBufferedCommand();
223
+ this.displayPrompt();
224
+ return;
225
+ }
226
+ try {
227
+ const applyContext = {
228
+ context: replServer.context["context"],
229
+ loadContext: replServer.context["loadContext"],
230
+ route: replServer.context["route"],
231
+ routes: replServer.context["routes"],
232
+ };
233
+ await fn(applyContext);
234
+ print(`Applied ${text.trim()}`);
235
+ }
236
+ catch (error) {
237
+ print(`Error: ${String(error)}`);
238
+ }
239
+ this.clearBufferedCommand();
240
+ this.displayPrompt();
241
+ },
242
+ help: 'apply a scenario script (".scenario <path>" calls the named export from scenarios/)',
243
+ });
132
244
  return replServer;
133
245
  }
@@ -1,4 +1,17 @@
1
1
  import { RawHttpClient } from "./raw-http-client.js";
2
+ /**
3
+ * Immutable fluent builder for constructing and sending HTTP requests from the
4
+ * Counterfact REPL.
5
+ *
6
+ * Each builder method returns a **new** `RouteBuilder` instance with the
7
+ * updated field — the original is never mutated. When all required parameters
8
+ * are set, call {@link send} to execute the request.
9
+ *
10
+ * ```ts
11
+ * // Inside the REPL:
12
+ * route("/pets/{petId}").method("get").path({ petId: 1 }).send();
13
+ * ```
14
+ */
2
15
  export class RouteBuilder {
3
16
  routePath;
4
17
  _body;
@@ -47,29 +60,63 @@ export class RouteBuilder {
47
60
  queryParams: overrides.queryParams ?? this._queryParams,
48
61
  });
49
62
  }
63
+ /**
64
+ * Returns a new builder with the HTTP method set.
65
+ *
66
+ * @param method - HTTP method name (case-insensitive, e.g. `"get"`, `"POST"`).
67
+ */
50
68
  method(method) {
51
69
  return this.clone({ method: method.toUpperCase() });
52
70
  }
71
+ /**
72
+ * Returns a new builder with additional path parameters merged in.
73
+ *
74
+ * @param params - Key/value map of path variable names to values.
75
+ */
53
76
  path(params) {
54
77
  return this.clone({ pathParams: { ...this._pathParams, ...params } });
55
78
  }
79
+ /**
80
+ * Returns a new builder with additional query parameters merged in.
81
+ *
82
+ * @param params - Key/value map of query parameter names to values.
83
+ */
56
84
  query(params) {
57
85
  return this.clone({ queryParams: { ...this._queryParams, ...params } });
58
86
  }
87
+ /**
88
+ * Returns a new builder with additional request headers merged in.
89
+ *
90
+ * @param params - Key/value map of header names to values.
91
+ */
59
92
  headers(params) {
60
93
  return this.clone({ headerParams: { ...this._headerParams, ...params } });
61
94
  }
95
+ /**
96
+ * Returns a new builder with the request body set.
97
+ *
98
+ * @param body - The request body (will be serialised to JSON or sent as-is).
99
+ */
62
100
  body(body) {
63
101
  return this.clone({ body });
64
102
  }
65
103
  getOperation() {
66
104
  return this._operation;
67
105
  }
106
+ /**
107
+ * Returns `true` when a method is set and no required parameters are
108
+ * missing.
109
+ */
68
110
  ready() {
69
111
  if (!this._method)
70
112
  return false;
71
113
  return this.missing() === undefined;
72
114
  }
115
+ /**
116
+ * Returns a {@link MissingParams} object describing all required parameters
117
+ * that have not yet been set, or `undefined` when nothing is missing (or
118
+ * when the operation has no parameters).
119
+ */
73
120
  missing() {
74
121
  const operation = this.getOperation();
75
122
  if (!operation?.parameters)
@@ -98,6 +145,10 @@ export class RouteBuilder {
98
145
  return undefined;
99
146
  return missingParams;
100
147
  }
148
+ /**
149
+ * Returns a human-readable help string describing the operation, its
150
+ * parameters, and the expected responses.
151
+ */
101
152
  help() {
102
153
  const method = this._method ?? "[no method set]";
103
154
  const operation = this.getOperation();
@@ -168,6 +219,13 @@ export class RouteBuilder {
168
219
  }
169
220
  return lines.join("\n");
170
221
  }
222
+ /**
223
+ * Executes the HTTP request and returns the parsed response body.
224
+ *
225
+ * @throws When no HTTP method has been set.
226
+ * @throws When required parameters are missing.
227
+ * @throws When an unsupported HTTP method is used.
228
+ */
171
229
  async send() {
172
230
  if (!this._method) {
173
231
  throw new Error('No HTTP method set. Use .method("get") to set the method.');
@@ -265,6 +323,16 @@ export class RouteBuilder {
265
323
  return lines.join("\n");
266
324
  }
267
325
  }
326
+ /**
327
+ * Creates a factory function that constructs a {@link RouteBuilder} for a
328
+ * given route path, pre-configured with the server's host, port, and OpenAPI
329
+ * document.
330
+ *
331
+ * @param port - The port the Counterfact server is listening on.
332
+ * @param host - The server hostname (default `"localhost"`).
333
+ * @param openApiDocument - Optional OpenAPI document for parameter introspection.
334
+ * @returns A function `(routePath: string) => RouteBuilder`.
335
+ */
268
336
  export function createRouteFunction(port, host, openApiDocument) {
269
337
  return (routePath) => new RouteBuilder(routePath, { host, openApiDocument, port });
270
338
  }
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Default options passed to every chokidar watcher in Counterfact.
3
+ *
4
+ * - `ignoreInitial: true` — suppresses the initial `"add"` events emitted for
5
+ * files already present when the watcher starts.
6
+ * - `usePolling: true` on Windows — chokidar's native FSEvents are unreliable
7
+ * on Windows; polling is more reliable there.
8
+ */
1
9
  export const CHOKIDAR_OPTIONS = {
2
10
  ignoreInitial: true,
3
11
  usePolling: process.platform === "win32",
@@ -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,11 +42,25 @@ function cloneForCache(value) {
29
42
  }
30
43
  return clone;
31
44
  }
32
- export class ContextRegistry {
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
+ */
58
+ export class ContextRegistry extends EventTarget {
33
59
  entries = new Map();
34
60
  cache = new Map();
35
61
  seen = new Set();
36
62
  constructor() {
63
+ super();
37
64
  this.add("/", {});
38
65
  }
39
66
  getContextIgnoreCase(map, key) {
@@ -45,14 +72,54 @@ export class ContextRegistry {
45
72
  }
46
73
  return undefined;
47
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
+ */
48
82
  add(path, context) {
49
83
  this.entries.set(path, context);
50
84
  this.cache.set(path, cloneForCache(context));
85
+ this.dispatchEvent(new Event("context-changed"));
86
+ }
87
+ /**
88
+ * Removes the context entry for the given path and dispatches a
89
+ * "context-changed" event so that listeners (e.g. the _.context type
90
+ * generator) can regenerate type files in response to the removal.
91
+ *
92
+ * @param path - The route path whose context entry should be deleted
93
+ * (e.g. "/pets").
94
+ */
95
+ remove(path) {
96
+ this.entries.delete(path);
97
+ this.cache.delete(path);
98
+ this.seen.delete(path);
99
+ this.dispatchEvent(new Event("context-changed"));
51
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
+ */
52
108
  find(path) {
53
109
  return (this.getContextIgnoreCase(this.entries, path) ??
54
110
  this.find(parentPath(path)));
55
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
+ */
56
123
  update(path, updatedContext) {
57
124
  if (updatedContext === undefined) {
58
125
  return;
@@ -71,9 +138,11 @@ export class ContextRegistry {
71
138
  Object.setPrototypeOf(context, Object.getPrototypeOf(updatedContext));
72
139
  this.cache.set(path, cloneForCache(updatedContext));
73
140
  }
141
+ /** Returns all registered route paths as an array. */
74
142
  getAllPaths() {
75
143
  return Array.from(this.entries.keys());
76
144
  }
145
+ /** Returns a plain object mapping every registered path to its context. */
77
146
  getAllContexts() {
78
147
  const result = {};
79
148
  for (const [path, context] of this.entries.entries()) {
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Options for setting an HTTP cookie on a response.
3
+ * These correspond to standard `Set-Cookie` attributes and are passed to the
4
+ * `.cookie()` method on the response builder.
5
+ */
6
+ export interface CookieOptions {
7
+ domain?: string;
8
+ expires?: Date;
9
+ httpOnly?: boolean;
10
+ maxAge?: number;
11
+ path?: string;
12
+ sameSite?: "lax" | "none" | "strict";
13
+ secure?: boolean;
14
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A unique symbol used as a brand for the `COUNTERFACT_RESPONSE` type.
3
+ * This prevents arbitrary objects from being accidentally treated as a
4
+ * completed response value.
5
+ */
6
+ const counterfactResponse = Symbol("Counterfact Response");
7
+
8
+ /**
9
+ * The terminal value type returned by the fluent response builder once all
10
+ * required fields (body, headers, etc.) have been provided. When a route
11
+ * handler returns this type, Counterfact treats the response as complete.
12
+ */
13
+ export type COUNTERFACT_RESPONSE = {
14
+ [counterfactResponse]: typeof counterfactResponse;
15
+ };
@@ -0,0 +1,13 @@
1
+ import type { OpenApiResponse } from "./open-api-response.js";
2
+
3
+ /**
4
+ * Extracts the union of named example keys defined on an OpenAPI response.
5
+ * Resolves to `never` when the response has no named examples.
6
+ * Used to constrain the argument to the `.example(name)` method on the
7
+ * response builder.
8
+ */
9
+ export type ExampleNames<Response extends OpenApiResponse> = Response extends {
10
+ examples: infer E;
11
+ }
12
+ ? keyof E & string
13
+ : never;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Represents a named example defined in an OpenAPI document.
3
+ * Examples can be referenced by route handlers via the `.example(name)` method
4
+ * on the response builder.
5
+ */
6
+ export interface Example {
7
+ description: string;
8
+ summary: string;
9
+ value: unknown;
10
+ }
@@ -0,0 +1,164 @@
1
+ import type { COUNTERFACT_RESPONSE } from "./counterfact-response.js";
2
+ import type { CookieOptions } from "./cookie-options.js";
3
+ import type { ExampleNames } from "./example-names.js";
4
+ import type { IfHasKey } from "./if-has-key.js";
5
+ import type { MediaType } from "./media-type.js";
6
+ import type { OmitAll } from "./omit-all.js";
7
+ import type { OmitValueWhenNever } from "./omit-value-when-never.js";
8
+ import type { OpenApiResponse } from "./open-api-response.js";
9
+ import type { RandomFunction } from "./random-function.js";
10
+
11
+ /**
12
+ * Returns `never` when `Record` is an empty object type (`{}`), signalling
13
+ * that there are no remaining choices available on the response builder.
14
+ */
15
+ type NeverIfEmpty<Record> = object extends Record ? never : Record;
16
+
17
+ /**
18
+ * Extracts the union of schema types from a map of media-type content entries.
19
+ * Used to type the body argument of shortcut methods like `.json()` or `.html()`.
20
+ */
21
+ type SchemasOf<T extends { [key: string]: { schema: unknown } }> = {
22
+ [K in keyof T]: T[K]["schema"];
23
+ }[keyof T];
24
+
25
+ /**
26
+ * Produces a builder method for a shortcut (e.g. `.json()`, `.html()`) when
27
+ * the response contains at least one of the given `ContentTypes`, and `never`
28
+ * otherwise. Calling the method narrows the builder by removing those content
29
+ * types from the remaining options.
30
+ */
31
+ type MaybeShortcut<
32
+ ContentTypes extends MediaType[],
33
+ Response extends OpenApiResponse,
34
+ > = IfHasKey<
35
+ Response["content"],
36
+ ContentTypes,
37
+ (body: SchemasOf<Response["content"]>) => GenericResponseBuilder<{
38
+ content: NeverIfEmpty<OmitAll<Response["content"], ContentTypes>>;
39
+ headers: Response["headers"];
40
+ requiredHeaders: Response["requiredHeaders"];
41
+ }>,
42
+ never
43
+ >;
44
+
45
+ /**
46
+ * The type of the `.match(contentType, body)` method on the generic response
47
+ * builder. Calling it narrows the builder by removing the chosen content type
48
+ * from the remaining options.
49
+ */
50
+ type MatchFunction<Response extends OpenApiResponse> = <
51
+ ContentType extends MediaType & keyof Response["content"],
52
+ >(
53
+ contentType: ContentType,
54
+ body: Response["content"][ContentType]["schema"],
55
+ ) => GenericResponseBuilder<{
56
+ content: NeverIfEmpty<Omit<Response["content"], ContentType>>;
57
+ headers: Response["headers"];
58
+ requiredHeaders: Response["requiredHeaders"];
59
+ }>;
60
+
61
+ /**
62
+ * The type of the `.header(name, value)` method on the generic response
63
+ * builder. Calling it narrows the builder by removing the satisfied header
64
+ * from the set of required headers.
65
+ */
66
+ type HeaderFunction<Response extends OpenApiResponse> = <
67
+ Header extends string & keyof Response["headers"],
68
+ >(
69
+ header: Header,
70
+ value: Response["headers"][Header]["schema"],
71
+ ) => GenericResponseBuilder<{
72
+ content: NeverIfEmpty<Response["content"]>;
73
+ headers: NeverIfEmpty<Omit<Response["headers"], Header>>;
74
+ requiredHeaders: Exclude<Response["requiredHeaders"], Header>;
75
+ }>;
76
+
77
+ /**
78
+ * The inner shape of the generic response builder, listing all methods that
79
+ * are currently available given the remaining response constraints.
80
+ * Methods whose type resolves to `never` are stripped by `OmitValueWhenNever`.
81
+ *
82
+ * Note: `[T] extends [never]` (non-distributive tuple wrapping) is used
83
+ * alongside `[keyof T] extends [never]` to correctly handle both `T = never`
84
+ * (spec-generated no-body) and `T = {}` (all content types consumed) cases.
85
+ * TypeScript evaluates `keyof never` as `string | number | symbol`, so a
86
+ * direct `[keyof never] extends [never]` check would incorrectly return false.
87
+ */
88
+ export type GenericResponseBuilderInner<
89
+ Response extends OpenApiResponse = OpenApiResponse,
90
+ > = OmitValueWhenNever<{
91
+ binary: MaybeShortcut<["application/octet-stream"], Response>;
92
+ cookie: (
93
+ name: string,
94
+ value: string,
95
+ options?: CookieOptions,
96
+ ) => GenericResponseBuilder<Response>;
97
+ empty: [Response["content"]] extends [never]
98
+ ? () => COUNTERFACT_RESPONSE
99
+ : [keyof Response["content"]] extends [never]
100
+ ? () => COUNTERFACT_RESPONSE
101
+ : never;
102
+ header: [Response["headers"]] extends [never]
103
+ ? never
104
+ : [keyof Response["headers"]] extends [never]
105
+ ? never
106
+ : HeaderFunction<Response>;
107
+ html: MaybeShortcut<["text/html"], Response>;
108
+ json: MaybeShortcut<
109
+ [
110
+ "application/json",
111
+ "text/json",
112
+ "text/x-json",
113
+ "application/xml",
114
+ "text/xml",
115
+ ],
116
+ Response
117
+ >;
118
+ match: [Response["content"]] extends [never]
119
+ ? never
120
+ : [keyof Response["content"]] extends [never]
121
+ ? never
122
+ : MatchFunction<Response>;
123
+ random: [Response["content"]] extends [never]
124
+ ? never
125
+ : [keyof Response["content"]] extends [never]
126
+ ? never
127
+ : RandomFunction;
128
+ example: [ExampleNames<Response>] extends [never]
129
+ ? never
130
+ : (name: ExampleNames<Response>) => COUNTERFACT_RESPONSE;
131
+ text: MaybeShortcut<["text/plain"], Response>;
132
+ xml: MaybeShortcut<["application/xml", "text/xml"], Response>;
133
+ }>;
134
+
135
+ /**
136
+ * The strongly-typed, fluent response builder generated for each operation in
137
+ * a route handler. Its available methods are derived from the OpenAPI response
138
+ * schema: as methods are called, the builder type narrows until all required
139
+ * content and headers have been provided, at which point it resolves to
140
+ * `COUNTERFACT_RESPONSE`.
141
+ *
142
+ * When a Response type carries an `examples` key it is a spec-generated
143
+ * response (either the initial no-body builder or a builder that still has
144
+ * content/headers to satisfy). Those always go through
145
+ * `GenericResponseBuilderInner`, which exposes `empty()` when `content` is
146
+ * `never`.
147
+ *
148
+ * When a Response type has no `examples` key it is a narrowed type produced
149
+ * by a method call (e.g. `.json()` sets the body and returns a type without
150
+ * `examples`). Those go through the existing collapse logic so that
151
+ * fully-satisfied responses resolve directly to `COUNTERFACT_RESPONSE`.
152
+ */
153
+ export type GenericResponseBuilder<
154
+ Response extends OpenApiResponse = OpenApiResponse,
155
+ > = "examples" extends keyof Response
156
+ ? GenericResponseBuilderInner<Response>
157
+ : object extends OmitValueWhenNever<Omit<Response, "examples">>
158
+ ? COUNTERFACT_RESPONSE
159
+ : keyof OmitValueWhenNever<Omit<Response, "examples">> extends "headers"
160
+ ? {
161
+ ALL_REMAINING_HEADERS_ARE_OPTIONAL: COUNTERFACT_RESPONSE;
162
+ header: HeaderFunction<Response>;
163
+ }
164
+ : GenericResponseBuilderInner<Response>;