counterfact 2.5.1 → 2.7.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 (94) hide show
  1. package/README.md +103 -140
  2. package/bin/README.md +25 -4
  3. package/bin/counterfact.js +208 -24
  4. package/bin/register-ts-loader.mjs +17 -0
  5. package/bin/ts-loader.mjs +31 -0
  6. package/dist/app.js +31 -21
  7. package/dist/counterfact-types/cookie-options.js +1 -0
  8. package/dist/counterfact-types/counterfact-response.js +7 -0
  9. package/dist/counterfact-types/example-names.js +1 -0
  10. package/dist/counterfact-types/example.js +1 -0
  11. package/dist/counterfact-types/generic-response-builder.js +1 -0
  12. package/dist/counterfact-types/http-status-code.js +1 -0
  13. package/dist/counterfact-types/if-has-key.js +1 -0
  14. package/dist/counterfact-types/index.js +0 -1
  15. package/dist/counterfact-types/maybe-promise.js +1 -0
  16. package/dist/counterfact-types/media-type.js +1 -0
  17. package/dist/counterfact-types/omit-all.js +1 -0
  18. package/dist/counterfact-types/omit-value-when-never.js +1 -0
  19. package/dist/counterfact-types/open-api-content.js +1 -0
  20. package/dist/counterfact-types/open-api-operation.js +1 -0
  21. package/dist/counterfact-types/open-api-parameters.js +1 -0
  22. package/dist/counterfact-types/open-api-response.js +1 -0
  23. package/dist/counterfact-types/random-function.js +1 -0
  24. package/dist/counterfact-types/response-builder-factory.js +1 -0
  25. package/dist/counterfact-types/response-builder.js +1 -0
  26. package/dist/counterfact-types/wide-operation-argument.js +1 -0
  27. package/dist/counterfact-types/wide-response-builder.js +1 -0
  28. package/dist/migrate/update-route-types.js +30 -10
  29. package/dist/repl/raw-http-client.js +14 -14
  30. package/dist/repl/repl.js +119 -4
  31. package/dist/repl/route-builder.js +270 -0
  32. package/dist/server/config.js +1 -1
  33. package/dist/server/context-registry.js +44 -4
  34. package/dist/server/counterfact-types/cookie-options.ts +14 -0
  35. package/dist/server/counterfact-types/counterfact-response.ts +15 -0
  36. package/dist/server/counterfact-types/example-names.ts +13 -0
  37. package/dist/server/counterfact-types/example.ts +10 -0
  38. package/dist/server/counterfact-types/generic-response-builder.ts +164 -0
  39. package/dist/server/counterfact-types/http-status-code.ts +62 -0
  40. package/dist/server/counterfact-types/if-has-key.ts +19 -0
  41. package/dist/server/counterfact-types/index.ts +20 -328
  42. package/dist/server/counterfact-types/maybe-promise.ts +6 -0
  43. package/dist/server/counterfact-types/media-type.ts +6 -0
  44. package/dist/server/counterfact-types/omit-all.ts +11 -0
  45. package/dist/server/counterfact-types/omit-value-when-never.ts +11 -0
  46. package/dist/server/counterfact-types/open-api-content.ts +8 -0
  47. package/dist/server/counterfact-types/open-api-operation.ts +36 -0
  48. package/dist/server/counterfact-types/open-api-parameters.ts +16 -0
  49. package/dist/server/counterfact-types/open-api-response.ts +22 -0
  50. package/dist/server/counterfact-types/random-function.ts +9 -0
  51. package/dist/server/counterfact-types/response-builder-factory.ts +16 -0
  52. package/dist/server/counterfact-types/response-builder.ts +31 -0
  53. package/dist/server/counterfact-types/wide-operation-argument.ts +17 -0
  54. package/dist/server/counterfact-types/wide-response-builder.ts +26 -0
  55. package/dist/server/create-koa-app.js +1 -20
  56. package/dist/server/determine-module-kind.js +1 -1
  57. package/dist/server/dispatcher.js +39 -15
  58. package/dist/server/file-discovery.js +34 -0
  59. package/dist/server/json-to-xml.js +1 -1
  60. package/dist/server/koa-middleware.js +7 -1
  61. package/dist/server/load-openapi-document.js +13 -0
  62. package/dist/server/middleware-detector.js +8 -0
  63. package/dist/server/module-dependency-graph.js +4 -1
  64. package/dist/server/module-loader.js +81 -33
  65. package/dist/server/module-tree.js +26 -23
  66. package/dist/server/openapi-middleware.js +2 -2
  67. package/dist/server/openapi-watcher.js +35 -0
  68. package/dist/server/registry.js +2 -2
  69. package/dist/server/request-validator.js +57 -0
  70. package/dist/server/response-builder.js +3 -0
  71. package/dist/server/response-validator.js +58 -0
  72. package/dist/server/scenario-registry.js +29 -0
  73. package/dist/server/tools.js +2 -2
  74. package/dist/server/transpiler.js +13 -5
  75. package/dist/typescript-generator/coder.js +7 -2
  76. package/dist/typescript-generator/generate.js +155 -0
  77. package/dist/typescript-generator/jsdoc.js +45 -0
  78. package/dist/typescript-generator/operation-coder.js +1 -1
  79. package/dist/typescript-generator/operation-type-coder.js +5 -49
  80. package/dist/typescript-generator/parameters-type-coder.js +5 -1
  81. package/dist/typescript-generator/prune.js +2 -1
  82. package/dist/typescript-generator/read-only-comments.js +1 -1
  83. package/dist/typescript-generator/requirement.js +8 -1
  84. package/dist/typescript-generator/reserved-words.js +50 -0
  85. package/dist/typescript-generator/schema-type-coder.js +7 -1
  86. package/dist/typescript-generator/script.js +5 -3
  87. package/dist/typescript-generator/specification.js +7 -1
  88. package/dist/util/load-config-file.js +44 -0
  89. package/dist/util/runtime-can-execute-erasable-ts.js +22 -0
  90. package/package.json +12 -12
  91. package/dist/client/README.md +0 -14
  92. package/dist/client/index.html.hbs +0 -244
  93. package/dist/client/rapi-doc.html.hbs +0 -36
  94. package/dist/server/page-middleware.js +0 -23
