counterfact 2.5.0 → 2.6.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 (43) hide show
  1. package/README.md +1 -0
  2. package/bin/README.md +1 -0
  3. package/bin/counterfact.js +164 -23
  4. package/bin/register-ts-loader.mjs +17 -0
  5. package/bin/ts-loader.mjs +31 -0
  6. package/dist/app.js +23 -12
  7. package/dist/migrate/update-route-types.js +47 -29
  8. package/dist/repl/raw-http-client.js +14 -14
  9. package/dist/repl/repl.js +24 -2
  10. package/dist/repl/route-builder.js +270 -0
  11. package/dist/server/config.js +1 -1
  12. package/dist/server/context-registry.js +27 -3
  13. package/dist/server/counterfact-types/index.ts +11 -1
  14. package/dist/server/determine-module-kind.js +1 -1
  15. package/dist/server/dispatcher.js +21 -10
  16. package/dist/server/file-discovery.js +34 -0
  17. package/dist/server/middleware-detector.js +8 -0
  18. package/dist/server/module-dependency-graph.js +4 -1
  19. package/dist/server/module-loader.js +7 -31
  20. package/dist/server/module-tree.js +26 -23
  21. package/dist/server/openapi-middleware.js +2 -2
  22. package/dist/server/registry.js +2 -2
  23. package/dist/server/request-validator.js +61 -0
  24. package/dist/server/transpiler.js +13 -5
  25. package/dist/typescript-generator/coder.js +8 -4
  26. package/dist/typescript-generator/generate.js +3 -3
  27. package/dist/typescript-generator/jsdoc.js +45 -0
  28. package/dist/typescript-generator/operation-coder.js +8 -5
  29. package/dist/typescript-generator/operation-type-coder.js +21 -11
  30. package/dist/typescript-generator/parameter-export-type-coder.js +4 -1
  31. package/dist/typescript-generator/parameters-type-coder.js +6 -1
  32. package/dist/typescript-generator/prune.js +11 -11
  33. package/dist/typescript-generator/repository.js +1 -1
  34. package/dist/typescript-generator/requirement.js +10 -5
  35. package/dist/typescript-generator/response-type-coder.js +10 -5
  36. package/dist/typescript-generator/responses-type-coder.js +1 -0
  37. package/dist/typescript-generator/schema-coder.js +5 -5
  38. package/dist/typescript-generator/schema-type-coder.js +23 -12
  39. package/dist/typescript-generator/script.js +18 -5
  40. package/dist/typescript-generator/specification.js +13 -4
  41. package/dist/util/ensure-directory-exists.js +1 -1
  42. package/dist/util/runtime-can-execute-erasable-ts.js +22 -0
  43. package/package.json +7 -6
package/dist/repl/repl.js CHANGED
@@ -1,11 +1,31 @@
1
1
  import repl from "node:repl";
2
2
  import { RawHttpClient } from "./raw-http-client.js";
3
+ import { createRouteFunction } from "./route-builder.js";
3
4
  function printToStdout(line) {
4
5
  process.stdout.write(`${line}\n`);
5
6
  }
