apiforge-analyzer 1.3.9 → 1.3.10
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 +474 -474
- package/dist/cjs/cli.js +22 -2
- package/dist/cjs/detect.js +10 -1
- package/dist/cjs/express-mount-hook.js +82 -0
- package/dist/cjs/express-preload.js +17 -0
- package/dist/cjs/frameworks/express.js +41 -22
- package/dist/esm/cli.js +24 -4
- package/dist/esm/detect.js +10 -1
- package/dist/esm/express-mount-hook.js +77 -0
- package/dist/esm/express-preload.js +15 -0
- package/dist/esm/frameworks/express.js +41 -22
- package/dist/types/express-mount-hook.d.ts +19 -0
- package/dist/types/express-preload.d.ts +1 -0
- package/package.json +66 -66
package/dist/cjs/cli.js
CHANGED
|
@@ -687,7 +687,7 @@ async function microservicesFlow(config, opts) {
|
|
|
687
687
|
cwd,
|
|
688
688
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
689
689
|
shell: true,
|
|
690
|
-
env: { ...process.env },
|
|
690
|
+
env: withExpressMountHook({ ...process.env }),
|
|
691
691
|
detached: false,
|
|
692
692
|
});
|
|
693
693
|
// Capture output so failures are debuggable without re-running the service by hand.
|
|
@@ -1668,6 +1668,26 @@ function suppressNodemonSelfRestart(cwd) {
|
|
|
1668
1668
|
catch { }
|
|
1669
1669
|
};
|
|
1670
1670
|
}
|
|
1671
|
+
/**
|
|
1672
|
+
* Preloads the Express mount hook (express-preload.ts) into the user's server, so router / sub-app mount
|
|
1673
|
+
* paths are recorded as they're registered — see express-mount-hook.ts. Without the built preload file
|
|
1674
|
+
* (e.g. running from source) the env is returned unchanged and prefixes are read off the stack as before.
|
|
1675
|
+
*/
|
|
1676
|
+
function withExpressMountHook(env) {
|
|
1677
|
+
let preload;
|
|
1678
|
+
try {
|
|
1679
|
+
// The bin is dist/esm/cli.js (or dist/cjs/cli.js); the preload must be the CommonJS build.
|
|
1680
|
+
preload = (0, path_1.join)((0, path_1.dirname)((0, fs_1.realpathSync)(process.argv[1])), '..', 'cjs', 'express-preload.js');
|
|
1681
|
+
}
|
|
1682
|
+
catch {
|
|
1683
|
+
return env;
|
|
1684
|
+
}
|
|
1685
|
+
if (!(0, fs_1.existsSync)(preload))
|
|
1686
|
+
return env;
|
|
1687
|
+
// Forward slashes: NODE_OPTIONS treats a backslash inside quotes as an escape.
|
|
1688
|
+
const flag = `--require "${preload.replace(/\\/g, '/')}"`;
|
|
1689
|
+
return { ...env, NODE_OPTIONS: env.NODE_OPTIONS ? `${env.NODE_OPTIONS} ${flag}` : flag };
|
|
1690
|
+
}
|
|
1671
1691
|
function startAndWaitForAnalysis(command, timeoutMs) {
|
|
1672
1692
|
return new Promise(resolve => {
|
|
1673
1693
|
const statusPath = (0, path_1.join)(process.cwd(), STATUS_FILE);
|
|
@@ -1675,7 +1695,7 @@ function startAndWaitForAnalysis(command, timeoutMs) {
|
|
|
1675
1695
|
cwd: process.cwd(),
|
|
1676
1696
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1677
1697
|
shell: true,
|
|
1678
|
-
env: { ...process.env },
|
|
1698
|
+
env: withExpressMountHook({ ...process.env }),
|
|
1679
1699
|
detached: false,
|
|
1680
1700
|
});
|
|
1681
1701
|
// Capture server output (head-capped) so we can show it on failure
|
package/dist/cjs/detect.js
CHANGED
|
@@ -47,10 +47,19 @@ function mk(name, confidence) {
|
|
|
47
47
|
function detectFramework(app) {
|
|
48
48
|
// ── Instance-based detection (highest confidence) ─────────────────────────
|
|
49
49
|
if (app && typeof app === 'function') {
|
|
50
|
-
// Express: _router or lazyrouter
|
|
50
|
+
// Express 4: _router or lazyrouter
|
|
51
51
|
if (app._router || (app.lazyrouter && typeof app.lazyrouter === 'function')) {
|
|
52
52
|
return mk('express', 1.0);
|
|
53
53
|
}
|
|
54
|
+
// Express 5 dropped both; the router is behind an `app.router` getter (which throws in Express 4).
|
|
55
|
+
// Without this, an Express 5 app in a project that also has an app/ folder was mistaken for Next.js.
|
|
56
|
+
try {
|
|
57
|
+
if (app.router && Array.isArray(app.router.stack))
|
|
58
|
+
return mk('express', 1.0);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// not Express 5
|
|
62
|
+
}
|
|
54
63
|
// Koa: middleware array
|
|
55
64
|
if (Array.isArray(app.middleware) && typeof app.use === 'function' && app.context) {
|
|
56
65
|
return mk('koa', 0.95);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Records where every router / sub-app is mounted, at the moment it is mounted.
|
|
4
|
+
*
|
|
5
|
+
* The Express analyzer reads mount prefixes back off the router stack after the fact, which loses them:
|
|
6
|
+
* - Express 5 (router@2) keeps no path on a layer at all (only compiled matchers), so every mounted
|
|
7
|
+
* router's routes came out without their prefix, and same-looking ones collapsed as duplicates.
|
|
8
|
+
* - A sub-app (`app.use('/admin', express())`) is wrapped in a `mounted_app` function with no `stack`,
|
|
9
|
+
* so none of its routes were found.
|
|
10
|
+
* - Express 4's mount regexp for a parameterised prefix (`/users/:id/posts`) doesn't turn back into the path.
|
|
11
|
+
*
|
|
12
|
+
* Wrapping `use()` tags each layer it adds with the mount path as written, and a sub-app's layer with the
|
|
13
|
+
* sub-app itself; express.ts prefers those tags and falls back to the old regexp reading without them.
|
|
14
|
+
* Must run before the app registers its routes — the CLI preloads it (express-preload.ts).
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.SUB_APP = exports.MOUNT_PATH = void 0;
|
|
18
|
+
exports.appStack = appStack;
|
|
19
|
+
exports.installExpressMountHook = installExpressMountHook;
|
|
20
|
+
exports.MOUNT_PATH = '__apiforgeMountPath';
|
|
21
|
+
exports.SUB_APP = '__apiforgeSubApp';
|
|
22
|
+
const INSTALLED = Symbol.for('apiforge.expressMountHook');
|
|
23
|
+
/** An app's router stack: `_router` in Express 4, the `router` getter in Express 5 (which throws in 4). */
|
|
24
|
+
function appStack(app) {
|
|
25
|
+
const legacy = app?._router?.stack;
|
|
26
|
+
if (Array.isArray(legacy))
|
|
27
|
+
return legacy;
|
|
28
|
+
try {
|
|
29
|
+
const stack = app?.router?.stack;
|
|
30
|
+
return Array.isArray(stack) ? stack : undefined;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** The mount path `use()` was given, or undefined when the first argument is already a handler (Express's own rule). */
|
|
37
|
+
function mountPathArg(args) {
|
|
38
|
+
let arg = args[0];
|
|
39
|
+
while (Array.isArray(arg) && arg.length !== 0)
|
|
40
|
+
arg = arg[0];
|
|
41
|
+
return typeof arg === 'function' ? undefined : args[0];
|
|
42
|
+
}
|
|
43
|
+
function isApp(fn) {
|
|
44
|
+
return !!fn && typeof fn.handle === 'function' && typeof fn.set === 'function';
|
|
45
|
+
}
|
|
46
|
+
function wrapUse(target, stackOf) {
|
|
47
|
+
const original = target.use;
|
|
48
|
+
if (typeof original !== 'function')
|
|
49
|
+
return;
|
|
50
|
+
target.use = function apiforgeUse(...args) {
|
|
51
|
+
const before = stackOf(this)?.length ?? 0;
|
|
52
|
+
const result = original.apply(this, args);
|
|
53
|
+
try {
|
|
54
|
+
const added = (stackOf(this) ?? []).slice(before);
|
|
55
|
+
const path = mountPathArg(args) ?? '/';
|
|
56
|
+
const handlers = (mountPathArg(args) === undefined ? args : args.slice(1)).flat(Infinity);
|
|
57
|
+
const subApps = handlers.filter(isApp);
|
|
58
|
+
let next = 0;
|
|
59
|
+
for (const layer of added) {
|
|
60
|
+
layer[exports.MOUNT_PATH] = path;
|
|
61
|
+
if (layer.handle?.name === 'mounted_app' && next < subApps.length)
|
|
62
|
+
layer[exports.SUB_APP] = subApps[next++];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Never let documentation tagging break the user's app.
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function installExpressMountHook(express) {
|
|
72
|
+
if (!express || express[INSTALLED])
|
|
73
|
+
return;
|
|
74
|
+
express[INSTALLED] = true;
|
|
75
|
+
// Express 5: Router is a class (router@2). Express 4: Router is the proto object itself.
|
|
76
|
+
const Router = express.Router;
|
|
77
|
+
const routerProto = typeof Router?.prototype?.use === 'function' ? Router.prototype : Router;
|
|
78
|
+
if (routerProto)
|
|
79
|
+
wrapUse(routerProto, (self) => (Array.isArray(self?.stack) ? self.stack : undefined));
|
|
80
|
+
if (express.application)
|
|
81
|
+
wrapUse(express.application, appStack);
|
|
82
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* Preloaded into the user's server by the CLI (`NODE_OPTIONS=--require …/dist/cjs/express-preload.js`) so
|
|
5
|
+
* the mount hook is in place before any route is registered. Loads the project's own express (resolved
|
|
6
|
+
* from the working directory); a project without express is left untouched.
|
|
7
|
+
*/
|
|
8
|
+
const module_1 = require("module");
|
|
9
|
+
const path_1 = require("path");
|
|
10
|
+
const express_mount_hook_js_1 = require("./express-mount-hook.js");
|
|
11
|
+
try {
|
|
12
|
+
const projectRequire = (0, module_1.createRequire)((0, path_1.join)(process.cwd(), 'noop.js'));
|
|
13
|
+
(0, express_mount_hook_js_1.installExpressMountHook)(projectRequire('express'));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// No express here (npm, nodemon, a non-Express project): nothing to record.
|
|
17
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.analyzeExpress = analyzeExpress;
|
|
7
|
+
const express_mount_hook_js_1 = require("../express-mount-hook.js");
|
|
7
8
|
// ─── Path helpers ─────────────────────────────────────────────────────────────
|
|
8
9
|
function normalizePath(raw) {
|
|
9
10
|
return raw
|
|
@@ -52,29 +53,48 @@ function analyzeExpress(app) {
|
|
|
52
53
|
console.warn('⚠️ No routes found — ensure routes are registered before analyze()');
|
|
53
54
|
return routes;
|
|
54
55
|
}
|
|
55
|
-
|
|
56
|
+
const skipped = [];
|
|
57
|
+
walkStack(stack, '', routes, seen, skipped);
|
|
58
|
+
if (skipped.length) {
|
|
59
|
+
// Same method + path registered more than once: Express only ever serves the first, so it is
|
|
60
|
+
// documented once — but say so, so the count can be checked against the code.
|
|
61
|
+
console.warn(`⚠️ Skipped ${skipped.length} duplicate route(s) (same method + path registered twice):`);
|
|
62
|
+
for (const s of skipped)
|
|
63
|
+
console.warn(` ${s}`);
|
|
64
|
+
}
|
|
56
65
|
return routes;
|
|
57
66
|
}
|
|
58
|
-
function walkStack(stack, basePath, out, seen) {
|
|
67
|
+
function walkStack(stack, basePath, out, seen, skipped) {
|
|
59
68
|
for (const layer of stack) {
|
|
60
69
|
if (layer.route) {
|
|
61
|
-
const r
|
|
62
|
-
|
|
70
|
+
for (const r of buildRoutes(layer.route, basePath)) {
|
|
71
|
+
const key = `${r.method} ${r.path}`;
|
|
72
|
+
if (seen.has(key)) {
|
|
73
|
+
skipped.push(key);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
63
76
|
out.push(r);
|
|
64
|
-
seen.add(
|
|
77
|
+
seen.add(key);
|
|
65
78
|
}
|
|
66
79
|
}
|
|
67
|
-
else
|
|
68
|
-
// Router or sub-app
|
|
80
|
+
else {
|
|
81
|
+
// Router mounted at a prefix, or a sub-app (its layer only holds a `mounted_app` wrapper, so the
|
|
82
|
+
// mount hook hands over the app itself)
|
|
83
|
+
const subStack = layer.handle?.stack ?? (layer[express_mount_hook_js_1.SUB_APP] ? (0, express_mount_hook_js_1.appStack)(layer[express_mount_hook_js_1.SUB_APP]) : undefined);
|
|
84
|
+
if (!subStack)
|
|
85
|
+
continue;
|
|
86
|
+
// The path as written to use(), when the mount hook saw it; otherwise read back off the layer
|
|
69
87
|
let prefix = '';
|
|
70
|
-
if (layer.
|
|
88
|
+
if (typeof layer[express_mount_hook_js_1.MOUNT_PATH] === 'string')
|
|
89
|
+
prefix = normalizePath(layer[express_mount_hook_js_1.MOUNT_PATH]);
|
|
90
|
+
else if (layer.regexp?.source)
|
|
71
91
|
prefix = normalizePath(layer.regexp.source);
|
|
72
92
|
else if (layer.path)
|
|
73
93
|
prefix = normalizePath(layer.path);
|
|
74
94
|
else if (typeof layer.route === 'string')
|
|
75
95
|
prefix = normalizePath(layer.route);
|
|
76
96
|
const combined = combinePaths(basePath, prefix);
|
|
77
|
-
walkStack(
|
|
97
|
+
walkStack(subStack, combined, out, seen, skipped);
|
|
78
98
|
}
|
|
79
99
|
}
|
|
80
100
|
}
|
|
@@ -91,14 +111,14 @@ const AUTH_NAMES = new Set([
|
|
|
91
111
|
'checkToken', 'authMiddleware', 'jwt', 'bearer', 'apiKeyAuth',
|
|
92
112
|
'passport.authenticate',
|
|
93
113
|
]);
|
|
94
|
-
function
|
|
114
|
+
function buildRoutes(route, basePath) {
|
|
95
115
|
if (!route.path)
|
|
96
|
-
return
|
|
116
|
+
return [];
|
|
97
117
|
const methods = Object.keys(route.methods || {})
|
|
98
118
|
.filter(m => route.methods[m])
|
|
99
119
|
.map(m => m.toUpperCase());
|
|
100
120
|
if (!methods.length)
|
|
101
|
-
return
|
|
121
|
+
return [];
|
|
102
122
|
const cleanBase = normalizePath(basePath);
|
|
103
123
|
const cleanRoute = normalizePath(route.path);
|
|
104
124
|
let fullPath = (cleanBase === '/' ? '' : cleanBase) + cleanRoute;
|
|
@@ -142,19 +162,18 @@ function buildRoute(route, basePath) {
|
|
|
142
162
|
// ── Auth detection ─────────────────────────────────────────────────────────
|
|
143
163
|
const hasAuth = middleware.some(m => AUTH_NAMES.has(m) || [...AUTH_NAMES].some(a => m.toLowerCase().includes(a.toLowerCase()))) ||
|
|
144
164
|
/req\.user|req\.auth|req\.currentUser|verifyToken|jwt\.verify|passport\.authenticate/.test(src);
|
|
145
|
-
const method = methods[0];
|
|
146
165
|
const tag = tagFromPath(fullPath);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
.replace(/_+/g, '_')
|
|
151
|
-
.replace(/^_|_$/, '')}`;
|
|
152
|
-
return {
|
|
166
|
+
// One route per method: router.route('/x').get(...).put(...) registers a single layer
|
|
167
|
+
// carrying every method, and each one is its own endpoint.
|
|
168
|
+
return methods.map(method => ({
|
|
153
169
|
method,
|
|
154
170
|
path: fullPath,
|
|
155
171
|
tag,
|
|
156
|
-
operationId
|
|
157
|
-
|
|
172
|
+
operationId: `${method.toLowerCase()}_${fullPath
|
|
173
|
+
.replace(/[^a-zA-Z0-9]/g, '_')
|
|
174
|
+
.replace(/_+/g, '_')
|
|
175
|
+
.replace(/^_|_$/, '')}`,
|
|
176
|
+
summary: generateSummary(method, fullPath),
|
|
158
177
|
middleware,
|
|
159
178
|
authRequired: hasAuth,
|
|
160
179
|
pathParams,
|
|
@@ -165,7 +184,7 @@ function buildRoute(route, basePath) {
|
|
|
165
184
|
framework: 'express',
|
|
166
185
|
responseStatuses: responseStatuses.length ? responseStatuses : undefined,
|
|
167
186
|
headerParams: headerParams.length ? headerParams : undefined,
|
|
168
|
-
};
|
|
187
|
+
}));
|
|
169
188
|
}
|
|
170
189
|
// ─── Extraction helpers ───────────────────────────────────────────────────────
|
|
171
190
|
function matchAll(src, re) {
|
package/dist/esm/cli.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { group, intro, outro, text, select, password, confirm, tasks, note, log, isCancel, cancel, spinner, } from '@clack/prompts';
|
|
3
|
-
import { readFileSync, existsSync, writeFileSync, unlinkSync, readdirSync } from 'fs';
|
|
3
|
+
import { readFileSync, existsSync, writeFileSync, unlinkSync, readdirSync, realpathSync } from 'fs';
|
|
4
4
|
import { tmpdir } from 'os';
|
|
5
|
-
import { join, basename, relative, isAbsolute } from 'path';
|
|
5
|
+
import { join, basename, relative, isAbsolute, dirname } from 'path';
|
|
6
6
|
import { spawn, spawnSync } from 'child_process';
|
|
7
7
|
import axios from 'axios';
|
|
8
8
|
import { detectArchitectureType } from './arch-detect.js';
|
|
@@ -682,7 +682,7 @@ async function microservicesFlow(config, opts) {
|
|
|
682
682
|
cwd,
|
|
683
683
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
684
684
|
shell: true,
|
|
685
|
-
env: { ...process.env },
|
|
685
|
+
env: withExpressMountHook({ ...process.env }),
|
|
686
686
|
detached: false,
|
|
687
687
|
});
|
|
688
688
|
// Capture output so failures are debuggable without re-running the service by hand.
|
|
@@ -1663,6 +1663,26 @@ function suppressNodemonSelfRestart(cwd) {
|
|
|
1663
1663
|
catch { }
|
|
1664
1664
|
};
|
|
1665
1665
|
}
|
|
1666
|
+
/**
|
|
1667
|
+
* Preloads the Express mount hook (express-preload.ts) into the user's server, so router / sub-app mount
|
|
1668
|
+
* paths are recorded as they're registered — see express-mount-hook.ts. Without the built preload file
|
|
1669
|
+
* (e.g. running from source) the env is returned unchanged and prefixes are read off the stack as before.
|
|
1670
|
+
*/
|
|
1671
|
+
function withExpressMountHook(env) {
|
|
1672
|
+
let preload;
|
|
1673
|
+
try {
|
|
1674
|
+
// The bin is dist/esm/cli.js (or dist/cjs/cli.js); the preload must be the CommonJS build.
|
|
1675
|
+
preload = join(dirname(realpathSync(process.argv[1])), '..', 'cjs', 'express-preload.js');
|
|
1676
|
+
}
|
|
1677
|
+
catch {
|
|
1678
|
+
return env;
|
|
1679
|
+
}
|
|
1680
|
+
if (!existsSync(preload))
|
|
1681
|
+
return env;
|
|
1682
|
+
// Forward slashes: NODE_OPTIONS treats a backslash inside quotes as an escape.
|
|
1683
|
+
const flag = `--require "${preload.replace(/\\/g, '/')}"`;
|
|
1684
|
+
return { ...env, NODE_OPTIONS: env.NODE_OPTIONS ? `${env.NODE_OPTIONS} ${flag}` : flag };
|
|
1685
|
+
}
|
|
1666
1686
|
function startAndWaitForAnalysis(command, timeoutMs) {
|
|
1667
1687
|
return new Promise(resolve => {
|
|
1668
1688
|
const statusPath = join(process.cwd(), STATUS_FILE);
|
|
@@ -1670,7 +1690,7 @@ function startAndWaitForAnalysis(command, timeoutMs) {
|
|
|
1670
1690
|
cwd: process.cwd(),
|
|
1671
1691
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1672
1692
|
shell: true,
|
|
1673
|
-
env: { ...process.env },
|
|
1693
|
+
env: withExpressMountHook({ ...process.env }),
|
|
1674
1694
|
detached: false,
|
|
1675
1695
|
});
|
|
1676
1696
|
// Capture server output (head-capped) so we can show it on failure
|
package/dist/esm/detect.js
CHANGED
|
@@ -41,10 +41,19 @@ function mk(name, confidence) {
|
|
|
41
41
|
export function detectFramework(app) {
|
|
42
42
|
// ── Instance-based detection (highest confidence) ─────────────────────────
|
|
43
43
|
if (app && typeof app === 'function') {
|
|
44
|
-
// Express: _router or lazyrouter
|
|
44
|
+
// Express 4: _router or lazyrouter
|
|
45
45
|
if (app._router || (app.lazyrouter && typeof app.lazyrouter === 'function')) {
|
|
46
46
|
return mk('express', 1.0);
|
|
47
47
|
}
|
|
48
|
+
// Express 5 dropped both; the router is behind an `app.router` getter (which throws in Express 4).
|
|
49
|
+
// Without this, an Express 5 app in a project that also has an app/ folder was mistaken for Next.js.
|
|
50
|
+
try {
|
|
51
|
+
if (app.router && Array.isArray(app.router.stack))
|
|
52
|
+
return mk('express', 1.0);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// not Express 5
|
|
56
|
+
}
|
|
48
57
|
// Koa: middleware array
|
|
49
58
|
if (Array.isArray(app.middleware) && typeof app.use === 'function' && app.context) {
|
|
50
59
|
return mk('koa', 0.95);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Records where every router / sub-app is mounted, at the moment it is mounted.
|
|
3
|
+
*
|
|
4
|
+
* The Express analyzer reads mount prefixes back off the router stack after the fact, which loses them:
|
|
5
|
+
* - Express 5 (router@2) keeps no path on a layer at all (only compiled matchers), so every mounted
|
|
6
|
+
* router's routes came out without their prefix, and same-looking ones collapsed as duplicates.
|
|
7
|
+
* - A sub-app (`app.use('/admin', express())`) is wrapped in a `mounted_app` function with no `stack`,
|
|
8
|
+
* so none of its routes were found.
|
|
9
|
+
* - Express 4's mount regexp for a parameterised prefix (`/users/:id/posts`) doesn't turn back into the path.
|
|
10
|
+
*
|
|
11
|
+
* Wrapping `use()` tags each layer it adds with the mount path as written, and a sub-app's layer with the
|
|
12
|
+
* sub-app itself; express.ts prefers those tags and falls back to the old regexp reading without them.
|
|
13
|
+
* Must run before the app registers its routes — the CLI preloads it (express-preload.ts).
|
|
14
|
+
*/
|
|
15
|
+
export const MOUNT_PATH = '__apiforgeMountPath';
|
|
16
|
+
export const SUB_APP = '__apiforgeSubApp';
|
|
17
|
+
const INSTALLED = Symbol.for('apiforge.expressMountHook');
|
|
18
|
+
/** An app's router stack: `_router` in Express 4, the `router` getter in Express 5 (which throws in 4). */
|
|
19
|
+
export function appStack(app) {
|
|
20
|
+
const legacy = app?._router?.stack;
|
|
21
|
+
if (Array.isArray(legacy))
|
|
22
|
+
return legacy;
|
|
23
|
+
try {
|
|
24
|
+
const stack = app?.router?.stack;
|
|
25
|
+
return Array.isArray(stack) ? stack : undefined;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** The mount path `use()` was given, or undefined when the first argument is already a handler (Express's own rule). */
|
|
32
|
+
function mountPathArg(args) {
|
|
33
|
+
let arg = args[0];
|
|
34
|
+
while (Array.isArray(arg) && arg.length !== 0)
|
|
35
|
+
arg = arg[0];
|
|
36
|
+
return typeof arg === 'function' ? undefined : args[0];
|
|
37
|
+
}
|
|
38
|
+
function isApp(fn) {
|
|
39
|
+
return !!fn && typeof fn.handle === 'function' && typeof fn.set === 'function';
|
|
40
|
+
}
|
|
41
|
+
function wrapUse(target, stackOf) {
|
|
42
|
+
const original = target.use;
|
|
43
|
+
if (typeof original !== 'function')
|
|
44
|
+
return;
|
|
45
|
+
target.use = function apiforgeUse(...args) {
|
|
46
|
+
const before = stackOf(this)?.length ?? 0;
|
|
47
|
+
const result = original.apply(this, args);
|
|
48
|
+
try {
|
|
49
|
+
const added = (stackOf(this) ?? []).slice(before);
|
|
50
|
+
const path = mountPathArg(args) ?? '/';
|
|
51
|
+
const handlers = (mountPathArg(args) === undefined ? args : args.slice(1)).flat(Infinity);
|
|
52
|
+
const subApps = handlers.filter(isApp);
|
|
53
|
+
let next = 0;
|
|
54
|
+
for (const layer of added) {
|
|
55
|
+
layer[MOUNT_PATH] = path;
|
|
56
|
+
if (layer.handle?.name === 'mounted_app' && next < subApps.length)
|
|
57
|
+
layer[SUB_APP] = subApps[next++];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Never let documentation tagging break the user's app.
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export function installExpressMountHook(express) {
|
|
67
|
+
if (!express || express[INSTALLED])
|
|
68
|
+
return;
|
|
69
|
+
express[INSTALLED] = true;
|
|
70
|
+
// Express 5: Router is a class (router@2). Express 4: Router is the proto object itself.
|
|
71
|
+
const Router = express.Router;
|
|
72
|
+
const routerProto = typeof Router?.prototype?.use === 'function' ? Router.prototype : Router;
|
|
73
|
+
if (routerProto)
|
|
74
|
+
wrapUse(routerProto, (self) => (Array.isArray(self?.stack) ? self.stack : undefined));
|
|
75
|
+
if (express.application)
|
|
76
|
+
wrapUse(express.application, appStack);
|
|
77
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preloaded into the user's server by the CLI (`NODE_OPTIONS=--require …/dist/cjs/express-preload.js`) so
|
|
3
|
+
* the mount hook is in place before any route is registered. Loads the project's own express (resolved
|
|
4
|
+
* from the working directory); a project without express is left untouched.
|
|
5
|
+
*/
|
|
6
|
+
import { createRequire } from 'module';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import { installExpressMountHook } from './express-mount-hook.js';
|
|
9
|
+
try {
|
|
10
|
+
const projectRequire = createRequire(join(process.cwd(), 'noop.js'));
|
|
11
|
+
installExpressMountHook(projectRequire('express'));
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// No express here (npm, nodemon, a non-Express project): nothing to record.
|
|
15
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Express route analyzer — deep extraction of routes, parameters, and auth patterns
|
|
3
3
|
*/
|
|
4
|
+
import { MOUNT_PATH, SUB_APP, appStack } from '../express-mount-hook.js';
|
|
4
5
|
// ─── Path helpers ─────────────────────────────────────────────────────────────
|
|
5
6
|
function normalizePath(raw) {
|
|
6
7
|
return raw
|
|
@@ -49,29 +50,48 @@ export function analyzeExpress(app) {
|
|
|
49
50
|
console.warn('⚠️ No routes found — ensure routes are registered before analyze()');
|
|
50
51
|
return routes;
|
|
51
52
|
}
|
|
52
|
-
|
|
53
|
+
const skipped = [];
|
|
54
|
+
walkStack(stack, '', routes, seen, skipped);
|
|
55
|
+
if (skipped.length) {
|
|
56
|
+
// Same method + path registered more than once: Express only ever serves the first, so it is
|
|
57
|
+
// documented once — but say so, so the count can be checked against the code.
|
|
58
|
+
console.warn(`⚠️ Skipped ${skipped.length} duplicate route(s) (same method + path registered twice):`);
|
|
59
|
+
for (const s of skipped)
|
|
60
|
+
console.warn(` ${s}`);
|
|
61
|
+
}
|
|
53
62
|
return routes;
|
|
54
63
|
}
|
|
55
|
-
function walkStack(stack, basePath, out, seen) {
|
|
64
|
+
function walkStack(stack, basePath, out, seen, skipped) {
|
|
56
65
|
for (const layer of stack) {
|
|
57
66
|
if (layer.route) {
|
|
58
|
-
const r
|
|
59
|
-
|
|
67
|
+
for (const r of buildRoutes(layer.route, basePath)) {
|
|
68
|
+
const key = `${r.method} ${r.path}`;
|
|
69
|
+
if (seen.has(key)) {
|
|
70
|
+
skipped.push(key);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
60
73
|
out.push(r);
|
|
61
|
-
seen.add(
|
|
74
|
+
seen.add(key);
|
|
62
75
|
}
|
|
63
76
|
}
|
|
64
|
-
else
|
|
65
|
-
// Router or sub-app
|
|
77
|
+
else {
|
|
78
|
+
// Router mounted at a prefix, or a sub-app (its layer only holds a `mounted_app` wrapper, so the
|
|
79
|
+
// mount hook hands over the app itself)
|
|
80
|
+
const subStack = layer.handle?.stack ?? (layer[SUB_APP] ? appStack(layer[SUB_APP]) : undefined);
|
|
81
|
+
if (!subStack)
|
|
82
|
+
continue;
|
|
83
|
+
// The path as written to use(), when the mount hook saw it; otherwise read back off the layer
|
|
66
84
|
let prefix = '';
|
|
67
|
-
if (layer
|
|
85
|
+
if (typeof layer[MOUNT_PATH] === 'string')
|
|
86
|
+
prefix = normalizePath(layer[MOUNT_PATH]);
|
|
87
|
+
else if (layer.regexp?.source)
|
|
68
88
|
prefix = normalizePath(layer.regexp.source);
|
|
69
89
|
else if (layer.path)
|
|
70
90
|
prefix = normalizePath(layer.path);
|
|
71
91
|
else if (typeof layer.route === 'string')
|
|
72
92
|
prefix = normalizePath(layer.route);
|
|
73
93
|
const combined = combinePaths(basePath, prefix);
|
|
74
|
-
walkStack(
|
|
94
|
+
walkStack(subStack, combined, out, seen, skipped);
|
|
75
95
|
}
|
|
76
96
|
}
|
|
77
97
|
}
|
|
@@ -88,14 +108,14 @@ const AUTH_NAMES = new Set([
|
|
|
88
108
|
'checkToken', 'authMiddleware', 'jwt', 'bearer', 'apiKeyAuth',
|
|
89
109
|
'passport.authenticate',
|
|
90
110
|
]);
|
|
91
|
-
function
|
|
111
|
+
function buildRoutes(route, basePath) {
|
|
92
112
|
if (!route.path)
|
|
93
|
-
return
|
|
113
|
+
return [];
|
|
94
114
|
const methods = Object.keys(route.methods || {})
|
|
95
115
|
.filter(m => route.methods[m])
|
|
96
116
|
.map(m => m.toUpperCase());
|
|
97
117
|
if (!methods.length)
|
|
98
|
-
return
|
|
118
|
+
return [];
|
|
99
119
|
const cleanBase = normalizePath(basePath);
|
|
100
120
|
const cleanRoute = normalizePath(route.path);
|
|
101
121
|
let fullPath = (cleanBase === '/' ? '' : cleanBase) + cleanRoute;
|
|
@@ -139,19 +159,18 @@ function buildRoute(route, basePath) {
|
|
|
139
159
|
// ── Auth detection ─────────────────────────────────────────────────────────
|
|
140
160
|
const hasAuth = middleware.some(m => AUTH_NAMES.has(m) || [...AUTH_NAMES].some(a => m.toLowerCase().includes(a.toLowerCase()))) ||
|
|
141
161
|
/req\.user|req\.auth|req\.currentUser|verifyToken|jwt\.verify|passport\.authenticate/.test(src);
|
|
142
|
-
const method = methods[0];
|
|
143
162
|
const tag = tagFromPath(fullPath);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
.replace(/_+/g, '_')
|
|
148
|
-
.replace(/^_|_$/, '')}`;
|
|
149
|
-
return {
|
|
163
|
+
// One route per method: router.route('/x').get(...).put(...) registers a single layer
|
|
164
|
+
// carrying every method, and each one is its own endpoint.
|
|
165
|
+
return methods.map(method => ({
|
|
150
166
|
method,
|
|
151
167
|
path: fullPath,
|
|
152
168
|
tag,
|
|
153
|
-
operationId
|
|
154
|
-
|
|
169
|
+
operationId: `${method.toLowerCase()}_${fullPath
|
|
170
|
+
.replace(/[^a-zA-Z0-9]/g, '_')
|
|
171
|
+
.replace(/_+/g, '_')
|
|
172
|
+
.replace(/^_|_$/, '')}`,
|
|
173
|
+
summary: generateSummary(method, fullPath),
|
|
155
174
|
middleware,
|
|
156
175
|
authRequired: hasAuth,
|
|
157
176
|
pathParams,
|
|
@@ -162,7 +181,7 @@ function buildRoute(route, basePath) {
|
|
|
162
181
|
framework: 'express',
|
|
163
182
|
responseStatuses: responseStatuses.length ? responseStatuses : undefined,
|
|
164
183
|
headerParams: headerParams.length ? headerParams : undefined,
|
|
165
|
-
};
|
|
184
|
+
}));
|
|
166
185
|
}
|
|
167
186
|
// ─── Extraction helpers ───────────────────────────────────────────────────────
|
|
168
187
|
function matchAll(src, re) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Records where every router / sub-app is mounted, at the moment it is mounted.
|
|
3
|
+
*
|
|
4
|
+
* The Express analyzer reads mount prefixes back off the router stack after the fact, which loses them:
|
|
5
|
+
* - Express 5 (router@2) keeps no path on a layer at all (only compiled matchers), so every mounted
|
|
6
|
+
* router's routes came out without their prefix, and same-looking ones collapsed as duplicates.
|
|
7
|
+
* - A sub-app (`app.use('/admin', express())`) is wrapped in a `mounted_app` function with no `stack`,
|
|
8
|
+
* so none of its routes were found.
|
|
9
|
+
* - Express 4's mount regexp for a parameterised prefix (`/users/:id/posts`) doesn't turn back into the path.
|
|
10
|
+
*
|
|
11
|
+
* Wrapping `use()` tags each layer it adds with the mount path as written, and a sub-app's layer with the
|
|
12
|
+
* sub-app itself; express.ts prefers those tags and falls back to the old regexp reading without them.
|
|
13
|
+
* Must run before the app registers its routes — the CLI preloads it (express-preload.ts).
|
|
14
|
+
*/
|
|
15
|
+
export declare const MOUNT_PATH = "__apiforgeMountPath";
|
|
16
|
+
export declare const SUB_APP = "__apiforgeSubApp";
|
|
17
|
+
/** An app's router stack: `_router` in Express 4, the `router` getter in Express 5 (which throws in 4). */
|
|
18
|
+
export declare function appStack(app: any): any[] | undefined;
|
|
19
|
+
export declare function installExpressMountHook(express: any): void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|