@@ -0,0 +1,22 @@
1
+ import type { MediaType } from "./media-type.js";
2
+ import type { OpenApiContent } from "./open-api-content.js";
3
+
4
+ /**
5
+ * Describes a single HTTP response as modelled in an OpenAPI document.
6
+ * Contains the allowed content types, optional named examples, and the
7
+ * required/optional response headers for that response.
8
+ */
9
+ export interface OpenApiResponse {
10
+ content: { [key: MediaType]: OpenApiContent };
11
+ examples?: { [key: string]: unknown };
12
+ headers: { [key: string]: { schema: unknown } };
13
+ requiredHeaders: string;
14
+ }
15
+
16
+ /**
17
+ * A map of HTTP status codes (or `"default"`) to their corresponding
18
+ * `OpenApiResponse` definitions for a given operation.
19
+ */
20
+ export interface OpenApiResponses {
21
+ [key: string]: OpenApiResponse;
22
+ }
@@ -0,0 +1,9 @@
1
+ import type { COUNTERFACT_RESPONSE } from "./counterfact-response.js";
2
+ import type { MaybePromise } from "./maybe-promise.js";
3
+
4
+ /**
5
+ * The type of the `.random()` method on the response builder.
6
+ * When called, it randomly selects one of the available content-type examples
7
+ * and returns a completed `COUNTERFACT_RESPONSE`.
8
+ */
9
+ export type RandomFunction = () => MaybePromise<COUNTERFACT_RESPONSE>;
@@ -0,0 +1,16 @@
1
+ import type { GenericResponseBuilder } from "./generic-response-builder.js";
2
+ import type { OpenApiResponses } from "./open-api-response.js";
3
+
4
+ /**
5
+ * Maps each HTTP status code (or `"default"`) in an OpenAPI operation's
6
+ * response definitions to the corresponding `GenericResponseBuilder`.
7
+ * This is the type of the `response` property in a generated route handler's
8
+ * argument object, allowing handlers to call e.g. `response[200].json(body)`.
9
+ */
10
+ export type ResponseBuilderFactory<
11
+ Responses extends OpenApiResponses = OpenApiResponses,
12
+ > = {
13
+ [StatusCode in keyof Responses]: GenericResponseBuilder<
14
+ Responses[StatusCode]
15
+ >;
16
+ } & { [key: string]: GenericResponseBuilder<Responses["default"]> };
@@ -0,0 +1,31 @@
1
+ import type { CookieOptions } from "./cookie-options.js";
2
+ import type { MaybePromise } from "./maybe-promise.js";
3
+
4
+ /**
5
+ * A loosely-typed, chainable response builder used in non-generated contexts
6
+ * (e.g. middleware or wide/catch-all route handlers) where the exact response
7
+ * shape is not statically known. For generated route handlers, prefer the
8
+ * strongly-typed `GenericResponseBuilder`.
9
+ */
10
+ export interface ResponseBuilder {
11
+ [status: number | `${number} ${string}`]: ResponseBuilder;
12
+ binary: (body: Uint8Array | string) => ResponseBuilder;
13
+ content?: { body: unknown; type: string }[];
14
+ cookie: (
15
+ name: string,
16
+ value: string,
17
+ options?: CookieOptions,
18
+ ) => ResponseBuilder;
19
+ empty: () => ResponseBuilder;
20
+ example: (name: string) => ResponseBuilder;
21
+ header: (name: string, value: string) => ResponseBuilder;
22
+ headers: { [name: string]: string | string[] };
23
+ html: (body: unknown) => ResponseBuilder;
24
+ json: (body: unknown) => ResponseBuilder;
25
+ match: (contentType: string, body: unknown) => ResponseBuilder;
26
+ random: () => MaybePromise<ResponseBuilder>;
27
+ randomLegacy: () => MaybePromise<ResponseBuilder>;
28
+ status?: number;
29
+ text: (body: unknown) => ResponseBuilder;
30
+ xml: (body: unknown) => ResponseBuilder;
31
+ }
@@ -0,0 +1,17 @@
1
+ import type { WideResponseBuilder } from "./wide-response-builder.js";
2
+
3
+ /**
4
+ * The loosely-typed argument object passed to wide (catch-all) route handlers.
5
+ * Unlike the generated operation argument types, all fields are typed as
6
+ * `unknown` or broad index signatures. Use this when writing handlers that
7
+ * should accept any request without compile-time schema enforcement.
8
+ */
9
+ export interface WideOperationArgument {
10
+ body: unknown;
11
+ context: unknown;
12
+ headers: { [key: string]: string };
13
+ path: { [key: string]: string };
14
+ proxy: (url: string) => { proxyUrl: string };
15
+ query: { [key: string]: string };
16
+ response: { [key: number]: WideResponseBuilder };
17
+ }
@@ -0,0 +1,26 @@
1
+ import type { CookieOptions } from "./cookie-options.js";
2
+ import type { MaybePromise } from "./maybe-promise.js";
3
+
4
+ /**
5
+ * A loosely-typed response builder used in wide (catch-all) route handlers
6
+ * where the response shape is not known at compile time. Unlike the generated
7
+ * `GenericResponseBuilder`, this interface accepts `unknown` for all body
8
+ * arguments and does not enforce content-type constraints.
9
+ */
10
+ export interface WideResponseBuilder {
11
+ binary: (body: Uint8Array | string) => WideResponseBuilder;
12
+ empty: () => WideResponseBuilder;
13
+ example: (name: string) => WideResponseBuilder;
14
+ cookie: (
15
+ name: string,
16
+ value: string,
17
+ options?: CookieOptions,
18
+ ) => WideResponseBuilder;
19
+ header: (body: unknown) => WideResponseBuilder;
20
+ html: (body: unknown) => WideResponseBuilder;
21
+ json: (body: unknown) => WideResponseBuilder;
22
+ match: (contentType: string, body: unknown) => WideResponseBuilder;
23
+ random: () => MaybePromise<WideResponseBuilder>;
24
+ text: (body: unknown) => WideResponseBuilder;
25
+ xml: (body: unknown) => WideResponseBuilder;
26
+ }
@@ -1,11 +1,9 @@
1
- import { pathToFileURL } from "node:url";
2
1
  import createDebug from "debug";