7
+ const ROUTE_BUILDER_METHODS = [
8
+ "body(",
9
+ "headers(",
10
+ "help(",
11
+ "method(",
12
+ "missing(",
13
+ "path(",
14
+ "query(",
15
+ "ready(",
16
+ "send(",
17
+ ];
6
18
  export function createCompleter(registry, fallback) {
7
19
  return (line, callback) => {
8
- const match = line.match(/client\.(?:get|post|put|patch|delete)\("(?<partial>[^"]*)$/u);
20
+ // Check for RouteBuilder method completion: route("..."). or chained calls
21
+ const builderMatch = line.match(/route\(.*\)\.(?<partial>[a-zA-Z]*)$/u);
22
+ if (builderMatch) {
23
+ const partial = builderMatch.groups?.["partial"] ?? "";
24
+ const matches = ROUTE_BUILDER_METHODS.filter((m) => m.startsWith(partial));
25
+ callback(null, [matches, partial]);
26
+ return;
27
+ }
28
+ const match = line.match(/(?:client\.(?:get|post|put|patch|delete)|route)\("(?<partial>[^"]*)$/u);
9
29
  if (!match) {
10
30
  if (fallback) {
11
31
  fallback(line, callback);
@@ -21,7 +41,7 @@ export function createCompleter(registry, fallback) {
21
41
  callback(null, [matches, partial]);
22
42
  };
23
43
  }
24
- export function startRepl(contextRegistry, registry, config, print = printToStdout) {
44
+ export function startRepl(contextRegistry, registry, config, print = printToStdout, openApiDocument) {
25
45
  function printProxyStatus() {
26
46
  if (config.proxyUrl === "") {
27
47
  print("The proxy URL is not set.");
@@ -73,6 +93,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
73
93
  print("");
74
94
  print("- loadContext('/some/path'): to access the context object for a given path");
75
95
  print("- context: the root context ( same as loadContext('/') )");
96
+ print("- route('/some/path'): create a request builder for the given path");
76
97
  print("");
77
98
  print("For more information, see https://counterfact.dev/docs/usage.html");
78
99
  print("");
@@ -107,5 +128,6 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
107
128
  replServer.context.context = replServer.context.loadContext("/");
108
129
  replServer.context.client = new RawHttpClient("localhost", config.port);
109
130
  replServer.context.RawHttpClient = RawHttpClient;
131
+ replServer.context.route = createRouteFunction(config.port, "localhost", openApiDocument);
110
132
  return replServer;
111
133
  }
@@ -0,0 +1,270 @@
1
+ import { RawHttpClient } from "./raw-http-client.js";
2
+ export class RouteBuilder {
3
+ routePath;
4
+ _body;
5
+ _headerParams;
6
+ _host;
7
+ _method;
8
+ _openApiDocument;
9
+ _pathParams;
10
+ _port;
11
+ _queryParams;
12
+ _operation;
13
+ constructor(routePath, options) {
14
+ this.routePath = routePath;
15
+ this._method = options.method;
16
+ this._pathParams = options.pathParams ?? {};
17
+ this._queryParams = options.queryParams ?? {};
18
+ this._headerParams = options.headerParams ?? {};
19
+ this._body = options.body;
20
+ this._port = options.port;
21
+ this._host = options.host ?? "localhost";
22
+ this._openApiDocument = options.openApiDocument;
23
+ this._operation = this._resolveOperation();
24
+ }
25
+ _resolveOperation() {
26
+ if (!this._openApiDocument || !this._method)
27
+ return undefined;
28
+ const method = this._method.toLowerCase();
29
+ const normalizedPath = this.routePath.toLowerCase();
30
+ for (const key of Object.keys(this._openApiDocument.paths)) {
31
+ if (key.toLowerCase() === normalizedPath) {
32
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
33
+ return this._openApiDocument.paths[key][method];
34
+ }
35
+ }
36
+ return undefined;
37
+ }
38
+ clone(overrides) {
39
+ return new RouteBuilder(this.routePath, {
40
+ body: "body" in overrides ? overrides.body : this._body,
41
+ headerParams: overrides.headerParams ?? this._headerParams,
42
+ host: this._host,
43
+ method: overrides.method ?? this._method,
44
+ openApiDocument: this._openApiDocument,
45
+ pathParams: overrides.pathParams ?? this._pathParams,
46
+ port: this._port,
47
+ queryParams: overrides.queryParams ?? this._queryParams,
48
+ });
49
+ }
50
+ method(method) {
51
+ return this.clone({ method: method.toUpperCase() });
52
+ }
53
+ path(params) {
54
+ return this.clone({ pathParams: { ...this._pathParams, ...params } });
55
+ }
56
+ query(params) {
57
+ return this.clone({ queryParams: { ...this._queryParams, ...params } });
58
+ }
59
+ headers(params) {
60
+ return this.clone({ headerParams: { ...this._headerParams, ...params } });
61
+ }
62
+ body(body) {
63
+ return this.clone({ body });
64
+ }
65
+ getOperation() {
66
+ return this._operation;
67
+ }
68
+ ready() {
69
+ if (!this._method)
70
+ return false;
71
+ return this.missing() === undefined;
72
+ }
73
+ missing() {
74
+ const operation = this.getOperation();
75
+ if (!operation?.parameters)
76
+ return undefined;
77
+ const missingParams = {};
78
+ for (const param of operation.parameters) {
79
+ if (!param.required)
80
+ continue;
81
+ const paramType = param.type ?? param.schema?.type ?? "string";
82
+ const paramInfo = {
83
+ description: param.description,
84
+ name: param.name,
85
+ type: paramType,
86
+ };
87
+ if (param.in === "path" && !(param.name in this._pathParams)) {
88
+ missingParams.path = [...(missingParams.path ?? []), paramInfo];
89
+ }
90
+ else if (param.in === "query" && !(param.name in this._queryParams)) {
91
+ missingParams.query = [...(missingParams.query ?? []), paramInfo];
92
+ }
93
+ else if (param.in === "header" && !(param.name in this._headerParams)) {
94
+ missingParams.header = [...(missingParams.header ?? []), paramInfo];
95
+ }
96
+ }
97
+ if (Object.keys(missingParams).length === 0)
98
+ return undefined;
99
+ return missingParams;
100
+ }
101
+ help() {
102
+ const method = this._method ?? "[no method set]";
103
+ const operation = this.getOperation();
104
+ const lines = [];
105
+ lines.push(`${method} ${this.routePath}`);
106
+ if (operation?.summary) {
107
+ lines.push("");
108
+ lines.push("Summary:");
109
+ lines.push(` ${operation.summary}`);
110
+ }
111
+ if (operation?.description) {
112
+ lines.push("");
113
+ lines.push("Description:");
114
+ lines.push(` ${operation.description}`);
115
+ }
116
+ if (operation?.parameters && operation.parameters.length > 0) {
117
+ const pathParams = operation.parameters.filter((p) => p.in === "path");
118
+ const queryParams = operation.parameters.filter((p) => p.in === "query");
119
+ const headerParams = operation.parameters.filter((p) => p.in === "header");
120
+ if (pathParams.length > 0) {
121
+ lines.push("");
122
+ lines.push("Path Parameters:");
123
+ for (const p of pathParams) {
124
+ const paramType = p.type ?? p.schema?.type ?? "string";
125
+ const required = p.required ? "required" : "optional";
126
+ lines.push(` ${p.name} (${paramType}, ${required})`);
127
+ if (p.description)
128
+ lines.push(` Description: ${p.description}`);
129
+ if (p.enum)
130
+ lines.push(` Allowed values: ${p.enum.join(" | ")}`);
131
+ }
132
+ }
133
+ if (queryParams.length > 0) {
134
+ lines.push("");
135
+ lines.push("Query Parameters:");
136
+ for (const p of queryParams) {
137
+ const paramType = p.type ?? p.schema?.type ?? "string";
138
+ const required = p.required ? "required" : "optional";
139
+ lines.push(` ${p.name} (${paramType}, ${required})`);
140
+ if (p.description)
141
+ lines.push(` Description: ${p.description}`);
142
+ const enumValues = p.enum ?? p.schema?.enum;
143
+ if (enumValues)
144
+ lines.push(` Allowed values: ${enumValues.join(" | ")}`);
145
+ }
146
+ }
147
+ if (headerParams.length > 0) {
148
+ lines.push("");
149
+ lines.push("Headers:");
150
+ for (const p of headerParams) {
151
+ const paramType = p.type ?? p.schema?.type ?? "string";
152
+ const required = p.required ? "required" : "optional";
153
+ lines.push(` ${p.name} (${paramType}, ${required})`);
154
+ if (p.description)
155
+ lines.push(` Description: ${p.description}`);
156
+ }
157
+ }
158
+ }
159
+ if (operation?.responses) {
160
+ lines.push("");
161
+ lines.push("Responses:");
162
+ for (const [status, response] of Object.entries(operation.responses)) {
163
+ lines.push(` ${status}`);
164
+ if (response.description) {
165
+ lines.push(` Description: ${response.description}`);
166
+ }
167
+ }
168
+ }
169
+ return lines.join("\n");
170
+ }
171
+ async send() {
172
+ if (!this._method) {
173
+ throw new Error('No HTTP method set. Use .method("get") to set the method.');
174
+ }
175
+ const missing = this.missing();
176
+ if (missing) {
177
+ const lines = [
178
+ "Cannot execute request.",
179
+ "",
180
+ "Missing required parameters:",
181
+ ];
182
+ if (missing.path) {
183
+ lines.push(" path:");
184
+ for (const p of missing.path) {
185
+ lines.push(` - ${p.name} (${p.type ?? "string"})`);
186
+ }
187
+ }
188
+ if (missing.query) {
189
+ lines.push(" query:");
190
+ for (const p of missing.query) {
191
+ lines.push(` - ${p.name} (${p.type ?? "string"})`);
192
+ }
193
+ }
194
+ if (missing.header) {
195
+ lines.push(" header:");
196
+ for (const p of missing.header) {
197
+ lines.push(` - ${p.name} (${p.type ?? "string"})`);
198
+ }
199
+ }
200
+ throw new Error(lines.join("\n"));
201
+ }
202
+ // Build URL with path parameters substituted
203
+ let url = this.routePath;
204
+ for (const [key, value] of Object.entries(this._pathParams)) {
205
+ url = url.replaceAll(`{${key}}`, String(value));
206
+ }
207
+ // Append query string
208
+ const queryEntries = Object.entries(this._queryParams);
209
+ if (queryEntries.length > 0) {
210
+ const qs = new URLSearchParams(queryEntries.map(([k, v]) => [k, String(v)])).toString();
211
+ url = `${url}?${qs}`;
212
+ }
213
+ const client = new RawHttpClient(this._host, this._port);
214
+ const headers = Object.fromEntries(Object.entries(this._headerParams).map(([k, v]) => [k, String(v)]));
215
+ const method = this._method.toLowerCase();
216
+ switch (method) {
217
+ case "get":
218
+ return client.get(url, headers);
219
+ case "head":
220
+ return client.head(url, headers);
221
+ case "delete":
222
+ return client.delete(url, headers);
223
+ case "options":
224
+ return client.options(url, headers);
225
+ case "trace":
226
+ return client.trace(url, headers);
227
+ case "post":
228
+ return client.post(url, this._body, headers);
229
+ case "put":
230
+ return client.put(url, this._body, headers);
231
+ case "patch":
232
+ return client.patch(url, this._body, headers);
233
+ default:
234
+ throw new Error(`Unsupported HTTP method: ${this._method}`);
235
+ }
236
+ }
237
+ [Symbol.for("nodejs.util.inspect.custom")]() {
238
+ const method = this._method ?? "[no method set]";
239
+ const operation = this.getOperation();
240
+ const lines = [];
241
+ lines.push(`${method} ${this.routePath}`);
242
+ if (operation?.parameters) {
243
+ const pathParams = operation.parameters.filter((p) => p.in === "path");
244
+ const queryParams = operation.parameters.filter((p) => p.in === "query");
245
+ if (pathParams.length > 0) {
246
+ lines.push("");
247
+ lines.push("Path:");
248
+ for (const p of pathParams) {
249
+ const value = this._pathParams[p.name];
250
+ lines.push(` ${p.name}: ${value !== undefined ? String(value) : "[missing]"}`);
251
+ }
252
+ }
253
+ if (queryParams.length > 0) {
254
+ lines.push("");
255
+ lines.push("Query:");
256
+ for (const p of queryParams) {
257
+ const value = this._queryParams[p.name];
258
+ const label = p.required ? "[missing]" : "[optional]";
259
+ lines.push(` ${p.name}: ${value !== undefined ? String(value) : label}`);
260
+ }
261
+ }
262
+ }
263
+ lines.push("");
264
+ lines.push(`Ready: ${this.ready()}`);
265
+ return lines.join("\n");
266
+ }
267
+ }
268
+ export function createRouteFunction(port, host, openApiDocument) {
269
+ return (routePath) => new RouteBuilder(routePath, { host, openApiDocument, port });
270
+ }
@@ -1 +1 @@
1
- export const DUMMY_EXPORT_FOR_TEST_COVERAGE = 1;
1
+ export {};
@@ -1,10 +1,34 @@
1
- import cloneDeep from "lodash/cloneDeep.js";
2
1
  export class Context {
3
2
  constructor() { }
4
3
  }
5
4
  export function parentPath(path) {
6
5
  return String(path.split("/").slice(0, -1).join("/")) || "/";
7
6
  }
7
+ /**
8
+ * Deep-clones an object for caching purposes.
9
+ * Plain objects and class instances have their own enumerable properties
10
+ * recursively cloned (preserving the prototype chain). Functions are copied
11
+ * by reference since they are not structurally comparable data.
12
+ */
13
+ function cloneForCache(value) {
14
+ if (value === null || typeof value === "function") {
15
+ return value;
16
+ }
17
+ if (typeof value !== "object") {
18
+ return value;
19
+ }
20
+ if (Array.isArray(value)) {
21
+ return value.map(cloneForCache);
22
+ }
23
+ const proto = Object.getPrototypeOf(value);
24
+ const clone = proto !== null && proto !== Object.prototype
25
+ ? Object.create(proto)
26
+ : {};
27
+ for (const key of Object.keys(value)) {
28
+ clone[key] = cloneForCache(value[key]);
29
+ }
30
+ return clone;
31
+ }
8
32
  export class ContextRegistry {
9
33
  entries = new Map();
10
34
  cache = new Map();
@@ -23,7 +47,7 @@ export class ContextRegistry {
23
47
  }
24
48
  add(path, context) {
25
49
  this.entries.set(path, context);
26
- this.cache.set(path, cloneDeep(context));
50
+ this.cache.set(path, cloneForCache(context));
27
51
  }
28
52
  find(path) {
29
53
  return (this.getContextIgnoreCase(this.entries, path) ??
@@ -45,7 +69,7 @@ export class ContextRegistry {
45
69
  }
46
70
  }
47
71
  Object.setPrototypeOf(context, Object.getPrototypeOf(updatedContext));
48
- this.cache.set(path, cloneDeep(updatedContext));
72
+ this.cache.set(path, cloneForCache(updatedContext));
49
73
  }
50
74
  getAllPaths() {
51
75
  return Array.from(this.entries.keys());
@@ -255,8 +255,10 @@ type HttpStatusCode =
255
255
  interface OpenApiParameters {
256
256
  in: "body" | "cookie" | "formData" | "header" | "path" | "query";
257
257
  name: string;
258
+ required?: boolean;
258
259
  schema?: {
259
- type: string;
260
+ [key: string]: unknown;
261
+ type?: string;
260
262
  };
261
263
  type?: "string" | "number" | "integer" | "boolean";
262
264
  }
@@ -264,6 +266,14 @@ interface OpenApiParameters {
264
266
  interface OpenApiOperation {
265
267
  parameters?: OpenApiParameters[];
266
268
  produces?: string[];
269
+ requestBody?: {
270
+ content?: {
271
+ [mediaType: string]: {
272
+ schema: { [key: string]: unknown };
273
+ };
274
+ };
275
+ required?: boolean;
276
+ };
267
277
  responses: {
268
278
  [status: string]: {
269
279
  content?: {
@@ -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,7 @@ 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";
5
6
  import { Tools } from "./tools.js";
6
7
  const debug = createDebugger("counterfact:server:dispatcher");
7
8
  function parseCookies(cookieHeader) {
@@ -17,7 +18,8 @@ function parseCookies(cookieHeader) {
17
18
  try {
18
19
  cookies[key] = decodeURIComponent(value);
19
20
  }
20
- catch {
21
+ catch (error) {
22
+ debug("could not decode cookie value for key %s: %o", key, error);
21
23
  cookies[key] = value;
22
24
  }
23
25
  }
@@ -39,12 +41,12 @@ export class Dispatcher {
39
41
  }
40
42
  parameterTypes(parameters) {
41
43
  const types = {
42
- body: {},
43
- cookie: {},
44
- formData: {},
45
- header: {},
46
- path: {},
47
- query: {},
44
+ body: new Map(),
45
+ cookie: new Map(),
46
+ formData: new Map(),
47
+ header: new Map(),
48
+ path: new Map(),
49
+ query: new Map(),
48
50
  };
49
51
  if (!parameters) {
50
52
  return types;
@@ -52,8 +54,7 @@ export class Dispatcher {
52
54
  for (const parameter of parameters) {
53
55
  const type = parameter?.type;
54
56
  if (type !== undefined) {
55
- types[parameter.in][parameter.name] =
56
- type === "integer" ? "number" : type;
57
+ types[parameter.in].set(parameter.name, type === "integer" ? "number" : type);
57
58
  }
58
59
  }
59
60
  return types;
@@ -137,7 +138,7 @@ export class Dispatcher {
137
138
  // If the incoming path includes the base path, remove it
138
139
  if (this.openApiDocument?.basePath !== undefined &&
139
140
  path.toLowerCase().startsWith(this.openApiDocument.basePath.toLowerCase())) {
140
- path = path.replace(new RegExp(this.openApiDocument.basePath, "iu"), "");
141
+ path = path.slice(this.openApiDocument.basePath.length);
141
142
  }
142
143
  const { matchedPath } = this.registry.handler(path, method);
143
144
  if (!this.registry.exists(method, path) &&
@@ -150,6 +151,16 @@ export class Dispatcher {
150
151
  };
151
152
  }
152
153
  const operation = this.operationForPathAndMethod(matchedPath, method);
154
+ if (this.config?.validateRequests !== false) {
155
+ const validation = validateRequest(operation, { body, headers, query });
156
+ if (!validation.valid) {
157
+ return {
158
+ body: `Request validation failed:\n${validation.errors.join("\n")}`,
159
+ contentType: "text/plain",
160
+ status: 400,
161
+ };
162
+ }
163
+ }
153
164
  const continuousDistribution = (min, max) => {
154
165
  return min + Math.random() * (max - min);
155
166
  };
@@ -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
+ }
@@ -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,25 +6,20 @@ 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;
27
20
  contextRegistry;
28
21
  dependencyGraph = new ModuleDependencyGraph();
22
+ fileDiscovery;
29
23
  uncachedImport = async function (moduleName) {
30
24
  throw new Error(`uncachedImport not set up; importing ${moduleName}`);
31
25
  };
@@ -34,6 +28,7 @@ export class ModuleLoader extends EventTarget {
34
28
  this.basePath = basePath.replaceAll("\\", "/");
35
29
  this.registry = registry;
36
30
  this.contextRegistry = contextRegistry;
31
+ this.fileDiscovery = new FileDiscovery(this.basePath);
37
32
  }
38
33
  async watch() {
39
34
  this.watcher = watch(this.basePath, CHOKIDAR_OPTIONS).on("all", (eventName, pathNameOriginal) => {
@@ -69,27 +64,8 @@ export class ModuleLoader extends EventTarget {
69
64
  await this.watcher?.close();
70
65
  }
71
66
  async load(directory = "") {
72
- if (!existsSync(nodePath.join(this.basePath, directory).replaceAll("\\", "/"))) {
73
- throw new Error(`Directory does not exist ${this.basePath}`);
74
- }
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;
86
- }
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);
67
+ const files = await this.fileDiscovery.findFiles(directory);
68
+ await Promise.all(files.map((file) => this.loadEndpoint(file)));
93
69
  }
94
70
  async loadEndpoint(pathName) {
95
71
  debug("importing module: %s", pathName);