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.
- package/README.md +14 -207
- package/bin/README.md +24 -4
- package/bin/counterfact.js +54 -3
- package/dist/app.js +81 -28
- package/dist/counterfact-types/cookie-options.js +1 -0
- package/dist/counterfact-types/counterfact-response.js +7 -0
- package/dist/counterfact-types/example-names.js +1 -0
- package/dist/counterfact-types/example.js +1 -0
- package/dist/counterfact-types/generic-response-builder.js +1 -0
- package/dist/counterfact-types/http-status-code.js +1 -0
- package/dist/counterfact-types/if-has-key.js +1 -0
- package/dist/counterfact-types/index.js +0 -1
- package/dist/counterfact-types/maybe-promise.js +1 -0
- package/dist/counterfact-types/media-type.js +1 -0
- package/dist/counterfact-types/omit-all.js +1 -0
- package/dist/counterfact-types/omit-value-when-never.js +1 -0
- package/dist/counterfact-types/open-api-content.js +1 -0
- package/dist/counterfact-types/open-api-operation.js +1 -0
- package/dist/counterfact-types/open-api-parameters.js +1 -0
- package/dist/counterfact-types/open-api-response.js +1 -0
- package/dist/counterfact-types/random-function.js +1 -0
- package/dist/counterfact-types/response-builder-factory.js +1 -0
- package/dist/counterfact-types/response-builder.js +1 -0
- package/dist/counterfact-types/wide-operation-argument.js +1 -0
- package/dist/counterfact-types/wide-response-builder.js +1 -0
- package/dist/migrate/update-route-types.js +2 -3
- package/dist/repl/raw-http-client.js +19 -0
- package/dist/repl/repl.js +116 -4
- package/dist/repl/route-builder.js +68 -0
- package/dist/server/constants.js +8 -0
- package/dist/server/context-registry.js +70 -1
- package/dist/server/counterfact-types/cookie-options.ts +14 -0
- package/dist/server/counterfact-types/counterfact-response.ts +15 -0
- package/dist/server/counterfact-types/example-names.ts +13 -0
- package/dist/server/counterfact-types/example.ts +10 -0
- package/dist/server/counterfact-types/generic-response-builder.ts +164 -0
- package/dist/server/counterfact-types/http-status-code.ts +62 -0
- package/dist/server/counterfact-types/if-has-key.ts +19 -0
- package/dist/server/counterfact-types/index.ts +20 -338
- package/dist/server/counterfact-types/maybe-promise.ts +6 -0
- package/dist/server/counterfact-types/media-type.ts +6 -0
- package/dist/server/counterfact-types/omit-all.ts +11 -0
- package/dist/server/counterfact-types/omit-value-when-never.ts +11 -0
- package/dist/server/counterfact-types/open-api-content.ts +8 -0
- package/dist/server/counterfact-types/open-api-operation.ts +36 -0
- package/dist/server/counterfact-types/open-api-parameters.ts +16 -0
- package/dist/server/counterfact-types/open-api-response.ts +22 -0
- package/dist/server/counterfact-types/random-function.ts +9 -0
- package/dist/server/counterfact-types/response-builder-factory.ts +16 -0
- package/dist/server/counterfact-types/response-builder.ts +31 -0
- package/dist/server/counterfact-types/wide-operation-argument.ts +17 -0
- package/dist/server/counterfact-types/wide-response-builder.ts +26 -0
- package/dist/server/create-koa-app.js +28 -24
- package/dist/server/determine-module-kind.js +13 -0
- package/dist/server/dispatcher.js +64 -5
- package/dist/server/file-discovery.js +20 -9
- package/dist/server/is-proxy-enabled-for-path.js +12 -0
- package/dist/server/json-to-xml.js +11 -1
- package/dist/server/koa-middleware.js +25 -2
- package/dist/server/load-openapi-document.js +6 -0
- package/dist/server/module-dependency-graph.js +25 -0
- package/dist/server/module-loader.js +112 -17
- package/dist/server/module-tree.js +36 -0
- package/dist/server/openapi-document.js +69 -0
- package/dist/server/openapi-middleware.js +34 -5
- package/dist/server/openapi-watcher.js +35 -0
- package/dist/server/registry.js +89 -0
- package/dist/server/request-validator.js +3 -7
- package/dist/server/response-builder.js +18 -0
- package/dist/server/response-validator.js +58 -0
- package/dist/server/scenario-registry.js +55 -0
- package/dist/server/tools.js +29 -2
- package/dist/server/transpiler.js +23 -9
- package/dist/typescript-generator/code-generator.js +117 -4
- package/dist/typescript-generator/coder.js +80 -2
- package/dist/typescript-generator/operation-coder.js +13 -5
- package/dist/typescript-generator/operation-type-coder.js +40 -53
- package/dist/typescript-generator/parameters-type-coder.js +2 -4
- package/dist/typescript-generator/prune.js +2 -1
- package/dist/typescript-generator/read-only-comments.js +1 -1
- package/dist/typescript-generator/repository.js +76 -20
- package/dist/typescript-generator/requirement.js +77 -1
- package/dist/typescript-generator/reserved-words.js +50 -0
- package/dist/typescript-generator/scenario-file-generator.js +235 -0
- package/dist/typescript-generator/script.js +70 -7
- package/dist/typescript-generator/specification.js +27 -0
- package/dist/util/ensure-directory-exists.js +7 -0
- package/dist/util/forward-slash-path.js +63 -0
- package/dist/util/load-config-file.js +44 -0
- package/dist/util/read-file.js +11 -0
- package/dist/util/runtime-can-execute-erasable-ts.js +11 -0
- package/dist/util/windows-escape.js +18 -0
- package/package.json +9 -10
- package/dist/client/README.md +0 -14
- package/dist/client/index.html.hbs +0 -244
- package/dist/client/rapi-doc.html.hbs +0 -36
- package/dist/server/page-middleware.js +0 -23
- package/dist/typescript-generator/generate.js +0 -63
|
@@ -1,15 +1,37 @@
|
|
|
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";
|
|
6
|
+
import { routesMiddleware } from "./koa-middleware.js";
|
|
7
7
|
import { openapiMiddleware } from "./openapi-middleware.js";
|
|
8
|
-
import { pageMiddleware } from "./page-middleware.js";
|
|
9
8
|
const debug = createDebug("counterfact:server:create-koa-app");
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Builds and configures the Koa application with all built-in middleware.
|
|
11
|
+
*
|
|
12
|
+
* The middleware stack (in order) is:
|
|
13
|
+
* 1. OpenAPI document serving at `/counterfact/openapi`
|
|
14
|
+
* 2. Swagger UI at `/counterfact/swagger`
|
|
15
|
+
* 3. Admin API (when `config.startAdminApi` is `true`) at `/_counterfact/api/`
|
|
16
|
+
* 4. Redirect `/counterfact` → `/counterfact/swagger`
|
|
17
|
+
* 5. Body parser
|
|
18
|
+
* 6. JSON serialisation of object bodies
|
|
19
|
+
* 7. Route-dispatching middleware
|
|
20
|
+
*
|
|
21
|
+
* @param config - Server configuration.
|
|
22
|
+
* @param dispatcher - The Dispatcher used to build the route-dispatching middleware.
|
|
23
|
+
* @param registry - The route Registry used to build the admin API middleware.
|
|
24
|
+
* @param contextRegistry - The ContextRegistry used to build the admin API middleware.
|
|
25
|
+
* @returns A configured Koa application (not yet listening).
|
|
26
|
+
*/
|
|
27
|
+
export function createKoaApp({ config, contextRegistry, dispatcher, registry, }) {
|
|
11
28
|
const app = new Koa();
|
|
12
|
-
app.use(openapiMiddleware(
|
|
29
|
+
app.use(openapiMiddleware([
|
|
30
|
+
{
|
|
31
|
+
path: config.openApiPath,
|
|
32
|
+
baseUrl: `//localhost:${config.port}${config.routePrefix}`,
|
|
33
|
+
},
|
|
34
|
+
]));
|
|
13
35
|
app.use(koaSwagger({
|
|
14
36
|
routePrefix: "/counterfact/swagger",
|
|
15
37
|
swaggerOptions: {
|
|
@@ -20,31 +42,13 @@ export function createKoaApp(registry, koaMiddleware, config, contextRegistry) {
|
|
|
20
42
|
app.use(adminApiMiddleware(registry, contextRegistry, config));
|
|
21
43
|
}
|
|
22
44
|
debug("basePath: %s", config.basePath);
|
|
23
|
-
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
45
|
app.use(async (ctx, next) => {
|
|
36
46
|
if (ctx.URL.pathname === "/counterfact") {
|
|
37
|
-
ctx.redirect("/counterfact/");
|
|
47
|
+
ctx.redirect("/counterfact/swagger");
|
|
38
48
|
return;
|
|
39
49
|
}
|
|
40
50
|
await next();
|
|
41
51
|
});
|
|
42
|
-
app.use(pageMiddleware("/counterfact/rapidoc", "rapi-doc", {
|
|
43
|
-
basePath: config.basePath,
|
|
44
|
-
get routes() {
|
|
45
|
-
return registry.routes;
|
|
46
|
-
},
|
|
47
|
-
}));
|
|
48
52
|
app.use(bodyParser());
|
|
49
53
|
app.use(async (ctx, next) => {
|
|
50
54
|
await next();
|
|
@@ -56,6 +60,6 @@ export function createKoaApp(registry, koaMiddleware, config, contextRegistry) {
|
|
|
56
60
|
ctx.type = "application/json";
|
|
57
61
|
}
|
|
58
62
|
});
|
|
59
|
-
app.use(
|
|
63
|
+
app.use(routesMiddleware(dispatcher, config));
|
|
60
64
|
return app;
|
|
61
65
|
}
|
|
@@ -2,6 +2,19 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
const DEFAULT_MODULE_KIND = "commonjs";
|
|
5
|
+
/**
|
|
6
|
+
* Determines whether a module file should be treated as CommonJS or ESM.
|
|
7
|
+
*
|
|
8
|
+
* Resolution order (matches Node.js conventions):
|
|
9
|
+
* 1. `.cjs` extension → `"commonjs"`.
|
|
10
|
+
* 2. `.mjs` or `.ts` extension → `"module"`.
|
|
11
|
+
* 3. Walk up the directory tree looking for a `package.json` with a `"type"`
|
|
12
|
+
* field.
|
|
13
|
+
* 4. Falls back to `"commonjs"` at the filesystem root.
|
|
14
|
+
*
|
|
15
|
+
* @param modulePath - Absolute or relative path to the module file.
|
|
16
|
+
* @returns `"commonjs"` or `"module"`.
|
|
17
|
+
*/
|
|
5
18
|
export async function determineModuleKind(modulePath) {
|
|
6
19
|
if (modulePath.endsWith(".cjs")) {
|
|
7
20
|
return "commonjs";
|
|
@@ -3,8 +3,18 @@ import createDebugger from "debug";
|
|
|
3
3
|
import fetch, { Headers } from "node-fetch";
|
|
4
4
|
import { createResponseBuilder } from "./response-builder.js";
|
|
5
5
|
import { validateRequest } from "./request-validator.js";
|
|
6
|
+
import { validateResponse } from "./response-validator.js";
|
|
6
7
|
import { Tools } from "./tools.js";
|
|
7
8
|
const debug = createDebugger("counterfact:server:dispatcher");
|
|
9
|
+
/**
|
|
10
|
+
* Parses the `Cookie` request header into a key/value map.
|
|
11
|
+
*
|
|
12
|
+
* Duplicate keys are silently dropped (first occurrence wins) and values are
|
|
13
|
+
* percent-decoded where possible.
|
|
14
|
+
*
|
|
15
|
+
* @param cookieHeader - The raw `Cookie` header string.
|
|
16
|
+
* @returns A record mapping cookie name to decoded value.
|
|
17
|
+
*/
|
|
8
18
|
function parseCookies(cookieHeader) {
|
|
9
19
|
const cookies = {};
|
|
10
20
|
for (const part of cookieHeader.split(";")) {
|
|
@@ -26,6 +36,16 @@ function parseCookies(cookieHeader) {
|
|
|
26
36
|
}
|
|
27
37
|
return cookies;
|
|
28
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Core HTTP request dispatcher.
|
|
41
|
+
*
|
|
42
|
+
* Receives incoming requests from the Koa middleware layer, matches them
|
|
43
|
+
* against the {@link Registry}, optionally validates the request and response
|
|
44
|
+
* against the OpenAPI spec, and invokes the matching route-handler function.
|
|
45
|
+
*
|
|
46
|
+
* Content-negotiation (Accept header handling) is performed before returning
|
|
47
|
+
* the response so the caller always receives the most appropriate content type.
|
|
48
|
+
*/
|
|
29
49
|
export class Dispatcher {
|
|
30
50
|
registry;
|
|
31
51
|
contextRegistry;
|
|
@@ -69,6 +89,14 @@ export class Dispatcher {
|
|
|
69
89
|
}
|
|
70
90
|
return undefined;
|
|
71
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Resolves the OpenAPI operation for `path` and `method`, merging any
|
|
94
|
+
* top-level `produces` array from the document root into the operation.
|
|
95
|
+
*
|
|
96
|
+
* @param path - The matched route path (e.g. `"/pets/{petId}"`).
|
|
97
|
+
* @param method - The HTTP method.
|
|
98
|
+
* @returns The {@link OpenApiOperation} if found, or `undefined`.
|
|
99
|
+
*/
|
|
72
100
|
operationForPathAndMethod(path, method) {
|
|
73
101
|
const operation = this.findOperation(path, method);
|
|
74
102
|
if (operation === undefined) {
|
|
@@ -107,6 +135,16 @@ export class Dispatcher {
|
|
|
107
135
|
"unknown/unknown",
|
|
108
136
|
};
|
|
109
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Picks the best matching content entry from a multi-type response using the
|
|
140
|
+
* request's `Accept` header preferences.
|
|
141
|
+
*
|
|
142
|
+
* @param acceptHeader - The value of the `Accept` request header.
|
|
143
|
+
* @param content - Array of `{ type, body }` objects representing all
|
|
144
|
+
* available content-type variants.
|
|
145
|
+
* @returns The first entry whose MIME type satisfies the accept preferences,
|
|
146
|
+
* or `undefined` when none match.
|
|
147
|
+
*/
|
|
110
148
|
selectContent(acceptHeader, content) {
|
|
111
149
|
const preferredMediaTypes = mediaTypes(acceptHeader);
|
|
112
150
|
for (const mediaType of preferredMediaTypes) {
|
|
@@ -131,7 +169,16 @@ export class Dispatcher {
|
|
|
131
169
|
}
|
|
132
170
|
return false;
|
|
133
171
|
}
|
|
134
|
-
|
|
172
|
+
/**
|
|
173
|
+
* Main request handler.
|
|
174
|
+
*
|
|
175
|
+
* Orchestrates base-path stripping, route matching, request validation,
|
|
176
|
+
* handler invocation, content negotiation, and response validation.
|
|
177
|
+
*
|
|
178
|
+
* @param request - The incoming request descriptor.
|
|
179
|
+
* @returns A promise that resolves to a {@link CounterfactResponseObject}.
|
|
180
|
+
*/
|
|
181
|
+
async request({ auth, body, headers = {}, method, path, query, rawBody, req, }) {
|
|
135
182
|
debug(`request: ${method} ${path}`);
|
|
136
183
|
debug(`headers: ${JSON.stringify(headers)}`);
|
|
137
184
|
debug(`body: ${JSON.stringify(body)}`);
|
|
@@ -177,12 +224,9 @@ export class Dispatcher {
|
|
|
177
224
|
cookie: parseCookies(headers.cookie ?? headers.Cookie ?? ""),
|
|
178
225
|
headers,
|
|
179
226
|
proxy: async (url) => {
|
|
180
|
-
if (body !== undefined && headers.contentType !== "application/json") {
|
|
181
|
-
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.`);
|
|
182
|
-
}
|
|
183
227
|
delete headers.host;
|
|
184
228
|
const fetchResponse = await this.fetch(`${url}${req.path ?? ""}`, {
|
|
185
|
-
body: body === undefined ? undefined :
|
|
229
|
+
body: body === undefined ? undefined : rawBody,
|
|
186
230
|
headers: new Headers(headers),
|
|
187
231
|
method,
|
|
188
232
|
});
|
|
@@ -213,6 +257,21 @@ export class Dispatcher {
|
|
|
213
257
|
status: 406,
|
|
214
258
|
};
|
|
215
259
|
}
|
|
260
|
+
if (this.config?.validateResponses !== false) {
|
|
261
|
+
const validation = validateResponse(operation, normalizedResponse);
|
|
262
|
+
if (!validation.valid) {
|
|
263
|
+
return {
|
|
264
|
+
...normalizedResponse,
|
|
265
|
+
appendedHeaders: [
|
|
266
|
+
...(normalizedResponse.appendedHeaders ?? []),
|
|
267
|
+
...validation.errors.map((error) => [
|
|
268
|
+
"response-type-error",
|
|
269
|
+
error,
|
|
270
|
+
]),
|
|
271
|
+
],
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
}
|
|
216
275
|
return normalizedResponse;
|
|
217
276
|
}
|
|
218
277
|
}
|
|
@@ -1,32 +1,43 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
|
-
import
|
|
3
|
+
import { toForwardSlashPath, pathJoin } from "../util/forward-slash-path.js";
|
|
4
4
|
import { escapePathForWindows } from "../util/windows-escape.js";
|
|
5
5
|
const JS_EXTENSIONS = new Set(["cjs", "cts", "js", "mjs", "mts", "ts"]);
|
|
6
|
+
/**
|
|
7
|
+
* Recursively discovers JavaScript/TypeScript source files under a base
|
|
8
|
+
* directory.
|
|
9
|
+
*
|
|
10
|
+
* Only files with one of the following extensions are returned:
|
|
11
|
+
* `js`, `mjs`, `cjs`, `ts`, `mts`, `cts`.
|
|
12
|
+
*/
|
|
6
13
|
export class FileDiscovery {
|
|
7
14
|
basePath;
|
|
8
15
|
constructor(basePath) {
|
|
9
|
-
this.basePath = basePath
|
|
16
|
+
this.basePath = toForwardSlashPath(basePath);
|
|
10
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Returns an array of absolute file paths for all JS/TS files found
|
|
20
|
+
* recursively under `basePath/directory`.
|
|
21
|
+
*
|
|
22
|
+
* @param directory - Sub-directory relative to `basePath` to start from.
|
|
23
|
+
* Defaults to `""` (the base path itself).
|
|
24
|
+
* @throws When `basePath/directory` does not exist.
|
|
25
|
+
*/
|
|
11
26
|
async findFiles(directory = "") {
|
|
12
|
-
const fullDir =
|
|
13
|
-
.join(this.basePath, directory)
|
|
14
|
-
.replaceAll("\\", "/");
|
|
27
|
+
const fullDir = pathJoin(this.basePath, directory);
|
|
15
28
|
if (!existsSync(fullDir)) {
|
|
16
29
|
throw new Error(`Directory does not exist ${fullDir}`);
|
|
17
30
|
}
|
|
18
31
|
const entries = await fs.readdir(fullDir, { withFileTypes: true });
|
|
19
32
|
const results = await Promise.all(entries.map(async (entry) => {
|
|
20
33
|
if (entry.isDirectory()) {
|
|
21
|
-
return this.findFiles(
|
|
34
|
+
return this.findFiles(pathJoin(directory, entry.name));
|
|
22
35
|
}
|
|
23
36
|
const extension = entry.name.split(".").at(-1);
|
|
24
37
|
if (!JS_EXTENSIONS.has(extension ?? "")) {
|
|
25
38
|
return [];
|
|
26
39
|
}
|
|
27
|
-
const fullPath =
|
|
28
|
-
.join(this.basePath, directory, entry.name)
|
|
29
|
-
.replaceAll("\\", "/");
|
|
40
|
+
const fullPath = pathJoin(this.basePath, directory, entry.name);
|
|
30
41
|
return [escapePathForWindows(fullPath)];
|
|
31
42
|
}));
|
|
32
43
|
return results.flat();
|
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Determines whether a given request path should be forwarded to the upstream
|
|
3
|
+
* proxy.
|
|
4
|
+
*
|
|
5
|
+
* The check walks up the path hierarchy until it either finds an explicit
|
|
6
|
+
* `proxyPaths` entry or reaches the root (in which case the proxy is
|
|
7
|
+
* considered disabled).
|
|
8
|
+
*
|
|
9
|
+
* @param path - The request path to check (e.g. `"/pets/1"`).
|
|
10
|
+
* @param config - Object containing the `proxyPaths` map.
|
|
11
|
+
* @returns `true` when the request should be proxied.
|
|
12
|
+
*/
|
|
1
13
|
export function isProxyEnabledForPath(path, config) {
|
|
2
14
|
if (config.proxyPaths.has(path)) {
|
|
3
15
|
return config.proxyPaths.get(path) ?? false;
|
|
@@ -36,6 +36,16 @@ function objectToXml(json, schema, name) {
|
|
|
36
36
|
});
|
|
37
37
|
return `<${name}${attributes.join("")}>${String(xml.join(""))}</${name}>`;
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Converts a JSON value to an XML string using optional OpenAPI `xml` schema
|
|
41
|
+
* hints (element names, attributes, wrapping).
|
|
42
|
+
*
|
|
43
|
+
* @param json - The value to serialise.
|
|
44
|
+
* @param schema - Optional JSON Schema with an `xml` hint block.
|
|
45
|
+
* @param keyName - Fallback XML element name when the schema does not provide
|
|
46
|
+
* one. Defaults to `"root"`.
|
|
47
|
+
* @returns A well-formed XML string.
|
|
48
|
+
*/
|
|
39
49
|
export function jsonToXml(json, schema, keyName = "root") {
|
|
40
50
|
const name = schema?.xml?.name ?? keyName;
|
|
41
51
|
if (Array.isArray(json)) {
|
|
@@ -50,5 +60,5 @@ export function jsonToXml(json, schema, keyName = "root") {
|
|
|
50
60
|
if (typeof json === "object" && json !== null) {
|
|
51
61
|
return objectToXml(json, schema, name);
|
|
52
62
|
}
|
|
53
|
-
return `<${name}>${String(json)}</${name}>`;
|
|
63
|
+
return `<${name}>${xmlEscape(String(json))}</${name}>`;
|
|
54
64
|
}
|
|
@@ -40,7 +40,24 @@ function getAuthObject(ctx) {
|
|
|
40
40
|
const [username, password] = user.split(":");
|
|
41
41
|
return { password, username };
|
|
42
42
|
}
|
|
43
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Builds the Koa middleware function that bridges Koa's request context with
|
|
45
|
+
* the Counterfact {@link Dispatcher}.
|
|
46
|
+
*
|
|
47
|
+
* Responsibilities:
|
|
48
|
+
* - Respects `routePrefix` — requests outside the prefix are passed to `next`.
|
|
49
|
+
* - Adds CORS headers to every response.
|
|
50
|
+
* - Handles `OPTIONS` pre-flight requests (200 with CORS headers, no body).
|
|
51
|
+
* - Proxies the request upstream when proxy is enabled for the path.
|
|
52
|
+
* - Forwards the request to the dispatcher and maps the response back onto
|
|
53
|
+
* the Koa context.
|
|
54
|
+
*
|
|
55
|
+
* @param dispatcher - The {@link Dispatcher} instance that handles requests.
|
|
56
|
+
* @param config - Server configuration (proxy settings, route prefix, etc.).
|
|
57
|
+
* @param proxy - Proxy factory; injectable for testing.
|
|
58
|
+
* @returns A Koa middleware function.
|
|
59
|
+
*/
|
|
60
|
+
export function routesMiddleware(dispatcher, config, proxy = koaProxy) {
|
|
44
61
|
return async function middleware(ctx, next) {
|
|
45
62
|
const { proxyUrl, routePrefix } = config;
|
|
46
63
|
debug("middleware running for path: %s", ctx.request.path);
|
|
@@ -49,7 +66,7 @@ export function koaMiddleware(dispatcher, config, proxy = koaProxy) {
|
|
|
49
66
|
return await next();
|
|
50
67
|
}
|
|
51
68
|
const auth = getAuthObject(ctx);
|
|
52
|
-
const { body, headers, query } = ctx.request;
|
|
69
|
+
const { body, headers, query, rawBody } = ctx.request;
|
|
53
70
|
const path = ctx.request.path.slice(routePrefix.length);
|
|
54
71
|
const method = ctx.request.method;
|
|
55
72
|
if (isProxyEnabledForPath(path, config) && proxyUrl) {
|
|
@@ -69,6 +86,7 @@ export function koaMiddleware(dispatcher, config, proxy = koaProxy) {
|
|
|
69
86
|
path,
|
|
70
87
|
/* @ts-expect-error the value of a querystring item can be an array and we don't have a solution for that yet */
|
|
71
88
|
query,
|
|
89
|
+
rawBody: method === "HEAD" || method === "GET" ? undefined : rawBody,
|
|
72
90
|
req: { path: "", ...ctx.req },
|
|
73
91
|
});
|
|
74
92
|
ctx.body = response.body;
|
|
@@ -88,6 +106,11 @@ export function koaMiddleware(dispatcher, config, proxy = koaProxy) {
|
|
|
88
106
|
}
|
|
89
107
|
}
|
|
90
108
|
}
|
|
109
|
+
if (response.appendedHeaders) {
|
|
110
|
+
for (const [key, value] of response.appendedHeaders) {
|
|
111
|
+
ctx.res.appendHeader(key, value);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
91
114
|
ctx.status = response.status ?? HTTP_STATUS_CODE_OK;
|
|
92
115
|
return undefined;
|
|
93
116
|
};
|
|
@@ -2,6 +2,14 @@ import { dirname, resolve } from "node:path";
|
|
|
2
2
|
import createDebug from "debug";
|
|
3
3
|
import precinct from "precinct";
|
|
4
4
|
const debug = createDebug("counterfact:server:module-dependency-graph");
|
|
5
|
+
/**
|
|
6
|
+
* Tracks which route files depend on shared modules so that when a shared
|
|
7
|
+
* module changes, all dependent route files can be reloaded.
|
|
8
|
+
*
|
|
9
|
+
* Dependency edges are extracted using [precinct](https://npm.im/precinct)'s
|
|
10
|
+
* static analysis and are stored as a reverse map (`dependency → Set<dependent
|
|
11
|
+
* files>`).
|
|
12
|
+
*/
|
|
5
13
|
export class ModuleDependencyGraph {
|
|
6
14
|
dependents = new Map();
|
|
7
15
|
loadDependencies(path) {
|
|
@@ -18,6 +26,14 @@ export class ModuleDependencyGraph {
|
|
|
18
26
|
group.delete(path);
|
|
19
27
|
});
|
|
20
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* (Re-)indexes the dependency edges for `path`, replacing any previously
|
|
31
|
+
* recorded edges.
|
|
32
|
+
*
|
|
33
|
+
* Only relative imports are tracked; node_modules dependencies are ignored.
|
|
34
|
+
*
|
|
35
|
+
* @param path - Absolute path of the file to analyse.
|
|
36
|
+
*/
|
|
21
37
|
load(path) {
|
|
22
38
|
this.clearDependents(path);
|
|
23
39
|
for (const dependency of this.loadDependencies(path)) {
|
|
@@ -31,6 +47,15 @@ export class ModuleDependencyGraph {
|
|
|
31
47
|
this.dependents.get(key)?.add(path);
|
|
32
48
|
}
|
|
33
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Returns the transitive set of files that (directly or indirectly) import
|
|
52
|
+
* `path`.
|
|
53
|
+
*
|
|
54
|
+
* Uses a BFS traversal so each dependent is returned exactly once.
|
|
55
|
+
*
|
|
56
|
+
* @param path - Absolute path of the changed dependency.
|
|
57
|
+
* @returns A `Set` of absolute paths of all dependent files.
|
|
58
|
+
*/
|
|
34
59
|
dependentsOf(path) {
|
|
35
60
|
const marked = new Set();
|
|
36
61
|
const dependents = new Set();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { once } from "node:events";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
|
-
import nodePath, { basename
|
|
3
|
+
import nodePath, { basename } from "node:path";
|
|
4
4
|
import { watch } from "chokidar";
|
|
5
5
|
import createDebug from "debug";
|
|
6
6
|
import { CHOKIDAR_OPTIONS } from "./constants.js";
|
|
@@ -10,46 +10,76 @@ import { FileDiscovery } from "./file-discovery.js";
|
|
|
10
10
|
import { isContextModule, isMiddlewareModule, } from "./middleware-detector.js";
|
|
11
11
|
import { ModuleDependencyGraph } from "./module-dependency-graph.js";
|
|
12
12
|
import { uncachedImport } from "./uncached-import.js";
|
|
13
|
+
import { toForwardSlashPath, pathDirname, pathRelative, } from "../util/forward-slash-path.js";
|
|
13
14
|
import { unescapePathForWindows } from "../util/windows-escape.js";
|
|
14
15
|
const { uncachedRequire } = await import("./uncached-require.cjs");
|
|
15
16
|
const debug = createDebug("counterfact:server:module-loader");
|
|
17
|
+
/**
|
|
18
|
+
* Watches the compiled routes directory and dynamically loads/reloads route
|
|
19
|
+
* modules, context files, and middleware as files are added, changed, or
|
|
20
|
+
* removed.
|
|
21
|
+
*
|
|
22
|
+
* Loaded modules are registered in the {@link Registry} (route handlers) or
|
|
23
|
+
* the {@link ContextRegistry} (context files). An optional
|
|
24
|
+
* {@link ScenarioRegistry} receives scenario modules loaded from a separate
|
|
25
|
+
* `scenarios/` directory.
|
|
26
|
+
*
|
|
27
|
+
* Emits DOM-style events (`"add"`, `"remove"`) so callers can react to module
|
|
28
|
+
* lifecycle changes.
|
|
29
|
+
*/
|
|
16
30
|
export class ModuleLoader extends EventTarget {
|
|
17
31
|
basePath;
|
|
18
32
|
registry;
|
|
19
33
|
watcher;
|
|
34
|
+
scenariosWatcher;
|
|
20
35
|
contextRegistry;
|
|
36
|
+
scenariosPath;
|
|
37
|
+
scenarioRegistry;
|
|
21
38
|
dependencyGraph = new ModuleDependencyGraph();
|
|
22
39
|
fileDiscovery;
|
|
23
40
|
uncachedImport = async function (moduleName) {
|
|
24
41
|
throw new Error(`uncachedImport not set up; importing ${moduleName}`);
|
|
25
42
|
};
|
|
26
|
-
constructor(basePath, registry, contextRegistry = new ContextRegistry()) {
|
|
43
|
+
constructor(basePath, registry, contextRegistry = new ContextRegistry(), scenariosPath, scenarioRegistry) {
|
|
27
44
|
super();
|
|
28
|
-
this.basePath = basePath
|
|
45
|
+
this.basePath = toForwardSlashPath(basePath);
|
|
29
46
|
this.registry = registry;
|
|
30
47
|
this.contextRegistry = contextRegistry;
|
|
48
|
+
this.scenariosPath =
|
|
49
|
+
scenariosPath === undefined
|
|
50
|
+
? undefined
|
|
51
|
+
: toForwardSlashPath(scenariosPath);
|
|
52
|
+
this.scenarioRegistry = scenarioRegistry;
|
|
31
53
|
this.fileDiscovery = new FileDiscovery(this.basePath);
|
|
32
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Starts watching the routes directory (and optionally the scenarios
|
|
57
|
+
* directory) for file-system changes, loading or reloading modules on
|
|
58
|
+
* `"add"` and `"change"` events and deregistering them on `"unlink"`.
|
|
59
|
+
*
|
|
60
|
+
* Resolves once the initial directory scan is complete.
|
|
61
|
+
*/
|
|
33
62
|
async watch() {
|
|
34
63
|
this.watcher = watch(this.basePath, CHOKIDAR_OPTIONS).on("all", (eventName, pathNameOriginal) => {
|
|
35
64
|
const JS_EXTENSIONS = ["js", "mjs", "cjs", "ts", "mts", "cts"];
|
|
36
65
|
if (!JS_EXTENSIONS.some((extension) => pathNameOriginal.endsWith(`.${extension}`)))
|
|
37
66
|
return;
|
|
38
|
-
const pathName = pathNameOriginal
|
|
67
|
+
const pathName = toForwardSlashPath(pathNameOriginal);
|
|
39
68
|
if (pathName.includes("$.context") && eventName === "add") {
|
|
40
|
-
process.stdout.write(`\n\n!!! The file at ${pathName} needs a minor update.\n See https://github.com/pmcelhaney/counterfact/blob/main/docs/
|
|
69
|
+
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`);
|
|
41
70
|
return;
|
|
42
71
|
}
|
|
43
72
|
if (!["add", "change", "unlink"].includes(eventName)) {
|
|
44
73
|
return;
|
|
45
74
|
}
|
|
46
75
|
const parts = nodePath.parse(pathName.replace(this.basePath, ""));
|
|
47
|
-
const url = unescapePathForWindows(`/${parts.dir}/${parts.name}`
|
|
48
|
-
.replaceAll("\\", "/")
|
|
49
|
-
.replaceAll(/\/+/gu, "/"));
|
|
76
|
+
const url = unescapePathForWindows(toForwardSlashPath(`/${parts.dir}/${parts.name}`).replaceAll(/\/+/gu, "/"));
|
|
50
77
|
if (eventName === "unlink") {
|
|
51
78
|
this.registry.remove(url);
|
|
52
79
|
this.dispatchEvent(new Event("remove"));
|
|
80
|
+
if (this.isContextFile(pathName)) {
|
|
81
|
+
this.contextRegistry.remove(unescapePathForWindows(toForwardSlashPath(parts.dir)) || "/");
|
|
82
|
+
}
|
|
53
83
|
return;
|
|
54
84
|
}
|
|
55
85
|
const dependencies = this.dependencyGraph.dependentsOf(pathName);
|
|
@@ -59,20 +89,88 @@ export class ModuleLoader extends EventTarget {
|
|
|
59
89
|
}
|
|
60
90
|
});
|
|
61
91
|
await once(this.watcher, "ready");
|
|
92
|
+
if (this.scenariosPath && this.scenarioRegistry) {
|
|
93
|
+
const JS_EXTENSIONS = ["js", "mjs", "cjs", "ts", "mts", "cts"];
|
|
94
|
+
const scenariosPath = this.scenariosPath;
|
|
95
|
+
this.scenariosWatcher = watch(scenariosPath, CHOKIDAR_OPTIONS).on("all", (eventName, pathNameOriginal) => {
|
|
96
|
+
if (!JS_EXTENSIONS.some((ext) => pathNameOriginal.endsWith(`.${ext}`)))
|
|
97
|
+
return;
|
|
98
|
+
if (!["add", "change", "unlink"].includes(eventName))
|
|
99
|
+
return;
|
|
100
|
+
const pathName = toForwardSlashPath(pathNameOriginal);
|
|
101
|
+
if (eventName === "unlink") {
|
|
102
|
+
const fileKey = this.scenarioFileKey(pathName);
|
|
103
|
+
this.scenarioRegistry?.remove(fileKey);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
void this.loadScenarioFile(pathName);
|
|
107
|
+
});
|
|
108
|
+
await once(this.scenariosWatcher, "ready");
|
|
109
|
+
}
|
|
62
110
|
}
|
|
111
|
+
/** Closes both file-system watchers (routes and scenarios). */
|
|
63
112
|
async stopWatching() {
|
|
64
113
|
await this.watcher?.close();
|
|
114
|
+
await this.scenariosWatcher?.close();
|
|
115
|
+
}
|
|
116
|
+
isContextFile(pathName) {
|
|
117
|
+
return basename(pathName).startsWith("_.context.");
|
|
65
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Performs a one-shot load of all modules found under `directory` (relative
|
|
121
|
+
* to the configured base path) and all scenario files.
|
|
122
|
+
*
|
|
123
|
+
* @param directory - Sub-directory to load, defaults to the root (`""`).
|
|
124
|
+
*/
|
|
66
125
|
async load(directory = "") {
|
|
67
126
|
const files = await this.fileDiscovery.findFiles(directory);
|
|
68
127
|
await Promise.all(files.map((file) => this.loadEndpoint(file)));
|
|
128
|
+
await this.loadScenarios();
|
|
129
|
+
}
|
|
130
|
+
shouldLoadScenarioFile(pathName) {
|
|
131
|
+
return !pathName.endsWith(".d.ts") && !pathName.endsWith(".map");
|
|
132
|
+
}
|
|
133
|
+
async loadScenarios() {
|
|
134
|
+
if (!this.scenariosPath || !this.scenarioRegistry)
|
|
135
|
+
return;
|
|
136
|
+
try {
|
|
137
|
+
const fileDiscovery = new FileDiscovery(this.scenariosPath);
|
|
138
|
+
const files = await fileDiscovery.findFiles();
|
|
139
|
+
const loadableFiles = files.filter((file) => this.shouldLoadScenarioFile(file));
|
|
140
|
+
await Promise.all(loadableFiles.map((file) => this.loadScenarioFile(file)));
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// Scenarios directory does not exist yet — that's fine.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
scenarioFileKey(pathName) {
|
|
147
|
+
const normalizedScenariosPath = toForwardSlashPath(this.scenariosPath ?? "");
|
|
148
|
+
const directory = pathDirname(pathName.slice(normalizedScenariosPath.length));
|
|
149
|
+
const name = nodePath.parse(basename(pathName)).name;
|
|
150
|
+
const url = unescapePathForWindows(toForwardSlashPath(`/${nodePath.join(directory, name)}`).replaceAll(/\/+/gu, "/"));
|
|
151
|
+
return url.slice(1); // strip leading "/"
|
|
152
|
+
}
|
|
153
|
+
async loadScenarioFile(pathName) {
|
|
154
|
+
if (!this.scenariosPath || !this.scenarioRegistry)
|
|
155
|
+
return;
|
|
156
|
+
const fileKey = this.scenarioFileKey(pathName);
|
|
157
|
+
try {
|
|
158
|
+
const doImport = (await determineModuleKind(pathName)) === "commonjs"
|
|
159
|
+
? uncachedRequire
|
|
160
|
+
: uncachedImport;
|
|
161
|
+
const module = await doImport(pathName);
|
|
162
|
+
if (module) {
|
|
163
|
+
this.scenarioRegistry.add(fileKey, module);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
process.stdout.write(`\nError loading scenario ${pathName}:\n${String(error)}\n`);
|
|
168
|
+
}
|
|
69
169
|
}
|
|
70
170
|
async loadEndpoint(pathName) {
|
|
71
171
|
debug("importing module: %s", pathName);
|
|
72
|
-
const directory =
|
|
73
|
-
const url = unescapePathForWindows(`/${nodePath.join(directory, nodePath.parse(basename(pathName)).name)}`
|
|
74
|
-
.replaceAll("\\", "/")
|
|
75
|
-
.replaceAll(/\/+/gu, "/"));
|
|
172
|
+
const directory = pathDirname(pathName.slice(this.basePath.length));
|
|
173
|
+
const url = unescapePathForWindows(toForwardSlashPath(`/${nodePath.join(directory, nodePath.parse(basename(pathName)).name)}`).replaceAll(/\/+/gu, "/"));
|
|
76
174
|
debug(`loading pathName from dependencyGraph: ${pathName}`);
|
|
77
175
|
this.dependencyGraph.load(pathName);
|
|
78
176
|
try {
|
|
@@ -86,9 +184,7 @@ export class ModuleLoader extends EventTarget {
|
|
|
86
184
|
if (importError !== undefined) {
|
|
87
185
|
const isSyntaxError = importError instanceof SyntaxError ||
|
|
88
186
|
String(importError).startsWith("SyntaxError:");
|
|
89
|
-
const displayPath =
|
|
90
|
-
.relative(process.cwd(), unescapePathForWindows(pathName))
|
|
91
|
-
.replaceAll("\\", "/");
|
|
187
|
+
const displayPath = pathRelative(process.cwd(), unescapePathForWindows(pathName));
|
|
92
188
|
const message = isSyntaxError
|
|
93
189
|
? `There is a syntax error in the route file: ${displayPath}`
|
|
94
190
|
: `There was an error loading the route file: ${displayPath}`;
|
|
@@ -113,8 +209,7 @@ export class ModuleLoader extends EventTarget {
|
|
|
113
209
|
return;
|
|
114
210
|
}
|
|
115
211
|
this.dispatchEvent(new Event("add"));
|
|
116
|
-
if (
|
|
117
|
-
isContextModule(endpoint)) {
|
|
212
|
+
if (this.isContextFile(pathName) && isContextModule(endpoint)) {
|
|
118
213
|
const loadContext = (path) => this.contextRegistry.find(path);
|
|
119
214
|
const contextDir = nodePath.dirname(unescapePathForWindows(pathName));
|
|
120
215
|
const readJson = async (relativePath) => {
|