3
2
  import Koa from "koa";
4
3
  import bodyParser from "koa-bodyparser";
5
4
  import { koaSwagger } from "koa2-swagger-ui";
6
5
  import { adminApiMiddleware } from "./admin-api-middleware.js";
7
6
  import { openapiMiddleware } from "./openapi-middleware.js";
8
- import { pageMiddleware } from "./page-middleware.js";
9
7
  const debug = createDebug("counterfact:server:create-koa-app");
10
8
  export function createKoaApp(registry, koaMiddleware, config, contextRegistry) {
11
9
  const app = new Koa();
@@ -21,30 +19,13 @@ export function createKoaApp(registry, koaMiddleware, config, contextRegistry) {
21
19
  }
22
20
  debug("basePath: %s", config.basePath);
23
21
  debug("routes", registry.routes);
24
- app.use(pageMiddleware("/counterfact/", "index", {
25
- basePath: config.basePath,
26
- methods: ["get", "post", "put", "delete", "patch"],
27
- openApiHref: config.openApiPath.includes("://")
28
- ? config.openApiPath
29
- : pathToFileURL(config.openApiPath).href,
30
- openApiPath: config.openApiPath,
31
- get routes() {
32
- return registry.routes;
33
- },
34
- }));
35
22
  app.use(async (ctx, next) => {
36
23
  if (ctx.URL.pathname === "/counterfact") {
37
- ctx.redirect("/counterfact/");
24
+ ctx.redirect("/counterfact/swagger");
38
25
  return;
39
26
  }
40
27
  await next();
41
28
  });
42
- app.use(pageMiddleware("/counterfact/rapidoc", "rapi-doc", {
43
- basePath: config.basePath,
44
- get routes() {
45
- return registry.routes;
46
- },
47
- }));
48
29
  app.use(bodyParser());
49
30
  app.use(async (ctx, next) => {
50
31
  await next();
@@ -6,7 +6,7 @@ export async function determineModuleKind(modulePath) {
6
6
  if (modulePath.endsWith(".cjs")) {
7
7
  return "commonjs";
8
8
  }
9
- if (modulePath.endsWith(".mjs")) {
9
+ if (modulePath.endsWith(".mjs") || modulePath.endsWith(".ts")) {
10
10
  return "module";
11
11
  }
12
12
  if (modulePath === path.parse(modulePath).root) {
@@ -2,6 +2,8 @@ import { mediaTypes } from "@hapi/accept";
2
2
  import createDebugger from "debug";
3
3
  import fetch, { Headers } from "node-fetch";
4
4
  import { createResponseBuilder } from "./response-builder.js";
5
+ import { validateRequest } from "./request-validator.js";
6
+ import { validateResponse } from "./response-validator.js";
5
7
  import { Tools } from "./tools.js";
6
8
  const debug = createDebugger("counterfact:server:dispatcher");
7
9
  function parseCookies(cookieHeader) {
@@ -17,7 +19,8 @@ function parseCookies(cookieHeader) {
17
19
  try {
18
20
  cookies[key] = decodeURIComponent(value);
19
21
  }
20
- catch {
22
+ catch (error) {
23
+ debug("could not decode cookie value for key %s: %o", key, error);
21
24
  cookies[key] = value;
22
25
  }
23
26
  }
@@ -39,12 +42,12 @@ export class Dispatcher {
39
42
  }
40
43
  parameterTypes(parameters) {
41
44
  const types = {
42
- body: {},
43
- cookie: {},
44
- formData: {},
45
- header: {},
46
- path: {},
47
- query: {},
45
+ body: new Map(),
46
+ cookie: new Map(),
47
+ formData: new Map(),
48
+ header: new Map(),
49
+ path: new Map(),
50
+ query: new Map(),
48
51
  };
49
52
  if (!parameters) {
50
53
  return types;
@@ -52,8 +55,7 @@ export class Dispatcher {
52
55
  for (const parameter of parameters) {
53
56
  const type = parameter?.type;
54
57
  if (type !== undefined) {
55
- types[parameter.in][parameter.name] =
56
- type === "integer" ? "number" : type;
58
+ types[parameter.in].set(parameter.name, type === "integer" ? "number" : type);
57
59
  }
58
60
  }
59
61
  return types;
@@ -130,14 +132,14 @@ export class Dispatcher {
130
132
  }
131
133
  return false;
132
134
  }
133
- async request({ auth, body, headers = {}, method, path, query, req, }) {
135
+ async request({ auth, body, headers = {}, method, path, query, rawBody, req, }) {
134
136
  debug(`request: ${method} ${path}`);
135
137
  debug(`headers: ${JSON.stringify(headers)}`);
136
138
  debug(`body: ${JSON.stringify(body)}`);
137
139
  // If the incoming path includes the base path, remove it
138
140
  if (this.openApiDocument?.basePath !== undefined &&
139
141
  path.toLowerCase().startsWith(this.openApiDocument.basePath.toLowerCase())) {
140
- path = path.replace(new RegExp(this.openApiDocument.basePath, "iu"), "");
142
+ path = path.slice(this.openApiDocument.basePath.length);
141
143
  }
142
144
  const { matchedPath } = this.registry.handler(path, method);
143
145
  if (!this.registry.exists(method, path) &&
@@ -150,6 +152,16 @@ export class Dispatcher {
150
152
  };
151
153
  }
152
154
  const operation = this.operationForPathAndMethod(matchedPath, method);
155
+ if (this.config?.validateRequests !== false) {
156
+ const validation = validateRequest(operation, { body, headers, query });
157
+ if (!validation.valid) {
158
+ return {
159
+ body: `Request validation failed:\n${validation.errors.join("\n")}`,
160
+ contentType: "text/plain",
161
+ status: 400,
162
+ };
163
+ }
164
+ }
153
165
  const continuousDistribution = (min, max) => {
154
166
  return min + Math.random() * (max - min);
155
167
  };
@@ -166,12 +178,9 @@ export class Dispatcher {
166
178
  cookie: parseCookies(headers.cookie ?? headers.Cookie ?? ""),
167
179
  headers,
168
180
  proxy: async (url) => {
169
- if (body !== undefined && headers.contentType !== "application/json") {
170
- throw new Error(`$.proxy() is currently limited to application/json requests. You tried to proxy to ${url} with a Content-Type of ${headers.contentType ?? "[unknown]"}. Please open an issue at https://github.com/pmcelhaney/counterfact/issues and prod me to fix this limitation.`);
171
- }
172
181
  delete headers.host;
173
182
  const fetchResponse = await this.fetch(`${url}${req.path ?? ""}`, {
174
- body: body === undefined ? undefined : JSON.stringify(body),
183
+ body: body === undefined ? undefined : rawBody,
175
184
  headers: new Headers(headers),
176
185
  method,
177
186
  });
@@ -202,6 +211,21 @@ export class Dispatcher {
202
211
  status: 406,
203
212
  };
204
213
  }
214
+ if (this.config?.validateResponses !== false) {
215
+ const validation = validateResponse(operation, normalizedResponse);
216
+ if (!validation.valid) {
217
+ return {
218
+ ...normalizedResponse,
219
+ appendedHeaders: [
220
+ ...(normalizedResponse.appendedHeaders ?? []),
221
+ ...validation.errors.map((error) => [
222
+ "response-type-error",
223
+ error,
224
+ ]),
225
+ ],
226
+ };
227
+ }
228
+ }
205
229
  return normalizedResponse;
206
230
  }
207
231
  }
@@ -0,0 +1,34 @@
1
+ import { existsSync } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import nodePath from "node:path";
4
+ import { escapePathForWindows } from "../util/windows-escape.js";
5
+ const JS_EXTENSIONS = new Set(["cjs", "cts", "js", "mjs", "mts", "ts"]);
6
+ export class FileDiscovery {
7
+ basePath;
8
+ constructor(basePath) {
9
+ this.basePath = basePath.replaceAll("\\", "/");
10
+ }
11
+ async findFiles(directory = "") {
12
+ const fullDir = nodePath
13
+ .join(this.basePath, directory)
14
+ .replaceAll("\\", "/");
15
+ if (!existsSync(fullDir)) {
16
+ throw new Error(`Directory does not exist ${fullDir}`);
17
+ }
18
+ const entries = await fs.readdir(fullDir, { withFileTypes: true });
19
+ const results = await Promise.all(entries.map(async (entry) => {
20
+ if (entry.isDirectory()) {
21
+ return this.findFiles(nodePath.join(directory, entry.name).replaceAll("\\", "/"));
22
+ }
23
+ const extension = entry.name.split(".").at(-1);
24
+ if (!JS_EXTENSIONS.has(extension ?? "")) {
25
+ return [];
26
+ }
27
+ const fullPath = nodePath
28
+ .join(this.basePath, directory, entry.name)
29
+ .replaceAll("\\", "/");
30
+ return [escapePathForWindows(fullPath)];
31
+ }));
32
+ return results.flat();
33
+ }
34
+ }
@@ -50,5 +50,5 @@ export function jsonToXml(json, schema, keyName = "root") {
50
50
  if (typeof json === "object" && json !== null) {
51
51
  return objectToXml(json, schema, name);
52
52
  }
53
- return `<${name}>${String(json)}</${name}>`;
53
+ return `<${name}>${xmlEscape(String(json))}</${name}>`;
54
54
  }
@@ -49,7 +49,7 @@ export function koaMiddleware(dispatcher, config, proxy = koaProxy) {
49
49
  return await next();
50
50
  }
51
51
  const auth = getAuthObject(ctx);
52
- const { body, headers, query } = ctx.request;
52
+ const { body, headers, query, rawBody } = ctx.request;
53
53
  const path = ctx.request.path.slice(routePrefix.length);
54
54
  const method = ctx.request.method;
55
55
  if (isProxyEnabledForPath(path, config) && proxyUrl) {
@@ -69,6 +69,7 @@ export function koaMiddleware(dispatcher, config, proxy = koaProxy) {
69
69
  path,
70
70
  /* @ts-expect-error the value of a querystring item can be an array and we don't have a solution for that yet */
71
71
  query,
72
+ rawBody: method === "HEAD" || method === "GET" ? undefined : rawBody,
72
73
  req: { path: "", ...ctx.req },
73
74
  });
74
75
  ctx.body = response.body;
@@ -88,6 +89,11 @@ export function koaMiddleware(dispatcher, config, proxy = koaProxy) {
88
89
  }
89
90
  }
90
91
  }
92
+ if (response.appendedHeaders) {
93
+ for (const [key, value] of response.appendedHeaders) {
94
+ ctx.res.appendHeader(key, value);
95
+ }
96
+ }
91
97
  ctx.status = response.status ?? HTTP_STATUS_CODE_OK;
92
98
  return undefined;
93
99
  };
@@ -0,0 +1,13 @@
1
+ import { dereference } from "@apidevtools/json-schema-ref-parser";
2
+ import createDebug from "debug";
3
+ const debug = createDebug("counterfact:server:load-openapi-document");
4
+ export async function loadOpenApiDocument(source) {
5
+ try {
6
+ return (await dereference(source));
7
+ }
8
+ catch (error) {
9
+ debug("could not load OpenAPI document from %s: %o", source, error);
10
+ const details = error instanceof Error ? error.message : String(error);
11
+ throw new Error(`Could not load the OpenAPI spec from "${source}".\n${details}`, { cause: error });
12
+ }
13
+ }
@@ -0,0 +1,8 @@
1
+ export function isContextModule(module) {
2
+ return "Context" in module && typeof module.Context === "function";
3
+ }
4
+ export function isMiddlewareModule(module) {
5
+ return ("middleware" in module &&
6
+ typeof Object.getOwnPropertyDescriptor(module, "middleware")?.value ===
7
+ "function");
8
+ }
@@ -1,12 +1,15 @@
1
1
  import { dirname, resolve } from "node:path";
2
+ import createDebug from "debug";
2
3
  import precinct from "precinct";
4
+ const debug = createDebug("counterfact:server:module-dependency-graph");
3
5
  export class ModuleDependencyGraph {
4
6
  dependents = new Map();
5
7
  loadDependencies(path) {
6
8
  try {
7
9
  return precinct.paperwork(path);
8
10
  }
9
- catch {
11
+ catch (error) {
12
+ debug("could not load dependencies for %s: %o", path, error);
10
13
  return [];
11
14
  }
12
15
  }
@@ -1,5 +1,4 @@
1
1
  import { once } from "node:events";
2
- import { existsSync } from "node:fs";
3
2
  import fs from "node:fs/promises";
4
3
  import nodePath, { basename, dirname } from "node:path";
5
4
  import { watch } from "chokidar";
@@ -7,33 +6,34 @@ import createDebug from "debug";
7
6
  import { CHOKIDAR_OPTIONS } from "./constants.js";
8
7
  import { ContextRegistry } from "./context-registry.js";
9
8
  import { determineModuleKind } from "./determine-module-kind.js";
9
+ import { FileDiscovery } from "./file-discovery.js";
10
+ import { isContextModule, isMiddlewareModule, } from "./middleware-detector.js";
10
11
  import { ModuleDependencyGraph } from "./module-dependency-graph.js";
11
12
  import { uncachedImport } from "./uncached-import.js";
13
+ import { unescapePathForWindows } from "../util/windows-escape.js";
12
14
  const { uncachedRequire } = await import("./uncached-require.cjs");
13
15
  const debug = createDebug("counterfact:server:module-loader");
14
- import { escapePathForWindows, unescapePathForWindows, } from "../util/windows-escape.js";
15
- function isContextModule(module) {
16
- return "Context" in module && typeof module.Context === "function";
17
- }
18
- function isMiddlewareModule(module) {
19
- return ("middleware" in module &&
20
- typeof Object.getOwnPropertyDescriptor(module, "middleware")?.value ===
21
- "function");
22
- }
23
16
  export class ModuleLoader extends EventTarget {
24
17
  basePath;
25
18
  registry;
26
19
  watcher;
20
+ scenariosWatcher;
27
21
  contextRegistry;
22
+ scenariosPath;
23
+ scenarioRegistry;
28
24
  dependencyGraph = new ModuleDependencyGraph();
25
+ fileDiscovery;
29
26
  uncachedImport = async function (moduleName) {
30
27
  throw new Error(`uncachedImport not set up; importing ${moduleName}`);
31
28
  };
32
- constructor(basePath, registry, contextRegistry = new ContextRegistry()) {
29
+ constructor(basePath, registry, contextRegistry = new ContextRegistry(), scenariosPath, scenarioRegistry) {
33
30
  super();
34
31
  this.basePath = basePath.replaceAll("\\", "/");
35
32
  this.registry = registry;
36
33
  this.contextRegistry = contextRegistry;
34
+ this.scenariosPath = scenariosPath?.replaceAll("\\", "/");
35
+ this.scenarioRegistry = scenarioRegistry;
36
+ this.fileDiscovery = new FileDiscovery(this.basePath);
37
37
  }
38
38
  async watch() {
39
39
  this.watcher = watch(this.basePath, CHOKIDAR_OPTIONS).on("all", (eventName, pathNameOriginal) => {
@@ -42,7 +42,7 @@ export class ModuleLoader extends EventTarget {
42
42
  return;
43
43
  const pathName = pathNameOriginal.replaceAll("\\", "/");
44
44
  if (pathName.includes("$.context") && eventName === "add") {
45
- process.stdout.write(`\n\n!!! The file at ${pathName} needs a minor update.\n See https://github.com/pmcelhaney/counterfact/blob/main/docs/context-change.md\n\n\n`);
45
+ process.stdout.write(`\n\n!!! The file at ${pathName} needs a minor update.\n See https://github.com/pmcelhaney/counterfact/blob/main/docs/faq.md\n\n\n`);
46
46
  return;
47
47
  }
48
48
  if (!["add", "change", "unlink"].includes(eventName)) {
@@ -55,6 +55,9 @@ export class ModuleLoader extends EventTarget {
55
55
  if (eventName === "unlink") {
56
56
  this.registry.remove(url);
57
57
  this.dispatchEvent(new Event("remove"));
58
+ if (this.isContextFile(pathName)) {
59
+ this.contextRegistry.remove(unescapePathForWindows(parts.dir).replaceAll("\\", "/") || "/");
60
+ }
58
61
  return;
59
62
  }
60
63
  const dependencies = this.dependencyGraph.dependentsOf(pathName);
@@ -64,32 +67,78 @@ export class ModuleLoader extends EventTarget {
64
67
  }
65
68
  });
66
69
  await once(this.watcher, "ready");
70
+ if (this.scenariosPath && this.scenarioRegistry) {
71
+ const JS_EXTENSIONS = ["js", "mjs", "cjs", "ts", "mts", "cts"];
72
+ const scenariosPath = this.scenariosPath;
73
+ this.scenariosWatcher = watch(scenariosPath, CHOKIDAR_OPTIONS).on("all", (eventName, pathNameOriginal) => {
74
+ if (!JS_EXTENSIONS.some((ext) => pathNameOriginal.endsWith(`.${ext}`)))
75
+ return;
76
+ if (!["add", "change", "unlink"].includes(eventName))
77
+ return;
78
+ const pathName = pathNameOriginal.replaceAll("\\", "/");
79
+ if (eventName === "unlink") {
80
+ const fileKey = this.scenarioFileKey(pathName);
81
+ this.scenarioRegistry?.remove(fileKey);
82
+ return;
83
+ }
84
+ void this.loadScenarioFile(pathName);
85
+ });
86
+ await once(this.scenariosWatcher, "ready");
87
+ }
67
88
  }
68
89
  async stopWatching() {
69
90
  await this.watcher?.close();
91
+ await this.scenariosWatcher?.close();
92
+ }
93
+ isContextFile(pathName) {
94
+ return basename(pathName).startsWith("_.context.");
70
95
  }
71
96
  async load(directory = "") {
72
- if (!existsSync(nodePath.join(this.basePath, directory).replaceAll("\\", "/"))) {
73
- throw new Error(`Directory does not exist ${this.basePath}`);
97
+ const files = await this.fileDiscovery.findFiles(directory);
98
+ await Promise.all(files.map((file) => this.loadEndpoint(file)));
99
+ await this.loadScenarios();
100
+ }
101
+ shouldLoadScenarioFile(pathName) {
102
+ return !pathName.endsWith(".d.ts") && !pathName.endsWith(".map");
103
+ }
104
+ async loadScenarios() {
105
+ if (!this.scenariosPath || !this.scenarioRegistry)
106
+ return;
107
+ try {
108
+ const fileDiscovery = new FileDiscovery(this.scenariosPath);
109
+ const files = await fileDiscovery.findFiles();
110
+ const loadableFiles = files.filter((file) => this.shouldLoadScenarioFile(file));
111
+ await Promise.all(loadableFiles.map((file) => this.loadScenarioFile(file)));
74
112
  }
75
- const files = await fs.readdir(nodePath.join(this.basePath, directory).replaceAll("\\", "/"), {
76
- withFileTypes: true,
77
- });
78
- const imports = files.flatMap(async (file) => {
79
- const extension = file.name.split(".").at(-1);
80
- if (file.isDirectory()) {
81
- await this.load(nodePath.join(directory, file.name).replaceAll("\\", "/"));
82
- return;
83
- }
84
- if (!["cjs", "cts", "js", "mjs", "mts", "ts"].includes(extension ?? "")) {
85
- return;
113
+ catch {
114
+ // Scenarios directory does not exist yet — that's fine.
115
+ }
116
+ }
117
+ scenarioFileKey(pathName) {
118
+ const normalizedScenariosPath = (this.scenariosPath ?? "").replaceAll("\\", "/");
119
+ const directory = dirname(pathName.slice(normalizedScenariosPath.length)).replaceAll("\\", "/");
120
+ const name = nodePath.parse(basename(pathName)).name;
121
+ const url = unescapePathForWindows(`/${nodePath.join(directory, name)}`
122
+ .replaceAll("\\", "/")
123
+ .replaceAll(/\/+/gu, "/"));
124
+ return url.slice(1); // strip leading "/"
125
+ }
126
+ async loadScenarioFile(pathName) {
127
+ if (!this.scenariosPath || !this.scenarioRegistry)
128
+ return;
129
+ const fileKey = this.scenarioFileKey(pathName);
130
+ try {
131
+ const doImport = (await determineModuleKind(pathName)) === "commonjs"
132
+ ? uncachedRequire
133
+ : uncachedImport;
134
+ const module = await doImport(pathName);
135
+ if (module) {
136
+ this.scenarioRegistry.add(fileKey, module);
86
137
  }
87
- const fullPath = nodePath
88
- .join(this.basePath, directory, file.name)
89
- .replaceAll("\\", "/");
90
- await this.loadEndpoint(escapePathForWindows(fullPath));
91
- });
92
- await Promise.all(imports);
138
+ }
139
+ catch (error) {
140
+ process.stdout.write(`\nError loading scenario ${pathName}:\n${String(error)}\n`);
141
+ }
93
142
  }
94
143
  async loadEndpoint(pathName) {
95
144
  debug("importing module: %s", pathName);
@@ -137,8 +186,7 @@ export class ModuleLoader extends EventTarget {
137
186
  return;
138
187
  }
139
188
  this.dispatchEvent(new Event("add"));
140
- if (basename(pathName).startsWith("_.context.") &&
141
- isContextModule(endpoint)) {
189
+ if (this.isContextFile(pathName) && isContextModule(endpoint)) {
142
190
  const loadContext = (path) => this.contextRegistry.find(path);
143
191
  const contextDir = nodePath.dirname(unescapePathForWindows(pathName));
144
192
  const readJson = async (relativePath) => {