counterfact 2.8.1 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/README.md +39 -14
- package/bin/counterfact.js +18 -547
- package/bin/ts-loader.mjs +1 -0
- package/dist/api-runner.js +202 -0
- package/dist/app.js +72 -138
- package/dist/cli/banner.js +81 -0
- package/dist/cli/check-for-updates.js +45 -0
- package/dist/cli/run.js +304 -0
- package/dist/cli/telemetry.js +50 -0
- package/dist/migrate/paths-to-routes.js +1 -0
- package/dist/migrate/update-route-types.js +1 -0
- package/dist/msw.js +78 -0
- package/dist/repl/raw-http-client.js +3 -1
- package/dist/repl/repl.js +228 -60
- package/dist/server/determine-module-kind.js +1 -0
- package/dist/server/file-discovery.js +1 -0
- package/dist/server/module-loader.js +8 -0
- package/dist/server/transpiler.js +1 -0
- package/dist/server/{admin-api-middleware.js → web-server/admin-api-middleware.js} +19 -9
- package/dist/server/web-server/create-koa-app.js +68 -0
- package/dist/server/web-server/openapi-middleware.js +34 -0
- package/dist/server/{koa-middleware.js → web-server/routes-middleware.js} +11 -8
- package/dist/typescript-generator/code-generator.js +1 -0
- package/dist/typescript-generator/prune.js +1 -0
- package/dist/typescript-generator/repository.js +1 -0
- package/dist/typescript-generator/scenario-file-generator.js +1 -0
- package/dist/util/ensure-directory-exists.js +1 -0
- package/dist/util/load-config-file.js +2 -2
- package/dist/util/read-file.js +16 -2
- package/dist/util/runtime-can-execute-erasable-ts.js +1 -0
- package/package.json +3 -2
- package/dist/server/create-koa-app.js +0 -65
- package/dist/server/openapi-middleware.js +0 -48
package/dist/repl/repl.js
CHANGED
|
@@ -15,61 +15,125 @@ const ROUTE_BUILDER_METHODS = [
|
|
|
15
15
|
"ready(",
|
|
16
16
|
"send(",
|
|
17
17
|
];
|
|
18
|
+
function getScenarioCompletions(line, scenarioRegistry, groupedScenarioRegistries) {
|
|
19
|
+
function getPathCompletions(partial, registry) {
|
|
20
|
+
if (registry === undefined) {
|
|
21
|
+
return [[], partial];
|
|
22
|
+
}
|
|
23
|
+
const slashIdx = partial.lastIndexOf("/");
|
|
24
|
+
if (slashIdx === -1) {
|
|
25
|
+
const indexFunctions = registry.getExportedFunctionNames("index");
|
|
26
|
+
const fileKeys = registry.getFileKeys().filter((k) => k !== "index");
|
|
27
|
+
const topLevelPrefixes = [
|
|
28
|
+
...new Set(fileKeys.map((k) => k.split("/")[0] + "/")),
|
|
29
|
+
];
|
|
30
|
+
const allOptions = [...indexFunctions, ...topLevelPrefixes];
|
|
31
|
+
const matches = allOptions.filter((c) => c.startsWith(partial));
|
|
32
|
+
return [matches, partial];
|
|
33
|
+
}
|
|
34
|
+
const fileKey = partial.slice(0, slashIdx);
|
|
35
|
+
const funcPartial = partial.slice(slashIdx + 1);
|
|
36
|
+
const functions = registry.getExportedFunctionNames(fileKey);
|
|
37
|
+
const matches = functions
|
|
38
|
+
.filter((e) => e.startsWith(funcPartial))
|
|
39
|
+
.map((e) => `${fileKey}/${e}`);
|
|
40
|
+
return [matches, partial];
|
|
41
|
+
}
|
|
42
|
+
if (groupedScenarioRegistries !== undefined) {
|
|
43
|
+
if (!/^\.scenario(?:\s|$)/u.test(line)) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
const hasTrailingWhitespace = /\s$/u.test(line);
|
|
47
|
+
const args = line.trim().split(/\s+/u).slice(1);
|
|
48
|
+
const groupKeys = Object.keys(groupedScenarioRegistries);
|
|
49
|
+
if (args.length === 0) {
|
|
50
|
+
return [groupKeys, ""];
|
|
51
|
+
}
|
|
52
|
+
if (args.length === 1 && !hasTrailingWhitespace) {
|
|
53
|
+
const groupPartial = args[0] ?? "";
|
|
54
|
+
const matches = groupKeys.filter((key) => key.startsWith(groupPartial));
|
|
55
|
+
return [matches, groupPartial];
|
|
56
|
+
}
|
|
57
|
+
const selectedGroup = args[0] ?? "";
|
|
58
|
+
const selectedRegistry = groupedScenarioRegistries[selectedGroup];
|
|
59
|
+
if (selectedRegistry === undefined) {
|
|
60
|
+
const scenarioPartial = hasTrailingWhitespace
|
|
61
|
+
? ""
|
|
62
|
+
: (args[args.length - 1] ?? "");
|
|
63
|
+
return [[], scenarioPartial];
|
|
64
|
+
}
|
|
65
|
+
if (args.length === 1 && hasTrailingWhitespace) {
|
|
66
|
+
return getPathCompletions("", selectedRegistry);
|
|
67
|
+
}
|
|
68
|
+
if (args.length === 2 && !hasTrailingWhitespace) {
|
|
69
|
+
const scenarioPartial = args[1];
|
|
70
|
+
if (scenarioPartial === undefined) {
|
|
71
|
+
return [[], ""];
|
|
72
|
+
}
|
|
73
|
+
return getPathCompletions(scenarioPartial, selectedRegistry);
|
|
74
|
+
}
|
|
75
|
+
// More than two args (or trailing whitespace after the second arg) means
|
|
76
|
+
// no additional `.scenario` arguments are valid in multi-API mode.
|
|
77
|
+
return [[], args[args.length - 1] ?? ""];
|
|
78
|
+
}
|
|
79
|
+
const applyMatch = line.match(/^\.scenario\s+(?<partial>\S*)$/u);
|
|
80
|
+
if (!applyMatch) {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
const partial = applyMatch.groups?.["partial"] ?? "";
|
|
84
|
+
return getPathCompletions(partial, scenarioRegistry);
|
|
85
|
+
}
|
|
86
|
+
function getRouteBuilderMethodCompletions(line) {
|
|
87
|
+
const builderMatch = line.match(/route\(.*\)\.(?<partial>[a-zA-Z]*)$/u);
|
|
88
|
+
if (!builderMatch) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
const partial = builderMatch.groups?.["partial"] ?? "";
|
|
92
|
+
const matches = ROUTE_BUILDER_METHODS.filter((m) => m.startsWith(partial));
|
|
93
|
+
return [matches, partial];
|
|
94
|
+
}
|
|
95
|
+
function getRoutesForCompletion(registry, openApiDocument) {
|
|
96
|
+
const openApiPaths = openApiDocument
|
|
97
|
+
? Object.keys(openApiDocument.paths)
|
|
98
|
+
: [];
|
|
99
|
+
if (openApiPaths.length > 0) {
|
|
100
|
+
return openApiPaths;
|
|
101
|
+
}
|
|
102
|
+
return registry.routes.map((route) => route.path);
|
|
103
|
+
}
|
|
104
|
+
function getRouteCompletions(line, routes) {
|
|
105
|
+
const routeMatch = line.match(/(?:client\.(?:get|post|put|patch|delete)|route)\("(?<partial>[^"]*)$/u);
|
|
106
|
+
if (!routeMatch) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
const partial = routeMatch.groups?.["partial"] ?? "";
|
|
110
|
+
const matches = routes.filter((route) => route.startsWith(partial));
|
|
111
|
+
return [matches, partial];
|
|
112
|
+
}
|
|
18
113
|
/**
|
|
19
114
|
* Creates a tab-completion function for the REPL.
|
|
20
115
|
*
|
|
21
116
|
* @param registry - The route registry used to complete path arguments for `route()` and `client.*()` calls.
|
|
22
117
|
* @param fallback - Optional fallback completer (e.g. the Node.js built-in completer) invoked when no custom completion matches.
|
|
118
|
+
* @param openApiDocument - Optional OpenAPI document used as the source of route completions when available.
|
|
23
119
|
* @param scenarioRegistry - When provided, enables tab completion for `.scenario` commands by enumerating
|
|
24
120
|
* exported function names and file-key prefixes from the loaded scenario modules.
|
|
25
121
|
*/
|
|
26
|
-
export function createCompleter(registry, fallback, scenarioRegistry) {
|
|
122
|
+
export function createCompleter(registry, fallback, openApiDocument, scenarioRegistry, groupedScenarioRegistries) {
|
|
123
|
+
const routes = getRoutesForCompletion(registry, openApiDocument);
|
|
27
124
|
return (line, callback) => {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
}
|
|
125
|
+
const scenarioCompletions = getScenarioCompletions(line, scenarioRegistry, groupedScenarioRegistries);
|
|
126
|
+
if (scenarioCompletions !== undefined) {
|
|
127
|
+
callback(null, scenarioCompletions);
|
|
61
128
|
return;
|
|
62
129
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const partial = builderMatch.groups?.["partial"] ?? "";
|
|
67
|
-
const matches = ROUTE_BUILDER_METHODS.filter((m) => m.startsWith(partial));
|
|
68
|
-
callback(null, [matches, partial]);
|
|
130
|
+
const routeBuilderCompletions = getRouteBuilderMethodCompletions(line);
|
|
131
|
+
if (routeBuilderCompletions !== undefined) {
|
|
132
|
+
callback(null, routeBuilderCompletions);
|
|
69
133
|
return;
|
|
70
134
|
}
|
|
71
|
-
const
|
|
72
|
-
if (
|
|
135
|
+
const routeCompletions = getRouteCompletions(line, routes);
|
|
136
|
+
if (routeCompletions === undefined) {
|
|
73
137
|
if (fallback) {
|
|
74
138
|
fallback(line, callback);
|
|
75
139
|
}
|
|
@@ -78,10 +142,7 @@ export function createCompleter(registry, fallback, scenarioRegistry) {
|
|
|
78
142
|
}
|
|
79
143
|
return;
|
|
80
144
|
}
|
|
81
|
-
|
|
82
|
-
const routes = registry.routes.map((route) => route.path);
|
|
83
|
-
const matches = routes.filter((route) => route.startsWith(partial));
|
|
84
|
-
callback(null, [matches, partial]);
|
|
145
|
+
callback(null, routeCompletions);
|
|
85
146
|
};
|
|
86
147
|
}
|
|
87
148
|
/**
|
|
@@ -103,7 +164,52 @@ export function createCompleter(registry, fallback, scenarioRegistry) {
|
|
|
103
164
|
* @param scenarioRegistry - Optional scenario registry for `.scenario` support.
|
|
104
165
|
* @returns The configured Node.js REPL server instance.
|
|
105
166
|
*/
|
|
106
|
-
export function startRepl(contextRegistry, registry, config, print = printToStdout, openApiDocument, scenarioRegistry) {
|
|
167
|
+
export function startRepl(contextRegistry, registry, config, print = printToStdout, openApiDocument, scenarioRegistry, apiBindings) {
|
|
168
|
+
const bindings = apiBindings === undefined || apiBindings.length === 0
|
|
169
|
+
? [
|
|
170
|
+
{
|
|
171
|
+
contextRegistry,
|
|
172
|
+
group: "",
|
|
173
|
+
openApiDocument,
|
|
174
|
+
registry,
|
|
175
|
+
scenarioRegistry,
|
|
176
|
+
},
|
|
177
|
+
]
|
|
178
|
+
: apiBindings;
|
|
179
|
+
const isMultiApi = bindings.length > 1;
|
|
180
|
+
const groupedBindings = bindings.map((binding) => ({
|
|
181
|
+
...binding,
|
|
182
|
+
key: binding.group.trim(),
|
|
183
|
+
}));
|
|
184
|
+
if (isMultiApi) {
|
|
185
|
+
const invalidBindings = groupedBindings.filter((binding) => binding.key === "");
|
|
186
|
+
if (invalidBindings.length > 0) {
|
|
187
|
+
throw new Error("Each API binding must define a non-empty group when multiple APIs are configured.");
|
|
188
|
+
}
|
|
189
|
+
const seenGroups = new Set();
|
|
190
|
+
const duplicateGroups = new Set();
|
|
191
|
+
for (const binding of groupedBindings) {
|
|
192
|
+
if (seenGroups.has(binding.key)) {
|
|
193
|
+
duplicateGroups.add(binding.key);
|
|
194
|
+
}
|
|
195
|
+
seenGroups.add(binding.key);
|
|
196
|
+
}
|
|
197
|
+
if (duplicateGroups.size > 0) {
|
|
198
|
+
throw new Error(`Duplicate API groups are not allowed when multiple APIs are configured (duplicate groups: ${[...duplicateGroups].join(", ")}).`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const rootBinding = groupedBindings[0];
|
|
202
|
+
if (rootBinding === undefined) {
|
|
203
|
+
throw new Error("startRepl requires at least one API binding");
|
|
204
|
+
}
|
|
205
|
+
const groupedLoadContext = Object.fromEntries(groupedBindings.map((binding) => [
|
|
206
|
+
binding.key,
|
|
207
|
+
(path) => binding.contextRegistry.find(path),
|
|
208
|
+
]));
|
|
209
|
+
const groupedRoute = Object.fromEntries(groupedBindings.map((binding) => [
|
|
210
|
+
binding.key,
|
|
211
|
+
createRouteFunction(config.port, "localhost", binding.openApiDocument),
|
|
212
|
+
]));
|
|
107
213
|
function printProxyStatus() {
|
|
108
214
|
if (config.proxyUrl === "") {
|
|
109
215
|
print("The proxy URL is not set.");
|
|
@@ -147,7 +253,12 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
147
253
|
const builtinCompleter = replServer.completer;
|
|
148
254
|
// completer is typed as readonly in @types/node but is writable at runtime
|
|
149
255
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
150
|
-
replServer.completer = createCompleter(registry, builtinCompleter, scenarioRegistry
|
|
256
|
+
replServer.completer = createCompleter(rootBinding.registry, builtinCompleter, rootBinding.openApiDocument, rootBinding.scenarioRegistry, isMultiApi
|
|
257
|
+
? Object.fromEntries(groupedBindings.map((binding) => [
|
|
258
|
+
binding.key,
|
|
259
|
+
binding.scenarioRegistry,
|
|
260
|
+
]))
|
|
261
|
+
: undefined);
|
|
151
262
|
replServer.defineCommand("counterfact", {
|
|
152
263
|
action() {
|
|
153
264
|
print("This is a read-eval-print loop (REPL), the same as the one you get when you run node with no arguments.");
|
|
@@ -186,17 +297,63 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
186
297
|
},
|
|
187
298
|
help: 'proxy configuration (".proxy help" for details)',
|
|
188
299
|
});
|
|
189
|
-
replServer.context.loadContext =
|
|
190
|
-
|
|
300
|
+
replServer.context.loadContext = isMultiApi
|
|
301
|
+
? groupedLoadContext
|
|
302
|
+
: groupedLoadContext[rootBinding.key];
|
|
303
|
+
replServer.context.context = isMultiApi
|
|
304
|
+
? Object.fromEntries(groupedBindings.map((binding) => [
|
|
305
|
+
binding.key,
|
|
306
|
+
binding.contextRegistry.find("/"),
|
|
307
|
+
]))
|
|
308
|
+
: rootBinding.contextRegistry.find("/");
|
|
191
309
|
replServer.context.client = new RawHttpClient("localhost", config.port);
|
|
192
310
|
replServer.context.RawHttpClient = RawHttpClient;
|
|
193
|
-
replServer.context.route =
|
|
194
|
-
|
|
311
|
+
replServer.context.route = isMultiApi
|
|
312
|
+
? groupedRoute
|
|
313
|
+
: groupedRoute[rootBinding.key];
|
|
314
|
+
replServer.context.routes = isMultiApi
|
|
315
|
+
? Object.fromEntries(groupedBindings.map((binding) => [binding.key, {}]))
|
|
316
|
+
: {};
|
|
195
317
|
replServer.defineCommand("scenario", {
|
|
196
318
|
async action(text) {
|
|
197
|
-
const
|
|
319
|
+
const trimmedText = text.trim();
|
|
320
|
+
const parsedArgs = trimmedText.split(/\s+/u).filter(Boolean);
|
|
321
|
+
const usage = isMultiApi
|
|
322
|
+
? "usage: .scenario <group> <path>"
|
|
323
|
+
: "usage: .scenario <path>";
|
|
324
|
+
const { selectedBinding, scenarioPath } = (() => {
|
|
325
|
+
if (!isMultiApi) {
|
|
326
|
+
if (trimmedText === "") {
|
|
327
|
+
return { scenarioPath: undefined, selectedBinding: undefined };
|
|
328
|
+
}
|
|
329
|
+
return { scenarioPath: trimmedText, selectedBinding: rootBinding };
|
|
330
|
+
}
|
|
331
|
+
if (parsedArgs.length !== 2) {
|
|
332
|
+
return { scenarioPath: undefined, selectedBinding: undefined };
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
scenarioPath: parsedArgs[1],
|
|
336
|
+
selectedBinding: groupedBindings.find((binding) => binding.key === parsedArgs[0]),
|
|
337
|
+
};
|
|
338
|
+
})();
|
|
339
|
+
if (selectedBinding === undefined || scenarioPath === undefined) {
|
|
340
|
+
if (isMultiApi &&
|
|
341
|
+
scenarioPath !== undefined &&
|
|
342
|
+
selectedBinding === undefined) {
|
|
343
|
+
const groupName = parsedArgs[0] ?? "";
|
|
344
|
+
const availableGroups = groupedBindings.map((binding) => binding.key);
|
|
345
|
+
print(`Error: Unknown API group "${groupName}". Available groups: ${availableGroups.join(", ")}`);
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
print(usage);
|
|
349
|
+
}
|
|
350
|
+
this.clearBufferedCommand();
|
|
351
|
+
this.displayPrompt();
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const parts = scenarioPath.split("/").filter(Boolean);
|
|
198
355
|
if (parts.length === 0) {
|
|
199
|
-
print(
|
|
356
|
+
print(usage);
|
|
200
357
|
this.clearBufferedCommand();
|
|
201
358
|
this.displayPrompt();
|
|
202
359
|
return;
|
|
@@ -209,7 +366,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
209
366
|
}
|
|
210
367
|
const functionName = parts[parts.length - 1] ?? "";
|
|
211
368
|
const fileKey = parts.length === 1 ? "index" : parts.slice(0, -1).join("/");
|
|
212
|
-
const module = scenarioRegistry?.getModule(fileKey);
|
|
369
|
+
const module = selectedBinding.scenarioRegistry?.getModule(fileKey);
|
|
213
370
|
if (module === undefined) {
|
|
214
371
|
print(`Error: Could not find scenario file "${fileKey}"`);
|
|
215
372
|
this.clearBufferedCommand();
|
|
@@ -224,11 +381,20 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
224
381
|
return;
|
|
225
382
|
}
|
|
226
383
|
try {
|
|
384
|
+
const selectedRoutes = isMultiApi
|
|
385
|
+
? replServer.context["routes"][selectedBinding.key]
|
|
386
|
+
: replServer.context["routes"];
|
|
387
|
+
if (isMultiApi && selectedRoutes === undefined) {
|
|
388
|
+
print(`Error: Could not resolve routes for API group "${selectedBinding.key}"`);
|
|
389
|
+
this.clearBufferedCommand();
|
|
390
|
+
this.displayPrompt();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
227
393
|
const applyContext = {
|
|
228
|
-
context:
|
|
229
|
-
loadContext:
|
|
230
|
-
route:
|
|
231
|
-
routes:
|
|
394
|
+
context: selectedBinding.contextRegistry.find("/"),
|
|
395
|
+
loadContext: ((path) => selectedBinding.contextRegistry.find(path)),
|
|
396
|
+
route: groupedRoute[selectedBinding.key],
|
|
397
|
+
routes: selectedRoutes,
|
|
232
398
|
};
|
|
233
399
|
await fn(applyContext);
|
|
234
400
|
print(`Applied ${text.trim()}`);
|
|
@@ -239,7 +405,9 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
239
405
|
this.clearBufferedCommand();
|
|
240
406
|
this.displayPrompt();
|
|
241
407
|
},
|
|
242
|
-
help:
|
|
408
|
+
help: isMultiApi
|
|
409
|
+
? 'apply a scenario script (".scenario <group> <path>" calls the named export from that group\'s scenarios/)'
|
|
410
|
+
: 'apply a scenario script (".scenario <path>" calls the named export from scenarios/)',
|
|
243
411
|
});
|
|
244
412
|
return replServer;
|
|
245
413
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
/* eslint-disable security/detect-non-literal-fs-filename -- module kind detection only probes package.json while walking parent directories. */
|
|
4
5
|
const DEFAULT_MODULE_KIND = "commonjs";
|
|
5
6
|
/**
|
|
6
7
|
* Determines whether a module file should be treated as CommonJS or ESM.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
|
+
/* eslint-disable security/detect-non-literal-fs-filename -- discovery walks directories rooted at basePath and uses Dirent-provided names. */
|
|
3
4
|
import { toForwardSlashPath, pathJoin } from "../util/forward-slash-path.js";
|
|
4
5
|
import { escapePathForWindows } from "../util/windows-escape.js";
|
|
5
6
|
const JS_EXTENSIONS = new Set(["cjs", "cts", "js", "mjs", "mts", "ts"]);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { once } from "node:events";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import nodePath, { basename } from "node:path";
|
|
4
|
+
/* eslint-disable security/detect-non-literal-fs-filename -- readJson resolves paths against the current context directory before file access. */
|
|
4
5
|
import { watch } from "chokidar";
|
|
5
6
|
import createDebug from "debug";
|
|
6
7
|
import { CHOKIDAR_OPTIONS } from "./constants.js";
|
|
@@ -185,6 +186,13 @@ export class ModuleLoader extends EventTarget {
|
|
|
185
186
|
const isSyntaxError = importError instanceof SyntaxError ||
|
|
186
187
|
String(importError).startsWith("SyntaxError:");
|
|
187
188
|
const displayPath = pathRelative(process.cwd(), unescapePathForWindows(pathName));
|
|
189
|
+
if (this.isContextFile(pathName)) {
|
|
190
|
+
const warning = isSyntaxError
|
|
191
|
+
? `Warning: There is a syntax error in the context file: ${displayPath}`
|
|
192
|
+
: `Warning: There was an error loading the context file: ${displayPath}`;
|
|
193
|
+
process.stdout.write(`\n${warning}\n`);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
188
196
|
const message = isSyntaxError
|
|
189
197
|
? `There is a syntax error in the route file: ${displayPath}`
|
|
190
198
|
: `There was an error loading the route file: ${displayPath}`;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Stryker disable all
|
|
2
2
|
import { once } from "node:events";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
|
+
/* eslint-disable security/detect-non-literal-fs-filename -- transpiler consumes watched source files and writes paired outputs under configured directories. */
|
|
4
5
|
import { watch as chokidarWatch } from "chokidar";
|
|
5
6
|
import createDebug from "debug";
|
|
6
7
|
import ts from "typescript";
|
|
@@ -24,16 +24,25 @@ function isLoopbackIp(ip) {
|
|
|
24
24
|
/**
|
|
25
25
|
* Admin API middleware for programmatic access to Counterfact internals.
|
|
26
26
|
* Exposes context management, proxy configuration, and route discovery
|
|
27
|
-
* through HTTP endpoints at
|
|
27
|
+
* through HTTP endpoints at the given path prefix.
|
|
28
28
|
*
|
|
29
29
|
* This enables AI agents and external tools to interact with the mock server
|
|
30
30
|
* in the same way the REPL does, but via HTTP requests.
|
|
31
|
+
*
|
|
32
|
+
* @param pathPrefix - The URL path prefix at which the admin API is mounted,
|
|
33
|
+
* e.g. `"/_counterfact/api"`. Requests to paths that do not start with this
|
|
34
|
+
* prefix fall through to the next middleware.
|
|
35
|
+
* @param registry - The route registry used to list available routes.
|
|
36
|
+
* @param contextRegistry - The context registry used to read and update
|
|
37
|
+
* per-path context objects.
|
|
38
|
+
* @param config - Server configuration (proxy settings, port, etc.).
|
|
39
|
+
* @returns A Koa middleware function.
|
|
31
40
|
*/
|
|
32
|
-
export function adminApiMiddleware(registry, contextRegistry, config) {
|
|
41
|
+
export function adminApiMiddleware(pathPrefix, registry, contextRegistry, config) {
|
|
33
42
|
return async (ctx, next) => {
|
|
34
43
|
const { pathname } = ctx.URL;
|
|
35
|
-
// Only handle admin API routes
|
|
36
|
-
if (!pathname.startsWith(
|
|
44
|
+
// Only handle admin API routes (exact prefix or paths beneath it)
|
|
45
|
+
if (pathname !== pathPrefix && !pathname.startsWith(`${pathPrefix}/`)) {
|
|
37
46
|
return await next();
|
|
38
47
|
}
|
|
39
48
|
// ===== Admin API Access Guard =====
|
|
@@ -72,9 +81,10 @@ export function adminApiMiddleware(registry, contextRegistry, config) {
|
|
|
72
81
|
}
|
|
73
82
|
}
|
|
74
83
|
debug("Admin API request: %s %s", ctx.method, pathname);
|
|
75
|
-
// Extract
|
|
76
|
-
const
|
|
77
|
-
const
|
|
84
|
+
// Extract the resource path after the prefix
|
|
85
|
+
const subPath = pathname.slice(pathPrefix.length);
|
|
86
|
+
const parts = subPath.split("/").filter(Boolean);
|
|
87
|
+
const [resource, ...rest] = parts;
|
|
78
88
|
try {
|
|
79
89
|
// ===== Health Check =====
|
|
80
90
|
if (resource === "health" && ctx.method === "GET") {
|
|
@@ -83,7 +93,7 @@ export function adminApiMiddleware(registry, contextRegistry, config) {
|
|
|
83
93
|
port: config.port,
|
|
84
94
|
uptime: process.uptime(),
|
|
85
95
|
basePath: config.basePath,
|
|
86
|
-
|
|
96
|
+
prefix: config.prefix,
|
|
87
97
|
};
|
|
88
98
|
return;
|
|
89
99
|
}
|
|
@@ -155,7 +165,7 @@ export function adminApiMiddleware(registry, contextRegistry, config) {
|
|
|
155
165
|
openApiPath: config.openApiPath,
|
|
156
166
|
port: config.port,
|
|
157
167
|
proxyUrl: config.proxyUrl,
|
|
158
|
-
|
|
168
|
+
prefix: config.prefix,
|
|
159
169
|
startAdminApi: config.startAdminApi,
|
|
160
170
|
startRepl: config.startRepl,
|
|
161
171
|
startServer: config.startServer,
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import createDebug from "debug";
|
|
2
|
+
import Koa from "koa";
|
|
3
|
+
import bodyParser from "koa-bodyparser";
|
|
4
|
+
import { koaSwagger } from "koa2-swagger-ui";
|
|
5
|
+
import { adminApiMiddleware } from "./admin-api-middleware.js";
|
|
6
|
+
import { routesMiddleware } from "./routes-middleware.js";
|
|
7
|
+
import { openapiMiddleware } from "./openapi-middleware.js";
|
|
8
|
+
const debug = createDebug("counterfact:server:create-koa-app");
|
|
9
|
+
/**
|
|
10
|
+
* Builds and configures the Koa application with all built-in middleware.
|
|
11
|
+
*
|
|
12
|
+
* The middleware stack (in order) is:
|
|
13
|
+
* 1. Per runner: OpenAPI document serving at `/counterfact/openapi${runner.subdirectory}`
|
|
14
|
+
* 2. Per runner: Swagger UI at `/counterfact/swagger${runner.subdirectory}`
|
|
15
|
+
* 3. Per runner: Admin API (when `config.startAdminApi` is `true`) at `/_counterfact/api${runner.subdirectory}`
|
|
16
|
+
* 4. Redirect `/counterfact` → `/counterfact/swagger`
|
|
17
|
+
* 5. Body parser
|
|
18
|
+
* 6. JSON serialisation of object bodies
|
|
19
|
+
* 7. Per runner: Route-dispatching middleware at `runner.prefix`
|
|
20
|
+
*
|
|
21
|
+
* @param runners - The ApiRunner instances, one per API spec.
|
|
22
|
+
* @param config - Server configuration.
|
|
23
|
+
* @returns A configured Koa application (not yet listening).
|
|
24
|
+
*/
|
|
25
|
+
export function createKoaApp({ runners, config, }) {
|
|
26
|
+
const app = new Koa();
|
|
27
|
+
for (const runner of runners) {
|
|
28
|
+
app.use(openapiMiddleware(`/counterfact/openapi${runner.subdirectory}`, {
|
|
29
|
+
path: runner.openApiPath,
|
|
30
|
+
baseUrl: `//localhost:${config.port}${runner.prefix}`,
|
|
31
|
+
}));
|
|
32
|
+
app.use(koaSwagger({
|
|
33
|
+
routePrefix: `/counterfact/swagger${runner.subdirectory}`,
|
|
34
|
+
swaggerOptions: {
|
|
35
|
+
url: `/counterfact/openapi${runner.subdirectory}`,
|
|
36
|
+
},
|
|
37
|
+
}));
|
|
38
|
+
if (config.startAdminApi) {
|
|
39
|
+
app.use(adminApiMiddleware(`/_counterfact/api${runner.subdirectory}`, runner.registry, runner.contextRegistry, config));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
debug("basePath: %s", config.basePath);
|
|
43
|
+
app.use(async (ctx, next) => {
|
|
44
|
+
if (ctx.URL.pathname === "/counterfact") {
|
|
45
|
+
ctx.redirect("/counterfact/swagger");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
await next();
|
|
49
|
+
});
|
|
50
|
+
app.use(bodyParser());
|
|
51
|
+
app.use(async (ctx, next) => {
|
|
52
|
+
await next();
|
|
53
|
+
if (ctx.body !== null &&
|
|
54
|
+
ctx.body !== undefined &&
|
|
55
|
+
typeof ctx.body === "object" &&
|
|
56
|
+
!Buffer.isBuffer(ctx.body)) {
|
|
57
|
+
ctx.body = JSON.stringify(ctx.body, null, 2);
|
|
58
|
+
ctx.type = "application/json";
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
for (const runner of runners) {
|
|
62
|
+
app.use(routesMiddleware(runner.prefix, runner.dispatcher, {
|
|
63
|
+
proxyPaths: config.proxyPaths,
|
|
64
|
+
proxyUrl: config.proxyUrl,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
return app;
|
|
68
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { bundle } from "@apidevtools/json-schema-ref-parser";
|
|
2
|
+
import { dump } from "js-yaml";
|
|
3
|
+
/**
|
|
4
|
+
* Returns a Koa middleware that serves a bundled OpenAPI document as YAML at
|
|
5
|
+
* the given `pathPrefix`.
|
|
6
|
+
*
|
|
7
|
+
* The served document is augmented with a `servers` entry (OpenAPI 3.x) and a
|
|
8
|
+
* `host` field (OpenAPI 2.x / Swagger) so that the Swagger UI can send
|
|
9
|
+
* requests to the running Counterfact instance.
|
|
10
|
+
*
|
|
11
|
+
* @param pathPrefix - The URL path at which to serve the document, e.g.
|
|
12
|
+
* `"/counterfact/openapi"`. Requests to any other path fall through to the
|
|
13
|
+
* next middleware.
|
|
14
|
+
* @param document - Descriptor providing `path` (file path or URL to the
|
|
15
|
+
* source OpenAPI document) and `baseUrl` (the base URL to inject, e.g.
|
|
16
|
+
* `"//localhost:3100/api"`).
|
|
17
|
+
* @returns A Koa middleware function.
|
|
18
|
+
*/
|
|
19
|
+
export function openapiMiddleware(pathPrefix, document) {
|
|
20
|
+
return async (ctx, next) => {
|
|
21
|
+
if (ctx.URL.pathname !== pathPrefix) {
|
|
22
|
+
return await next();
|
|
23
|
+
}
|
|
24
|
+
const openApiDocument = (await bundle(document.path));
|
|
25
|
+
openApiDocument.servers ??= [];
|
|
26
|
+
openApiDocument.servers.unshift({
|
|
27
|
+
description: "Counterfact",
|
|
28
|
+
url: document.baseUrl,
|
|
29
|
+
});
|
|
30
|
+
// OpenApi 2 support:
|
|
31
|
+
openApiDocument.host = document.baseUrl;
|
|
32
|
+
ctx.body = dump(openApiDocument);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import createDebug from "debug";
|
|
2
2
|
import koaProxy from "koa-proxies";
|
|
3
|
-
import { isProxyEnabledForPath } from "
|
|
3
|
+
import { isProxyEnabledForPath } from "../is-proxy-enabled-for-path.js";
|
|
4
4
|
const debug = createDebug("counterfact:server:create-koa-app");
|
|
5
5
|
const HTTP_STATUS_CODE_OK = 200;
|
|
6
6
|
const HEADERS_TO_DROP = new Set([
|
|
@@ -45,29 +45,32 @@ function getAuthObject(ctx) {
|
|
|
45
45
|
* the Counterfact {@link Dispatcher}.
|
|
46
46
|
*
|
|
47
47
|
* Responsibilities:
|
|
48
|
-
* - Respects `
|
|
48
|
+
* - Respects `prefix` — requests outside the prefix are passed to `next`.
|
|
49
49
|
* - Adds CORS headers to every response.
|
|
50
50
|
* - Handles `OPTIONS` pre-flight requests (200 with CORS headers, no body).
|
|
51
51
|
* - Proxies the request upstream when proxy is enabled for the path.
|
|
52
52
|
* - Forwards the request to the dispatcher and maps the response back onto
|
|
53
53
|
* the Koa context.
|
|
54
54
|
*
|
|
55
|
+
* @param prefix - The URL path prefix that this middleware handles, e.g.
|
|
56
|
+
* `"/api/v1"`. Requests to paths that do not start with this prefix fall
|
|
57
|
+
* through to the next middleware.
|
|
55
58
|
* @param dispatcher - The {@link Dispatcher} instance that handles requests.
|
|
56
|
-
* @param config - Server configuration (proxy settings,
|
|
59
|
+
* @param config - Server configuration (proxy settings, etc.).
|
|
57
60
|
* @param proxy - Proxy factory; injectable for testing.
|
|
58
61
|
* @returns A Koa middleware function.
|
|
59
62
|
*/
|
|
60
|
-
export function routesMiddleware(dispatcher, config, proxy = koaProxy) {
|
|
63
|
+
export function routesMiddleware(prefix, dispatcher, config, proxy = koaProxy) {
|
|
61
64
|
return async function middleware(ctx, next) {
|
|
62
|
-
const { proxyUrl
|
|
65
|
+
const { proxyUrl } = config;
|
|
63
66
|
debug("middleware running for path: %s", ctx.request.path);
|
|
64
|
-
debug("
|
|
65
|
-
if (!ctx.request.path.startsWith(
|
|
67
|
+
debug("prefix: %s", prefix);
|
|
68
|
+
if (!ctx.request.path.startsWith(prefix)) {
|
|
66
69
|
return await next();
|
|
67
70
|
}
|
|
68
71
|
const auth = getAuthObject(ctx);
|
|
69
72
|
const { body, headers, query, rawBody } = ctx.request;
|
|
70
|
-
const path = ctx.request.path.slice(
|
|
73
|
+
const path = ctx.request.path.slice(prefix.length);
|
|
71
74
|
const method = ctx.request.method;
|
|
72
75
|
if (isProxyEnabledForPath(path, config) && proxyUrl) {
|
|
73
76
|
return proxy("/", { changeOrigin: true, target: proxyUrl })(ctx, next);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import nodePath from "node:path";
|
|
4
|
+
/* eslint-disable security/detect-non-literal-fs-filename -- generated files are written under the caller-provided destination tree. */
|
|
4
5
|
import { watch } from "chokidar";
|
|
5
6
|
import createDebug from "debug";
|
|
6
7
|
import { CHOKIDAR_OPTIONS } from "../server/constants.js";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import nodePath from "node:path";
|
|
3
|
+
/* eslint-disable security/detect-non-literal-fs-filename -- pruning only traverses and removes files under destination/routes. */
|
|
3
4
|
import createDebug from "debug";
|
|
4
5
|
import { toForwardSlashPath } from "../util/forward-slash-path.js";
|
|
5
6
|
const debug = createDebug("counterfact:typescript-generator:prune");
|
|
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import nodePath, { dirname } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
/* eslint-disable security/detect-non-literal-fs-filename -- repository writes and stats generated files only inside destination output directories. */
|
|
5
6
|
import createDebug from "debug";
|
|
6
7
|
import { ensureDirectoryExists } from "../util/ensure-directory-exists.js";
|
|
7
8
|
import { toForwardSlashPath, pathJoin, pathRelative, pathDirname, } from "../util/forward-slash-path.js";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import nodePath from "node:path";
|
|
4
|
+
/* eslint-disable security/detect-non-literal-fs-filename -- scenario files are discovered and generated under the configured destination tree. */
|
|
4
5
|
import { watch } from "chokidar";
|
|
5
6
|
import { CHOKIDAR_OPTIONS } from "../server/constants.js";
|
|
6
7
|
import { pathRelative } from "../util/forward-slash-path.js";
|