apiforge-analyzer 1.3.3 → 1.3.5
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/dist/cjs/cli.js +161 -34
- package/dist/esm/cli.js +161 -34
- package/package.json +5 -5
package/dist/cjs/cli.js
CHANGED
|
@@ -220,7 +220,19 @@ async function main() {
|
|
|
220
220
|
].join('\n'), 'Running');
|
|
221
221
|
const s = (0, prompts_1.spinner)();
|
|
222
222
|
s.start('Starting server and analyzing routes…');
|
|
223
|
-
|
|
223
|
+
// If the dev command runs under nodemon, our own STATUS_FILE/LOCK_FILE
|
|
224
|
+
// writes (both plain .json files inside the project root) are themselves
|
|
225
|
+
// inside nodemon's default watch glob — writing them mid-analysis makes
|
|
226
|
+
// nodemon restart the very server we're trying to analyze, racing the old
|
|
227
|
+
// and new process for the port (EADDRINUSE) and hanging the whole run.
|
|
228
|
+
const restoreNodemonIgnore = suppressNodemonSelfRestart(process.cwd());
|
|
229
|
+
let serverResult;
|
|
230
|
+
try {
|
|
231
|
+
serverResult = await startAndWaitForAnalysis(runCommand, 90000);
|
|
232
|
+
}
|
|
233
|
+
finally {
|
|
234
|
+
restoreNodemonIgnore();
|
|
235
|
+
}
|
|
224
236
|
if (serverResult.status === 'error') {
|
|
225
237
|
s.stop(red(`✗ ${serverResult.message}`));
|
|
226
238
|
// Give a targeted hint for common failure modes
|
|
@@ -653,11 +665,9 @@ async function microservicesFlow(config, opts) {
|
|
|
653
665
|
child.stderr?.on('data', capture);
|
|
654
666
|
// Wait up to 30 s for the status file to appear
|
|
655
667
|
const routes = await waitForServiceStatusFile(statusFile, 30000);
|
|
656
|
-
// Kill service process
|
|
657
|
-
|
|
658
|
-
child.
|
|
659
|
-
}
|
|
660
|
-
catch { }
|
|
668
|
+
// Kill service process (and its full descendant tree — see killProcessTree)
|
|
669
|
+
if (child.pid)
|
|
670
|
+
killProcessTree(child.pid);
|
|
661
671
|
tryUnlink((0, path_1.join)(cwd, statusFile));
|
|
662
672
|
if (routes && routes.length > 0) {
|
|
663
673
|
const prefix = gatewayPrefixes[svc.name];
|
|
@@ -759,6 +769,84 @@ async function microservicesFlow(config, opts) {
|
|
|
759
769
|
}[approval.status];
|
|
760
770
|
(0, prompts_1.outro)(outroMsg);
|
|
761
771
|
}
|
|
772
|
+
// ─── Server subprocess ────────────────────────────────────────────────────────
|
|
773
|
+
/**
|
|
774
|
+
* Kill the whole process tree rooted at `pid`, not just that one process.
|
|
775
|
+
*
|
|
776
|
+
* The dev server is started via `spawn(command, { shell: true })`, so `pid`
|
|
777
|
+
* is the shell (cmd.exe /c "npm run dev") — `child.kill()` only signals that
|
|
778
|
+
* shell. On Windows, that leaves the real descendants (npm → nodemon → node)
|
|
779
|
+
* running and still holding the port, so the very next run immediately hits
|
|
780
|
+
* EADDRINUSE. `taskkill /T` kills the shell's entire descendant tree instead.
|
|
781
|
+
* On POSIX this isn't needed the same way (SIGTERM propagates to the shell's
|
|
782
|
+
* job, and the analyzer process itself exits cleanly), so a plain kill there
|
|
783
|
+
* is enough.
|
|
784
|
+
*/
|
|
785
|
+
function killProcessTree(pid) {
|
|
786
|
+
if (process.platform === 'win32') {
|
|
787
|
+
try {
|
|
788
|
+
(0, child_process_1.spawn)('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' });
|
|
789
|
+
}
|
|
790
|
+
catch { }
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
793
|
+
try {
|
|
794
|
+
process.kill(pid, 'SIGTERM');
|
|
795
|
+
}
|
|
796
|
+
catch { }
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
const NODEMON_IGNORE_PATTERNS = ['.apiforge-status.json', '.apiforge-last-run.json', '.env.local'];
|
|
800
|
+
/**
|
|
801
|
+
* If the project depends on nodemon, make sure it won't watch our own
|
|
802
|
+
* .apiforge-status.json / .apiforge-last-run.json / .env.local writes —
|
|
803
|
+
* nodemon's default watch glob includes *.json, so without this it restarts
|
|
804
|
+
* the app mid-analysis (racing the old and new process for the port) every
|
|
805
|
+
* time analyze() writes its completion signal.
|
|
806
|
+
*
|
|
807
|
+
* Uses nodemon's own `.nodemonignore` file (read automatically if present,
|
|
808
|
+
* no config changes needed). Creates it if absent, or appends any missing
|
|
809
|
+
* patterns if it already exists — then returns a function that restores
|
|
810
|
+
* whatever was there before (or removes the file if we created it).
|
|
811
|
+
*/
|
|
812
|
+
function suppressNodemonSelfRestart(cwd) {
|
|
813
|
+
try {
|
|
814
|
+
const pkg = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(cwd, 'package.json'), 'utf-8'));
|
|
815
|
+
const usesNodemon = !!(pkg.dependencies?.nodemon || pkg.devDependencies?.nodemon);
|
|
816
|
+
if (!usesNodemon)
|
|
817
|
+
return () => { };
|
|
818
|
+
}
|
|
819
|
+
catch {
|
|
820
|
+
return () => { };
|
|
821
|
+
}
|
|
822
|
+
const ignorePath = (0, path_1.join)(cwd, '.nodemonignore');
|
|
823
|
+
const existed = (0, fs_1.existsSync)(ignorePath);
|
|
824
|
+
const original = existed ? (0, fs_1.readFileSync)(ignorePath, 'utf-8') : '';
|
|
825
|
+
const existingLines = new Set(original.split('\n').map(l => l.trim()).filter(Boolean));
|
|
826
|
+
const missing = NODEMON_IGNORE_PATTERNS.filter(p => !existingLines.has(p));
|
|
827
|
+
if (missing.length === 0)
|
|
828
|
+
return () => { };
|
|
829
|
+
try {
|
|
830
|
+
const next = existed
|
|
831
|
+
? original.trimEnd() + '\n' + missing.join('\n') + '\n'
|
|
832
|
+
: missing.join('\n') + '\n';
|
|
833
|
+
(0, fs_1.writeFileSync)(ignorePath, next, 'utf-8');
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
return () => { };
|
|
837
|
+
}
|
|
838
|
+
return () => {
|
|
839
|
+
try {
|
|
840
|
+
if (existed) {
|
|
841
|
+
(0, fs_1.writeFileSync)(ignorePath, original, 'utf-8');
|
|
842
|
+
}
|
|
843
|
+
else {
|
|
844
|
+
(0, fs_1.unlinkSync)(ignorePath);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
catch { }
|
|
848
|
+
};
|
|
849
|
+
}
|
|
762
850
|
function startAndWaitForAnalysis(command, timeoutMs) {
|
|
763
851
|
return new Promise(resolve => {
|
|
764
852
|
const statusPath = (0, path_1.join)(process.cwd(), STATUS_FILE);
|
|
@@ -785,10 +873,8 @@ function startAndWaitForAnalysis(command, timeoutMs) {
|
|
|
785
873
|
resolved = true;
|
|
786
874
|
clearInterval(poller);
|
|
787
875
|
clearTimeout(timeout);
|
|
788
|
-
|
|
789
|
-
child.
|
|
790
|
-
}
|
|
791
|
-
catch { }
|
|
876
|
+
if (child.pid)
|
|
877
|
+
killProcessTree(child.pid);
|
|
792
878
|
resolve({ ...result, serverOutput: serverOutput.trim() });
|
|
793
879
|
}
|
|
794
880
|
// Poll for the status file every 500 ms
|
|
@@ -899,6 +985,28 @@ function removeInjectedCode(filePath) {
|
|
|
899
985
|
return false;
|
|
900
986
|
}
|
|
901
987
|
}
|
|
988
|
+
// ─── Entry file detection ─────────────────────────────────────────────────────
|
|
989
|
+
/**
|
|
990
|
+
* If `candidate` looks like compiled build output (dist/build/out/lib) and a
|
|
991
|
+
* TypeScript source with the same base name exists under src/, prefer that
|
|
992
|
+
* instead of the build artifact.
|
|
993
|
+
*
|
|
994
|
+
* Injecting into dist/index.js is worse than pointless when the project has
|
|
995
|
+
* a watch compiler (tsc --watch, common in a "dev" script alongside
|
|
996
|
+
* nodemon watching that same dist folder): the compiler immediately
|
|
997
|
+
* overwrites the file with a fresh, uninjected build, and that overwrite
|
|
998
|
+
* itself looks like a file change to nodemon, which restarts the server —
|
|
999
|
+
* colliding with the still-running instance that had the injected snippet
|
|
1000
|
+
* and racing it for the port (EADDRINUSE). The injected code also can't be
|
|
1001
|
+
* cleaned up afterward because tsc already erased it before cleanup ran.
|
|
1002
|
+
*/
|
|
1003
|
+
function preferSourceOverBuildOutput(cwd, candidate) {
|
|
1004
|
+
const match = candidate.match(/^(?:dist|build|out|lib)[\\/](.+)\.(?:js|mjs|cjs)$/);
|
|
1005
|
+
if (!match)
|
|
1006
|
+
return candidate;
|
|
1007
|
+
const srcCandidate = (0, path_1.join)('src', `${match[1]}.ts`);
|
|
1008
|
+
return (0, fs_1.existsSync)((0, path_1.join)(cwd, srcCandidate)) ? srcCandidate : candidate;
|
|
1009
|
+
}
|
|
902
1010
|
/** Look for a server entry inside a single directory (checks src/ then root). */
|
|
903
1011
|
function findEntryInDir(absDir) {
|
|
904
1012
|
const names = ['index', 'server', 'app', 'main'];
|
|
@@ -924,12 +1032,12 @@ function detectMainEntry(cwd) {
|
|
|
924
1032
|
try {
|
|
925
1033
|
const pkg = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(cwd, 'package.json'), 'utf-8'));
|
|
926
1034
|
if (pkg.main && (0, fs_1.existsSync)((0, path_1.join)(cwd, pkg.main)))
|
|
927
|
-
return pkg.main;
|
|
1035
|
+
return preferSourceOverBuildOutput(cwd, pkg.main);
|
|
928
1036
|
for (const s of ['start', 'dev', 'serve']) {
|
|
929
1037
|
const cmd = pkg.scripts?.[s] ?? '';
|
|
930
|
-
const m = cmd.match(/(?:node|tsx|ts-node|nodemon)\s+([^\s&|;]+\.(js|ts|mjs|cjs))/);
|
|
1038
|
+
const m = cmd.match(/(?:node|tsx|ts-node|nodemon)\s+(?:-\S+\s+)*([^\s&|;]+\.(js|ts|mjs|cjs))/);
|
|
931
1039
|
if (m && (0, fs_1.existsSync)((0, path_1.join)(cwd, m[1])))
|
|
932
|
-
return m[1];
|
|
1040
|
+
return preferSourceOverBuildOutput(cwd, m[1]);
|
|
933
1041
|
}
|
|
934
1042
|
}
|
|
935
1043
|
catch { }
|
|
@@ -1020,12 +1128,12 @@ function detectEntryFile(archType) {
|
|
|
1020
1128
|
try {
|
|
1021
1129
|
const pkg = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(cwd, 'package.json'), 'utf-8'));
|
|
1022
1130
|
if (pkg.main && (0, fs_1.existsSync)((0, path_1.join)(cwd, pkg.main)))
|
|
1023
|
-
return pkg.main;
|
|
1131
|
+
return preferSourceOverBuildOutput(cwd, pkg.main);
|
|
1024
1132
|
for (const s of ['start', 'dev', 'serve']) {
|
|
1025
1133
|
const cmd = pkg.scripts?.[s] ?? '';
|
|
1026
|
-
const m = cmd.match(/(?:node|tsx|ts-node|nodemon)\s+([^\s&|;]+\.(js|ts|mjs|cjs))/);
|
|
1134
|
+
const m = cmd.match(/(?:node|tsx|ts-node|nodemon)\s+(?:-\S+\s+)*([^\s&|;]+\.(js|ts|mjs|cjs))/);
|
|
1027
1135
|
if (m && (0, fs_1.existsSync)((0, path_1.join)(cwd, m[1])))
|
|
1028
|
-
return m[1];
|
|
1136
|
+
return preferSourceOverBuildOutput(cwd, m[1]);
|
|
1029
1137
|
}
|
|
1030
1138
|
}
|
|
1031
1139
|
catch { }
|
|
@@ -1216,24 +1324,37 @@ function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, a
|
|
|
1216
1324
|
].join('\n');
|
|
1217
1325
|
// Collection mode: some services only mount routes inside an async bootstrap
|
|
1218
1326
|
// gated behind a DB/broker connect (which may hang/fail outside Docker).
|
|
1219
|
-
//
|
|
1220
|
-
//
|
|
1221
|
-
//
|
|
1327
|
+
//
|
|
1328
|
+
// Express exposes no "I'm done registering routes" signal, so for
|
|
1329
|
+
// Express-shaped apps we poll app._router.stack briefly before giving up
|
|
1330
|
+
// and collecting whatever is there — bounded well under the CLI's 30s
|
|
1331
|
+
// per-service timeout so a hung connect still surfaces.
|
|
1332
|
+
//
|
|
1333
|
+
// Fastify DOES expose a real completion signal (`app.ready()`, resolved
|
|
1334
|
+
// once every plugin/route has finished registering), so Fastify apps use
|
|
1335
|
+
// that instead of the Express-shaped poll — polling `._router.stack` on a
|
|
1336
|
+
// Fastify instance always looks "empty" (Fastify doesn't have that
|
|
1337
|
+
// property), which used to make hasRoutes evaluate true immediately and
|
|
1338
|
+
// fire analyze() before async-registered routes existed yet.
|
|
1222
1339
|
const runAnalyze = `APIForge.analyze(${appVar}, {\n${opts}\n })`;
|
|
1223
1340
|
const call = collectionOpts
|
|
1224
1341
|
? [
|
|
1225
|
-
`
|
|
1226
|
-
`
|
|
1227
|
-
`
|
|
1228
|
-
`
|
|
1229
|
-
`
|
|
1230
|
-
`
|
|
1231
|
-
`
|
|
1232
|
-
`
|
|
1233
|
-
`
|
|
1342
|
+
` if (${appVar} && typeof ${appVar}.ready === 'function') {`,
|
|
1343
|
+
` ${appVar}.ready().then(() => { ${runAnalyze} }).catch(() => { ${runAnalyze} })`,
|
|
1344
|
+
` } else {`,
|
|
1345
|
+
` let __apiforgeTries = 0`,
|
|
1346
|
+
` const __apiforgeTryCollect = () => {`,
|
|
1347
|
+
` const hasRoutes = !${appVar} || !${appVar}._router`,
|
|
1348
|
+
` ? true`,
|
|
1349
|
+
` : Array.isArray(${appVar}._router.stack) && ${appVar}._router.stack.length > 0`,
|
|
1350
|
+
` if (hasRoutes || __apiforgeTries++ >= 26) {`,
|
|
1351
|
+
` ${runAnalyze}`,
|
|
1352
|
+
` } else {`,
|
|
1353
|
+
` setTimeout(__apiforgeTryCollect, 300)`,
|
|
1354
|
+
` }`,
|
|
1234
1355
|
` }`,
|
|
1356
|
+
` __apiforgeTryCollect()`,
|
|
1235
1357
|
` }`,
|
|
1236
|
-
` __apiforgeTryCollect()`,
|
|
1237
1358
|
].join('\n')
|
|
1238
1359
|
: ` ${runAnalyze}`;
|
|
1239
1360
|
if (isESM) {
|
|
@@ -1268,17 +1389,23 @@ function detectESM(filePath, content) {
|
|
|
1268
1389
|
return /^(?:import|export)\s+/m.test(content);
|
|
1269
1390
|
}
|
|
1270
1391
|
function detectAppVariable(content) {
|
|
1392
|
+
// Optional TS type annotation between the variable name and `=`, e.g.
|
|
1393
|
+
// `const app: FastifyInstance = fastify()` — common in Fastify+TS
|
|
1394
|
+
// projects (Fastify has first-class TS typings, unlike bare Express).
|
|
1395
|
+
// Without this, none of the patterns below matched a type-annotated
|
|
1396
|
+
// Fastify app at all.
|
|
1397
|
+
const typeAnn = `(?:\\s*:\\s*[\\w.$]+(?:<[^>]*>)?)?`;
|
|
1271
1398
|
const patterns = [
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1399
|
+
new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*express\\s*(?:\\.default)?\\s*\\(`),
|
|
1400
|
+
new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*(?:fastify|Fastify)\\s*\\(`),
|
|
1401
|
+
new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*new\\s+Koa\\s*\\(`),
|
|
1402
|
+
new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*(?:Router|createApp|createServer)\\s*\\(`),
|
|
1276
1403
|
// Inline `require('fastify')(...)` / `require('express')(...)` — the app
|
|
1277
1404
|
// factory is never bound to a local name, only its return value is. This
|
|
1278
1405
|
// is at least as common as the named-import style above (it's the pattern
|
|
1279
1406
|
// in Fastify's own project generator output), and the patterns above never
|
|
1280
1407
|
// match it because there's no `fastify`/`express` identifier in the source.
|
|
1281
|
-
|
|
1408
|
+
new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*require\\s*\\(\\s*['"](?:fastify|express)['"]\\s*\\)\\s*(?:\\.default\\s*)?\\(`),
|
|
1282
1409
|
];
|
|
1283
1410
|
for (const re of patterns) {
|
|
1284
1411
|
const m = content.match(re);
|
package/dist/esm/cli.js
CHANGED
|
@@ -215,7 +215,19 @@ async function main() {
|
|
|
215
215
|
].join('\n'), 'Running');
|
|
216
216
|
const s = spinner();
|
|
217
217
|
s.start('Starting server and analyzing routes…');
|
|
218
|
-
|
|
218
|
+
// If the dev command runs under nodemon, our own STATUS_FILE/LOCK_FILE
|
|
219
|
+
// writes (both plain .json files inside the project root) are themselves
|
|
220
|
+
// inside nodemon's default watch glob — writing them mid-analysis makes
|
|
221
|
+
// nodemon restart the very server we're trying to analyze, racing the old
|
|
222
|
+
// and new process for the port (EADDRINUSE) and hanging the whole run.
|
|
223
|
+
const restoreNodemonIgnore = suppressNodemonSelfRestart(process.cwd());
|
|
224
|
+
let serverResult;
|
|
225
|
+
try {
|
|
226
|
+
serverResult = await startAndWaitForAnalysis(runCommand, 90000);
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
restoreNodemonIgnore();
|
|
230
|
+
}
|
|
219
231
|
if (serverResult.status === 'error') {
|
|
220
232
|
s.stop(red(`✗ ${serverResult.message}`));
|
|
221
233
|
// Give a targeted hint for common failure modes
|
|
@@ -648,11 +660,9 @@ async function microservicesFlow(config, opts) {
|
|
|
648
660
|
child.stderr?.on('data', capture);
|
|
649
661
|
// Wait up to 30 s for the status file to appear
|
|
650
662
|
const routes = await waitForServiceStatusFile(statusFile, 30000);
|
|
651
|
-
// Kill service process
|
|
652
|
-
|
|
653
|
-
child.
|
|
654
|
-
}
|
|
655
|
-
catch { }
|
|
663
|
+
// Kill service process (and its full descendant tree — see killProcessTree)
|
|
664
|
+
if (child.pid)
|
|
665
|
+
killProcessTree(child.pid);
|
|
656
666
|
tryUnlink(join(cwd, statusFile));
|
|
657
667
|
if (routes && routes.length > 0) {
|
|
658
668
|
const prefix = gatewayPrefixes[svc.name];
|
|
@@ -754,6 +764,84 @@ async function microservicesFlow(config, opts) {
|
|
|
754
764
|
}[approval.status];
|
|
755
765
|
outro(outroMsg);
|
|
756
766
|
}
|
|
767
|
+
// ─── Server subprocess ────────────────────────────────────────────────────────
|
|
768
|
+
/**
|
|
769
|
+
* Kill the whole process tree rooted at `pid`, not just that one process.
|
|
770
|
+
*
|
|
771
|
+
* The dev server is started via `spawn(command, { shell: true })`, so `pid`
|
|
772
|
+
* is the shell (cmd.exe /c "npm run dev") — `child.kill()` only signals that
|
|
773
|
+
* shell. On Windows, that leaves the real descendants (npm → nodemon → node)
|
|
774
|
+
* running and still holding the port, so the very next run immediately hits
|
|
775
|
+
* EADDRINUSE. `taskkill /T` kills the shell's entire descendant tree instead.
|
|
776
|
+
* On POSIX this isn't needed the same way (SIGTERM propagates to the shell's
|
|
777
|
+
* job, and the analyzer process itself exits cleanly), so a plain kill there
|
|
778
|
+
* is enough.
|
|
779
|
+
*/
|
|
780
|
+
function killProcessTree(pid) {
|
|
781
|
+
if (process.platform === 'win32') {
|
|
782
|
+
try {
|
|
783
|
+
spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' });
|
|
784
|
+
}
|
|
785
|
+
catch { }
|
|
786
|
+
}
|
|
787
|
+
else {
|
|
788
|
+
try {
|
|
789
|
+
process.kill(pid, 'SIGTERM');
|
|
790
|
+
}
|
|
791
|
+
catch { }
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
const NODEMON_IGNORE_PATTERNS = ['.apiforge-status.json', '.apiforge-last-run.json', '.env.local'];
|
|
795
|
+
/**
|
|
796
|
+
* If the project depends on nodemon, make sure it won't watch our own
|
|
797
|
+
* .apiforge-status.json / .apiforge-last-run.json / .env.local writes —
|
|
798
|
+
* nodemon's default watch glob includes *.json, so without this it restarts
|
|
799
|
+
* the app mid-analysis (racing the old and new process for the port) every
|
|
800
|
+
* time analyze() writes its completion signal.
|
|
801
|
+
*
|
|
802
|
+
* Uses nodemon's own `.nodemonignore` file (read automatically if present,
|
|
803
|
+
* no config changes needed). Creates it if absent, or appends any missing
|
|
804
|
+
* patterns if it already exists — then returns a function that restores
|
|
805
|
+
* whatever was there before (or removes the file if we created it).
|
|
806
|
+
*/
|
|
807
|
+
function suppressNodemonSelfRestart(cwd) {
|
|
808
|
+
try {
|
|
809
|
+
const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8'));
|
|
810
|
+
const usesNodemon = !!(pkg.dependencies?.nodemon || pkg.devDependencies?.nodemon);
|
|
811
|
+
if (!usesNodemon)
|
|
812
|
+
return () => { };
|
|
813
|
+
}
|
|
814
|
+
catch {
|
|
815
|
+
return () => { };
|
|
816
|
+
}
|
|
817
|
+
const ignorePath = join(cwd, '.nodemonignore');
|
|
818
|
+
const existed = existsSync(ignorePath);
|
|
819
|
+
const original = existed ? readFileSync(ignorePath, 'utf-8') : '';
|
|
820
|
+
const existingLines = new Set(original.split('\n').map(l => l.trim()).filter(Boolean));
|
|
821
|
+
const missing = NODEMON_IGNORE_PATTERNS.filter(p => !existingLines.has(p));
|
|
822
|
+
if (missing.length === 0)
|
|
823
|
+
return () => { };
|
|
824
|
+
try {
|
|
825
|
+
const next = existed
|
|
826
|
+
? original.trimEnd() + '\n' + missing.join('\n') + '\n'
|
|
827
|
+
: missing.join('\n') + '\n';
|
|
828
|
+
writeFileSync(ignorePath, next, 'utf-8');
|
|
829
|
+
}
|
|
830
|
+
catch {
|
|
831
|
+
return () => { };
|
|
832
|
+
}
|
|
833
|
+
return () => {
|
|
834
|
+
try {
|
|
835
|
+
if (existed) {
|
|
836
|
+
writeFileSync(ignorePath, original, 'utf-8');
|
|
837
|
+
}
|
|
838
|
+
else {
|
|
839
|
+
unlinkSync(ignorePath);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
catch { }
|
|
843
|
+
};
|
|
844
|
+
}
|
|
757
845
|
function startAndWaitForAnalysis(command, timeoutMs) {
|
|
758
846
|
return new Promise(resolve => {
|
|
759
847
|
const statusPath = join(process.cwd(), STATUS_FILE);
|
|
@@ -780,10 +868,8 @@ function startAndWaitForAnalysis(command, timeoutMs) {
|
|
|
780
868
|
resolved = true;
|
|
781
869
|
clearInterval(poller);
|
|
782
870
|
clearTimeout(timeout);
|
|
783
|
-
|
|
784
|
-
child.
|
|
785
|
-
}
|
|
786
|
-
catch { }
|
|
871
|
+
if (child.pid)
|
|
872
|
+
killProcessTree(child.pid);
|
|
787
873
|
resolve({ ...result, serverOutput: serverOutput.trim() });
|
|
788
874
|
}
|
|
789
875
|
// Poll for the status file every 500 ms
|
|
@@ -894,6 +980,28 @@ function removeInjectedCode(filePath) {
|
|
|
894
980
|
return false;
|
|
895
981
|
}
|
|
896
982
|
}
|
|
983
|
+
// ─── Entry file detection ─────────────────────────────────────────────────────
|
|
984
|
+
/**
|
|
985
|
+
* If `candidate` looks like compiled build output (dist/build/out/lib) and a
|
|
986
|
+
* TypeScript source with the same base name exists under src/, prefer that
|
|
987
|
+
* instead of the build artifact.
|
|
988
|
+
*
|
|
989
|
+
* Injecting into dist/index.js is worse than pointless when the project has
|
|
990
|
+
* a watch compiler (tsc --watch, common in a "dev" script alongside
|
|
991
|
+
* nodemon watching that same dist folder): the compiler immediately
|
|
992
|
+
* overwrites the file with a fresh, uninjected build, and that overwrite
|
|
993
|
+
* itself looks like a file change to nodemon, which restarts the server —
|
|
994
|
+
* colliding with the still-running instance that had the injected snippet
|
|
995
|
+
* and racing it for the port (EADDRINUSE). The injected code also can't be
|
|
996
|
+
* cleaned up afterward because tsc already erased it before cleanup ran.
|
|
997
|
+
*/
|
|
998
|
+
function preferSourceOverBuildOutput(cwd, candidate) {
|
|
999
|
+
const match = candidate.match(/^(?:dist|build|out|lib)[\\/](.+)\.(?:js|mjs|cjs)$/);
|
|
1000
|
+
if (!match)
|
|
1001
|
+
return candidate;
|
|
1002
|
+
const srcCandidate = join('src', `${match[1]}.ts`);
|
|
1003
|
+
return existsSync(join(cwd, srcCandidate)) ? srcCandidate : candidate;
|
|
1004
|
+
}
|
|
897
1005
|
/** Look for a server entry inside a single directory (checks src/ then root). */
|
|
898
1006
|
function findEntryInDir(absDir) {
|
|
899
1007
|
const names = ['index', 'server', 'app', 'main'];
|
|
@@ -919,12 +1027,12 @@ function detectMainEntry(cwd) {
|
|
|
919
1027
|
try {
|
|
920
1028
|
const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8'));
|
|
921
1029
|
if (pkg.main && existsSync(join(cwd, pkg.main)))
|
|
922
|
-
return pkg.main;
|
|
1030
|
+
return preferSourceOverBuildOutput(cwd, pkg.main);
|
|
923
1031
|
for (const s of ['start', 'dev', 'serve']) {
|
|
924
1032
|
const cmd = pkg.scripts?.[s] ?? '';
|
|
925
|
-
const m = cmd.match(/(?:node|tsx|ts-node|nodemon)\s+([^\s&|;]+\.(js|ts|mjs|cjs))/);
|
|
1033
|
+
const m = cmd.match(/(?:node|tsx|ts-node|nodemon)\s+(?:-\S+\s+)*([^\s&|;]+\.(js|ts|mjs|cjs))/);
|
|
926
1034
|
if (m && existsSync(join(cwd, m[1])))
|
|
927
|
-
return m[1];
|
|
1035
|
+
return preferSourceOverBuildOutput(cwd, m[1]);
|
|
928
1036
|
}
|
|
929
1037
|
}
|
|
930
1038
|
catch { }
|
|
@@ -1015,12 +1123,12 @@ function detectEntryFile(archType) {
|
|
|
1015
1123
|
try {
|
|
1016
1124
|
const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8'));
|
|
1017
1125
|
if (pkg.main && existsSync(join(cwd, pkg.main)))
|
|
1018
|
-
return pkg.main;
|
|
1126
|
+
return preferSourceOverBuildOutput(cwd, pkg.main);
|
|
1019
1127
|
for (const s of ['start', 'dev', 'serve']) {
|
|
1020
1128
|
const cmd = pkg.scripts?.[s] ?? '';
|
|
1021
|
-
const m = cmd.match(/(?:node|tsx|ts-node|nodemon)\s+([^\s&|;]+\.(js|ts|mjs|cjs))/);
|
|
1129
|
+
const m = cmd.match(/(?:node|tsx|ts-node|nodemon)\s+(?:-\S+\s+)*([^\s&|;]+\.(js|ts|mjs|cjs))/);
|
|
1022
1130
|
if (m && existsSync(join(cwd, m[1])))
|
|
1023
|
-
return m[1];
|
|
1131
|
+
return preferSourceOverBuildOutput(cwd, m[1]);
|
|
1024
1132
|
}
|
|
1025
1133
|
}
|
|
1026
1134
|
catch { }
|
|
@@ -1211,24 +1319,37 @@ function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, a
|
|
|
1211
1319
|
].join('\n');
|
|
1212
1320
|
// Collection mode: some services only mount routes inside an async bootstrap
|
|
1213
1321
|
// gated behind a DB/broker connect (which may hang/fail outside Docker).
|
|
1214
|
-
//
|
|
1215
|
-
//
|
|
1216
|
-
//
|
|
1322
|
+
//
|
|
1323
|
+
// Express exposes no "I'm done registering routes" signal, so for
|
|
1324
|
+
// Express-shaped apps we poll app._router.stack briefly before giving up
|
|
1325
|
+
// and collecting whatever is there — bounded well under the CLI's 30s
|
|
1326
|
+
// per-service timeout so a hung connect still surfaces.
|
|
1327
|
+
//
|
|
1328
|
+
// Fastify DOES expose a real completion signal (`app.ready()`, resolved
|
|
1329
|
+
// once every plugin/route has finished registering), so Fastify apps use
|
|
1330
|
+
// that instead of the Express-shaped poll — polling `._router.stack` on a
|
|
1331
|
+
// Fastify instance always looks "empty" (Fastify doesn't have that
|
|
1332
|
+
// property), which used to make hasRoutes evaluate true immediately and
|
|
1333
|
+
// fire analyze() before async-registered routes existed yet.
|
|
1217
1334
|
const runAnalyze = `APIForge.analyze(${appVar}, {\n${opts}\n })`;
|
|
1218
1335
|
const call = collectionOpts
|
|
1219
1336
|
? [
|
|
1220
|
-
`
|
|
1221
|
-
`
|
|
1222
|
-
`
|
|
1223
|
-
`
|
|
1224
|
-
`
|
|
1225
|
-
`
|
|
1226
|
-
`
|
|
1227
|
-
`
|
|
1228
|
-
`
|
|
1337
|
+
` if (${appVar} && typeof ${appVar}.ready === 'function') {`,
|
|
1338
|
+
` ${appVar}.ready().then(() => { ${runAnalyze} }).catch(() => { ${runAnalyze} })`,
|
|
1339
|
+
` } else {`,
|
|
1340
|
+
` let __apiforgeTries = 0`,
|
|
1341
|
+
` const __apiforgeTryCollect = () => {`,
|
|
1342
|
+
` const hasRoutes = !${appVar} || !${appVar}._router`,
|
|
1343
|
+
` ? true`,
|
|
1344
|
+
` : Array.isArray(${appVar}._router.stack) && ${appVar}._router.stack.length > 0`,
|
|
1345
|
+
` if (hasRoutes || __apiforgeTries++ >= 26) {`,
|
|
1346
|
+
` ${runAnalyze}`,
|
|
1347
|
+
` } else {`,
|
|
1348
|
+
` setTimeout(__apiforgeTryCollect, 300)`,
|
|
1349
|
+
` }`,
|
|
1229
1350
|
` }`,
|
|
1351
|
+
` __apiforgeTryCollect()`,
|
|
1230
1352
|
` }`,
|
|
1231
|
-
` __apiforgeTryCollect()`,
|
|
1232
1353
|
].join('\n')
|
|
1233
1354
|
: ` ${runAnalyze}`;
|
|
1234
1355
|
if (isESM) {
|
|
@@ -1263,17 +1384,23 @@ function detectESM(filePath, content) {
|
|
|
1263
1384
|
return /^(?:import|export)\s+/m.test(content);
|
|
1264
1385
|
}
|
|
1265
1386
|
function detectAppVariable(content) {
|
|
1387
|
+
// Optional TS type annotation between the variable name and `=`, e.g.
|
|
1388
|
+
// `const app: FastifyInstance = fastify()` — common in Fastify+TS
|
|
1389
|
+
// projects (Fastify has first-class TS typings, unlike bare Express).
|
|
1390
|
+
// Without this, none of the patterns below matched a type-annotated
|
|
1391
|
+
// Fastify app at all.
|
|
1392
|
+
const typeAnn = `(?:\\s*:\\s*[\\w.$]+(?:<[^>]*>)?)?`;
|
|
1266
1393
|
const patterns = [
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1394
|
+
new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*express\\s*(?:\\.default)?\\s*\\(`),
|
|
1395
|
+
new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*(?:fastify|Fastify)\\s*\\(`),
|
|
1396
|
+
new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*new\\s+Koa\\s*\\(`),
|
|
1397
|
+
new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*(?:Router|createApp|createServer)\\s*\\(`),
|
|
1271
1398
|
// Inline `require('fastify')(...)` / `require('express')(...)` — the app
|
|
1272
1399
|
// factory is never bound to a local name, only its return value is. This
|
|
1273
1400
|
// is at least as common as the named-import style above (it's the pattern
|
|
1274
1401
|
// in Fastify's own project generator output), and the patterns above never
|
|
1275
1402
|
// match it because there's no `fastify`/`express` identifier in the source.
|
|
1276
|
-
|
|
1403
|
+
new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*require\\s*\\(\\s*['"](?:fastify|express)['"]\\s*\\)\\s*(?:\\.default\\s*)?\\(`),
|
|
1277
1404
|
];
|
|
1278
1405
|
for (const re of patterns) {
|
|
1279
1406
|
const m = content.match(re);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apiforge-analyzer",
|
|
3
|
-
"version": "1.3.
|
|
4
|
-
"description": "Universal API documentation generator for Express,
|
|
3
|
+
"version": "1.3.5",
|
|
4
|
+
"description": "Universal API documentation generator for Express, Next.js, Vite, React Router, and Node.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/cjs/index.js",
|
|
7
7
|
"module": "./dist/esm/index.js",
|
|
@@ -54,14 +54,14 @@
|
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@clack/prompts": "^0.9.1",
|
|
57
|
-
"axios": "^1.6.0"
|
|
57
|
+
"axios": "^1.6.0",
|
|
58
|
+
"ts-morph": "^28.0.0"
|
|
58
59
|
},
|
|
59
60
|
"devDependencies": {
|
|
60
61
|
"@types/express": "^4.17.21",
|
|
61
62
|
"@types/node": "^20.0.0",
|
|
62
63
|
"fastify": "^5.12.1",
|
|
63
|
-
"ts-morph": "^28.0.0",
|
|
64
64
|
"typescript": "^5.3.0",
|
|
65
65
|
"zod": "^4.5.4"
|
|
66
66
|
}
|
|
67
|
-
}
|
|
67
|
+
}
|