apiforge-analyzer 1.3.2 → 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/README.md +165 -3
- package/dist/cjs/cli.js +197 -40
- package/dist/cjs/detect.js +34 -16
- package/dist/cjs/frameworks/fastify.js +1112 -0
- package/dist/cjs/index.js +29 -6
- package/dist/cjs/package.json +1 -1
- package/dist/esm/cli.js +197 -40
- package/dist/esm/detect.js +33 -16
- package/dist/esm/frameworks/fastify.js +1076 -0
- package/dist/esm/index.js +29 -7
- package/dist/types/detect.d.ts +7 -3
- package/dist/types/frameworks/fastify.d.ts +16 -0
- package/dist/types/index.d.ts +16 -1
- package/package.json +8 -5
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# apiforge-analyzer
|
|
2
2
|
|
|
3
|
-
CLI and library for auto-generating API documentation from live Express, NestJS, Next.js, Vite, and React Router apps.
|
|
3
|
+
CLI and library for auto-generating API documentation from live Express, Fastify, NestJS, Next.js, Vite, and React Router apps.
|
|
4
4
|
|
|
5
5
|
Routes are extracted at runtime by walking the framework's internal route stack — no static analysis, no decorators, no changes to your production code.
|
|
6
6
|
|
|
@@ -90,6 +90,108 @@ await analyze(app.getHttpServer(), {
|
|
|
90
90
|
await app.listen(3000)
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
+
### Fastify
|
|
94
|
+
|
|
95
|
+
Routes are captured via Fastify's `onRoute` hook, so composed paths (`register(fn, { prefix })`,
|
|
96
|
+
relative registrations, nested scopes) resolve to their real runtime URL rather than the literal
|
|
97
|
+
string passed to `app.get(...)`. Handlers are resolved with a TypeScript checker (not a regex),
|
|
98
|
+
so a handler that lives in another module — including one reached through an aliased import,
|
|
99
|
+
a curried factory, or a thin delegating wrapper — is still followed to its real implementation.
|
|
100
|
+
|
|
101
|
+
```js
|
|
102
|
+
const fastify = require('fastify')
|
|
103
|
+
const { analyze } = require('apiforge-analyzer')
|
|
104
|
+
|
|
105
|
+
const app = fastify()
|
|
106
|
+
|
|
107
|
+
app.register(async (v1) => {
|
|
108
|
+
v1.get('/users', listUsers)
|
|
109
|
+
v1.post('/users', createUser)
|
|
110
|
+
}, { prefix: '/v1' })
|
|
111
|
+
|
|
112
|
+
// Call after all routes are registered, before or after app.listen — analyze()
|
|
113
|
+
// awaits app.ready() itself.
|
|
114
|
+
await analyze(app, {
|
|
115
|
+
projectName: 'My API',
|
|
116
|
+
apiKey: process.env.APIFORGE_API_KEY,
|
|
117
|
+
upload: true,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
await app.listen({ port: 3000 })
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
What's inferred automatically, with no annotation: the composed path, the handler's resolved
|
|
124
|
+
source location and leading JSDoc comment (lifted verbatim into `description`), header reads,
|
|
125
|
+
literal and simple-ternary response statuses, and auth — a route counts as authenticated if a
|
|
126
|
+
route-level `onRequest`/`preHandler` hook looks like an auth check by name or body, or if the
|
|
127
|
+
resolved handler calls a guard function (`requireScope`, `requireOperator`, or a local alias of
|
|
128
|
+
either — `const operator = requireOperator` is followed, not missed).
|
|
129
|
+
|
|
130
|
+
Query and body fields backed by a zod schema — matching both `schema.parse(request.query)` and
|
|
131
|
+
a wrapper call like `parse(schema, request.query)` — come out typed, not as bare field names:
|
|
132
|
+
`z.string().min/max/length()`, `.number()`, `.boolean()`, `.enum([...])` and `.default()` are
|
|
133
|
+
read off the schema and land in the standard `Route.parameters` / `Route.requestBody` fields as
|
|
134
|
+
real JSON Schema (`minLength`/`maxLength`, `enum`, `default`, etc.), the same fields
|
|
135
|
+
`uploadRoutes()` and `exportToJSON()` read — not a side channel that gets dropped on the way out.
|
|
136
|
+
A `.refine()` on the object doesn't drop the fields declared before it.
|
|
137
|
+
|
|
138
|
+
What can't be inferred safely is reported as `"unknown"` rather than guessed — an unresolved
|
|
139
|
+
handler, or a route-level hook whose name doesn't match a known auth pattern, produces
|
|
140
|
+
`authRequired: "unknown"` instead of a confident `false`.
|
|
141
|
+
|
|
142
|
+
#### Supplying what can't be inferred
|
|
143
|
+
|
|
144
|
+
Four extra `AnalyzerOptions` fields cover metadata no static analysis can recover:
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
await analyze(app, {
|
|
148
|
+
projectName: 'My API',
|
|
149
|
+
apiKey: process.env.APIFORGE_API_KEY,
|
|
150
|
+
|
|
151
|
+
// scope name -> extra metadata, joined onto each route's resolved auth scope
|
|
152
|
+
scopeCatalogPath: './scope-catalog.json',
|
|
153
|
+
|
|
154
|
+
// error code -> { status, retry, message, hint? }, joined onto a route by
|
|
155
|
+
// response status, or by the `errors` list in an annotation (see below)
|
|
156
|
+
errorCatalogPath: './error-catalog.json',
|
|
157
|
+
|
|
158
|
+
// per-prefix response envelope shape — required for every versioned prefix
|
|
159
|
+
// your API serves once you use this option at all; an unlisted prefix is
|
|
160
|
+
// reported as a warning rather than silently assumed bare
|
|
161
|
+
envelopes: {
|
|
162
|
+
'/v1': { bare: true },
|
|
163
|
+
'/v2': { wrapper: 'ApiEnvelope', dataField: 'data' },
|
|
164
|
+
},
|
|
165
|
+
// With `envelopes` set, `GET /v1/things` and `GET /v2/things` get *different*
|
|
166
|
+
// `responses['200']` schemas from this one declaration — `/v2`'s nests the same
|
|
167
|
+
// bare shape under `data` instead of replacing it, so it stays a strict superset.
|
|
168
|
+
|
|
169
|
+
// "METHOD /path" -> { summary?, description?, tags?, deprecated?, errors? }
|
|
170
|
+
// overrides for whatever the analyzer got wrong or couldn't see at all
|
|
171
|
+
annotationsPath: './annotations.json',
|
|
172
|
+
})
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The same per-route overrides can also be attached inline at the route, via Fastify's own
|
|
176
|
+
`config` option — no sidecar file needed for a one-off:
|
|
177
|
+
|
|
178
|
+
```js
|
|
179
|
+
app.get('/users', {
|
|
180
|
+
config: {
|
|
181
|
+
apiforge: { summary: 'List users', tags: ['users'] },
|
|
182
|
+
},
|
|
183
|
+
}, listUsers)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
An `annotationsPath` entry that doesn't match any route Fastify actually registered (a stale
|
|
187
|
+
key left behind after a route was renamed or removed) logs a warning rather than failing
|
|
188
|
+
silently.
|
|
189
|
+
|
|
190
|
+
We deliberately do **not** read Fastify's request-side `schema` option for documentation —
|
|
191
|
+
adding a schema there gives the route a second, independent validator with its own error
|
|
192
|
+
contract, and `fast-json-stringify` silently drops response fields a *response* schema doesn't
|
|
193
|
+
declare. Use zod (auto-detected) or the annotation options above instead.
|
|
194
|
+
|
|
93
195
|
### Microservices — per-service
|
|
94
196
|
|
|
95
197
|
When running each service independently, pass `service` to tag routes with the service name:
|
|
@@ -180,9 +282,23 @@ interface AnalyzerOptions {
|
|
|
180
282
|
|
|
181
283
|
skipInProduction?: boolean // skip when NODE_ENV=production (default: true)
|
|
182
284
|
runOnce?: boolean // debounce — skip if analyzed within the last 30 s (default: true)
|
|
285
|
+
|
|
286
|
+
// Fastify only — see "Supplying what can't be inferred" above
|
|
287
|
+
scopeCatalogPath?: string
|
|
288
|
+
errorCatalogPath?: string
|
|
289
|
+
envelopes?: Record<string, { wrapper: string; dataField: string; excluded?: string[] } | { bare: true }>
|
|
290
|
+
annotationsPath?: string
|
|
183
291
|
}
|
|
184
292
|
```
|
|
185
293
|
|
|
294
|
+
`analyze()` throws instead of resolving to `[]` when the detected or forced framework has no
|
|
295
|
+
analyzer (currently: anything other than `express`, `fastify`, `nestjs`, `nextjs-pages`,
|
|
296
|
+
`nextjs-app`, `vite`, `react-router`). Every other early-exit (no API key, debounced, zero
|
|
297
|
+
routes found, `NODE_ENV=production`) still resolves to `[]` — only "this framework cannot be
|
|
298
|
+
analyzed" is a thrown error, so a caller can tell the two apart. `detectFramework()` also
|
|
299
|
+
exposes this directly: `detectFramework(app).supported` is `false` for a framework it can
|
|
300
|
+
name but not analyze (currently `koa`, `hapi`, `unknown`).
|
|
301
|
+
|
|
186
302
|
## Architecture detection
|
|
187
303
|
|
|
188
304
|
The CLI scores structural signals to pick the right architecture automatically:
|
|
@@ -216,11 +332,57 @@ Each collected route:
|
|
|
216
332
|
requestBodyFields: [],
|
|
217
333
|
responseStatuses: [200, 404],
|
|
218
334
|
framework: 'express',
|
|
219
|
-
|
|
220
|
-
|
|
335
|
+
module?: 'users', // modular-monolithic only
|
|
336
|
+
service?: 'auth-service' // microservices only
|
|
221
337
|
}
|
|
222
338
|
```
|
|
223
339
|
|
|
340
|
+
## Escape hatch — building routes yourself
|
|
341
|
+
|
|
342
|
+
If your framework has no `analyze*()` function, call `uploadRoutes()` directly with a
|
|
343
|
+
hand-built `Route[]`. This is the same function every `analyze*()` call ends up using
|
|
344
|
+
internally — it's a supported, typed, top-level export, not a private helper.
|
|
345
|
+
|
|
346
|
+
```ts
|
|
347
|
+
import { uploadRoutes, type Route } from 'apiforge-analyzer'
|
|
348
|
+
|
|
349
|
+
const routes: Route[] = [
|
|
350
|
+
{
|
|
351
|
+
method: 'GET',
|
|
352
|
+
path: '/api/users/:id',
|
|
353
|
+
tag: 'users',
|
|
354
|
+
operationId: 'get_api_users_id',
|
|
355
|
+
summary: 'Get a user by id',
|
|
356
|
+
middleware: [],
|
|
357
|
+
authRequired: true,
|
|
358
|
+
pathParams: ['id'],
|
|
359
|
+
queryParams: [],
|
|
360
|
+
requestBodyFields: [],
|
|
361
|
+
linkedPaths: [],
|
|
362
|
+
hasAuth: true,
|
|
363
|
+
framework: 'my-framework',
|
|
364
|
+
// routeKey?: stable dedup key the dashboard uses across uploads — see below
|
|
365
|
+
},
|
|
366
|
+
]
|
|
367
|
+
|
|
368
|
+
await uploadRoutes(routes, {
|
|
369
|
+
projectName: 'My API',
|
|
370
|
+
apiKey: process.env.APIFORGE_API_KEY!,
|
|
371
|
+
})
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
The fields above (`method`, `path`, `tag`, `operationId`, `summary`, `middleware`,
|
|
375
|
+
`authRequired`, `pathParams`, `queryParams`, `requestBodyFields`, `linkedPaths`, `hasAuth`,
|
|
376
|
+
`framework`) are the only ones `Route` requires; everything else (`description`, `tags`,
|
|
377
|
+
`headerParams`, `responseStatuses`, `parameters`, `requestBody`, `responses`, `module`,
|
|
378
|
+
`service`, ...) is optional and gets a reasonable default from `enrichRoutes()`.
|
|
379
|
+
|
|
380
|
+
**Set `routeKey` yourself.** If you don't, it defaults to `api-001`, `api-002`, ... based on
|
|
381
|
+
array position — which shifts if a route is added, removed, or reordered between uploads, so
|
|
382
|
+
the dashboard can no longer tell "this is the same endpoint as last time" from "this is a new
|
|
383
|
+
one". Use something stable per endpoint, e.g. `` `${method} ${path}` ``, as every built-in
|
|
384
|
+
analyzer does.
|
|
385
|
+
|
|
224
386
|
## ESM and CommonJS
|
|
225
387
|
|
|
226
388
|
```js
|
package/dist/cjs/cli.js
CHANGED
|
@@ -220,15 +220,34 @@ 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
|
|
227
|
-
if (serverResult.message.includes('API key')) {
|
|
228
|
-
prompts_1.log.warn(yellow('Hint:
|
|
229
|
-
prompts_1.log.warn(yellow('
|
|
230
|
-
prompts_1.log.
|
|
231
|
-
prompts_1.log.
|
|
239
|
+
if (serverResult.message.includes('API key') || serverResult.message.includes('Invalid API key')) {
|
|
240
|
+
prompts_1.log.warn(yellow('Hint: Your API key was rejected by the server. Common causes:'));
|
|
241
|
+
prompts_1.log.warn(yellow(''));
|
|
242
|
+
prompts_1.log.warn(yellow(' 1. Key not yet created — go to your dashboard and generate one:'));
|
|
243
|
+
prompts_1.log.info(` ${cyan('https://apiforge.brainfogagency.com/dashboard')}`);
|
|
244
|
+
prompts_1.log.warn(yellow(''));
|
|
245
|
+
prompts_1.log.warn(yellow(' 2. Key belongs to a different account — make sure you\'re logged in'));
|
|
246
|
+
prompts_1.log.warn(yellow(' to the same account that owns this project.'));
|
|
247
|
+
prompts_1.log.warn(yellow(''));
|
|
248
|
+
prompts_1.log.warn(yellow(' 3. Server not loading .env.local — add this at the top of your entry file:'));
|
|
249
|
+
prompts_1.log.info(` ${cyan("require('dotenv').config({ path: '.env.local' })")}`);
|
|
250
|
+
prompts_1.log.warn(yellow(' Or install dotenv: npm install dotenv'));
|
|
232
251
|
}
|
|
233
252
|
else if (serverResult.message.includes('No routes')) {
|
|
234
253
|
prompts_1.log.warn(yellow('Hint: Make sure all routes are registered before analyze() is called.'));
|
|
@@ -238,6 +257,14 @@ async function main() {
|
|
|
238
257
|
prompts_1.log.warn(yellow('Hint: Your server might be taking too long to start, or the analyzer'));
|
|
239
258
|
prompts_1.log.warn(yellow(' is not being called. Check your server logs for APIForge messages.'));
|
|
240
259
|
}
|
|
260
|
+
// Show captured server output to help debug the root cause
|
|
261
|
+
const capturedOutput = serverResult.serverOutput;
|
|
262
|
+
if (capturedOutput) {
|
|
263
|
+
prompts_1.log.info('');
|
|
264
|
+
prompts_1.log.info(dim('─── Server output ───────────────────────────────'));
|
|
265
|
+
capturedOutput.split('\n').slice(0, 25).forEach((line) => prompts_1.log.info(dim(` ${line}`)));
|
|
266
|
+
prompts_1.log.info(dim('─────────────────────────────────────────────────'));
|
|
267
|
+
}
|
|
241
268
|
await offerCleanup(entryFile, injected);
|
|
242
269
|
(0, prompts_1.outro)(red('Analysis failed.'));
|
|
243
270
|
process.exit(1);
|
|
@@ -638,11 +665,9 @@ async function microservicesFlow(config, opts) {
|
|
|
638
665
|
child.stderr?.on('data', capture);
|
|
639
666
|
// Wait up to 30 s for the status file to appear
|
|
640
667
|
const routes = await waitForServiceStatusFile(statusFile, 30000);
|
|
641
|
-
// Kill service process
|
|
642
|
-
|
|
643
|
-
child.
|
|
644
|
-
}
|
|
645
|
-
catch { }
|
|
668
|
+
// Kill service process (and its full descendant tree — see killProcessTree)
|
|
669
|
+
if (child.pid)
|
|
670
|
+
killProcessTree(child.pid);
|
|
646
671
|
tryUnlink((0, path_1.join)(cwd, statusFile));
|
|
647
672
|
if (routes && routes.length > 0) {
|
|
648
673
|
const prefix = gatewayPrefixes[svc.name];
|
|
@@ -744,16 +769,103 @@ async function microservicesFlow(config, opts) {
|
|
|
744
769
|
}[approval.status];
|
|
745
770
|
(0, prompts_1.outro)(outroMsg);
|
|
746
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
|
+
}
|
|
747
850
|
function startAndWaitForAnalysis(command, timeoutMs) {
|
|
748
851
|
return new Promise(resolve => {
|
|
749
852
|
const statusPath = (0, path_1.join)(process.cwd(), STATUS_FILE);
|
|
750
853
|
const child = (0, child_process_1.spawn)(command, {
|
|
751
854
|
cwd: process.cwd(),
|
|
752
|
-
stdio: 'ignore',
|
|
855
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
753
856
|
shell: true,
|
|
754
857
|
env: { ...process.env },
|
|
755
858
|
detached: false,
|
|
756
859
|
});
|
|
860
|
+
// Capture server output (head-capped) so we can show it on failure
|
|
861
|
+
let serverOutput = '';
|
|
862
|
+
const MAX_OUTPUT = 4000;
|
|
863
|
+
const captureOutput = (chunk) => {
|
|
864
|
+
if (serverOutput.length < MAX_OUTPUT)
|
|
865
|
+
serverOutput += chunk.toString();
|
|
866
|
+
};
|
|
867
|
+
child.stdout?.on('data', captureOutput);
|
|
868
|
+
child.stderr?.on('data', captureOutput);
|
|
757
869
|
let resolved = false;
|
|
758
870
|
function finish(result) {
|
|
759
871
|
if (resolved)
|
|
@@ -761,11 +873,9 @@ function startAndWaitForAnalysis(command, timeoutMs) {
|
|
|
761
873
|
resolved = true;
|
|
762
874
|
clearInterval(poller);
|
|
763
875
|
clearTimeout(timeout);
|
|
764
|
-
|
|
765
|
-
child.
|
|
766
|
-
}
|
|
767
|
-
catch { }
|
|
768
|
-
resolve(result);
|
|
876
|
+
if (child.pid)
|
|
877
|
+
killProcessTree(child.pid);
|
|
878
|
+
resolve({ ...result, serverOutput: serverOutput.trim() });
|
|
769
879
|
}
|
|
770
880
|
// Poll for the status file every 500 ms
|
|
771
881
|
const poller = setInterval(() => {
|
|
@@ -875,6 +985,28 @@ function removeInjectedCode(filePath) {
|
|
|
875
985
|
return false;
|
|
876
986
|
}
|
|
877
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
|
+
}
|
|
878
1010
|
/** Look for a server entry inside a single directory (checks src/ then root). */
|
|
879
1011
|
function findEntryInDir(absDir) {
|
|
880
1012
|
const names = ['index', 'server', 'app', 'main'];
|
|
@@ -900,12 +1032,12 @@ function detectMainEntry(cwd) {
|
|
|
900
1032
|
try {
|
|
901
1033
|
const pkg = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(cwd, 'package.json'), 'utf-8'));
|
|
902
1034
|
if (pkg.main && (0, fs_1.existsSync)((0, path_1.join)(cwd, pkg.main)))
|
|
903
|
-
return pkg.main;
|
|
1035
|
+
return preferSourceOverBuildOutput(cwd, pkg.main);
|
|
904
1036
|
for (const s of ['start', 'dev', 'serve']) {
|
|
905
1037
|
const cmd = pkg.scripts?.[s] ?? '';
|
|
906
|
-
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))/);
|
|
907
1039
|
if (m && (0, fs_1.existsSync)((0, path_1.join)(cwd, m[1])))
|
|
908
|
-
return m[1];
|
|
1040
|
+
return preferSourceOverBuildOutput(cwd, m[1]);
|
|
909
1041
|
}
|
|
910
1042
|
}
|
|
911
1043
|
catch { }
|
|
@@ -996,12 +1128,12 @@ function detectEntryFile(archType) {
|
|
|
996
1128
|
try {
|
|
997
1129
|
const pkg = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(cwd, 'package.json'), 'utf-8'));
|
|
998
1130
|
if (pkg.main && (0, fs_1.existsSync)((0, path_1.join)(cwd, pkg.main)))
|
|
999
|
-
return pkg.main;
|
|
1131
|
+
return preferSourceOverBuildOutput(cwd, pkg.main);
|
|
1000
1132
|
for (const s of ['start', 'dev', 'serve']) {
|
|
1001
1133
|
const cmd = pkg.scripts?.[s] ?? '';
|
|
1002
|
-
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))/);
|
|
1003
1135
|
if (m && (0, fs_1.existsSync)((0, path_1.join)(cwd, m[1])))
|
|
1004
|
-
return m[1];
|
|
1136
|
+
return preferSourceOverBuildOutput(cwd, m[1]);
|
|
1005
1137
|
}
|
|
1006
1138
|
}
|
|
1007
1139
|
catch { }
|
|
@@ -1192,24 +1324,37 @@ function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, a
|
|
|
1192
1324
|
].join('\n');
|
|
1193
1325
|
// Collection mode: some services only mount routes inside an async bootstrap
|
|
1194
1326
|
// gated behind a DB/broker connect (which may hang/fail outside Docker).
|
|
1195
|
-
//
|
|
1196
|
-
//
|
|
1197
|
-
//
|
|
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.
|
|
1198
1339
|
const runAnalyze = `APIForge.analyze(${appVar}, {\n${opts}\n })`;
|
|
1199
1340
|
const call = collectionOpts
|
|
1200
1341
|
? [
|
|
1201
|
-
`
|
|
1202
|
-
`
|
|
1203
|
-
`
|
|
1204
|
-
`
|
|
1205
|
-
`
|
|
1206
|
-
`
|
|
1207
|
-
`
|
|
1208
|
-
`
|
|
1209
|
-
`
|
|
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
|
+
` }`,
|
|
1210
1355
|
` }`,
|
|
1356
|
+
` __apiforgeTryCollect()`,
|
|
1211
1357
|
` }`,
|
|
1212
|
-
` __apiforgeTryCollect()`,
|
|
1213
1358
|
].join('\n')
|
|
1214
1359
|
: ` ${runAnalyze}`;
|
|
1215
1360
|
if (isESM) {
|
|
@@ -1244,11 +1389,23 @@ function detectESM(filePath, content) {
|
|
|
1244
1389
|
return /^(?:import|export)\s+/m.test(content);
|
|
1245
1390
|
}
|
|
1246
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.$]+(?:<[^>]*>)?)?`;
|
|
1247
1398
|
const patterns = [
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
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*\\(`),
|
|
1403
|
+
// Inline `require('fastify')(...)` / `require('express')(...)` — the app
|
|
1404
|
+
// factory is never bound to a local name, only its return value is. This
|
|
1405
|
+
// is at least as common as the named-import style above (it's the pattern
|
|
1406
|
+
// in Fastify's own project generator output), and the patterns above never
|
|
1407
|
+
// match it because there's no `fastify`/`express` identifier in the source.
|
|
1408
|
+
new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*require\\s*\\(\\s*['"](?:fastify|express)['"]\\s*\\)\\s*(?:\\.default\\s*)?\\(`),
|
|
1252
1409
|
];
|
|
1253
1410
|
for (const re of patterns) {
|
|
1254
1411
|
const m = content.match(re);
|
package/dist/cjs/detect.js
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SUPPORTED_FRAMEWORKS = void 0;
|
|
3
4
|
exports.detectFramework = detectFramework;
|
|
4
5
|
const fs_1 = require("fs");
|
|
5
6
|
const path_1 = require("path");
|
|
7
|
+
/**
|
|
8
|
+
* Frameworks analyze() can actually dispatch to. Detection can identify more
|
|
9
|
+
* frameworks than this (koa, hapi) — those are named so the detector is honest
|
|
10
|
+
* about what it saw, but `supported: false` tells callers not to expect output.
|
|
11
|
+
*/
|
|
12
|
+
exports.SUPPORTED_FRAMEWORKS = new Set([
|
|
13
|
+
'express',
|
|
14
|
+
'fastify',
|
|
15
|
+
'nestjs',
|
|
16
|
+
'nextjs-pages',
|
|
17
|
+
'nextjs-app',
|
|
18
|
+
'vite',
|
|
19
|
+
'react-router',
|
|
20
|
+
]);
|
|
6
21
|
const cwd = () => process.cwd();
|
|
7
22
|
function fileExists(rel) {
|
|
8
23
|
return (0, fs_1.existsSync)((0, path_1.join)(cwd(), rel));
|
|
@@ -21,16 +36,19 @@ function tryResolve(mod) {
|
|
|
21
36
|
* Detect the web framework from the live app instance + filesystem hints.
|
|
22
37
|
* Confidence is used to pick the best match when multiple signals fire.
|
|
23
38
|
*/
|
|
39
|
+
function mk(name, confidence) {
|
|
40
|
+
return { name, confidence, supported: exports.SUPPORTED_FRAMEWORKS.has(name) };
|
|
41
|
+
}
|
|
24
42
|
function detectFramework(app) {
|
|
25
43
|
// ── Instance-based detection (highest confidence) ─────────────────────────
|
|
26
44
|
if (app && typeof app === 'function') {
|
|
27
45
|
// Express: _router or lazyrouter
|
|
28
46
|
if (app._router || (app.lazyrouter && typeof app.lazyrouter === 'function')) {
|
|
29
|
-
return
|
|
47
|
+
return mk('express', 1.0);
|
|
30
48
|
}
|
|
31
49
|
// Koa: middleware array
|
|
32
50
|
if (Array.isArray(app.middleware) && typeof app.use === 'function' && app.context) {
|
|
33
|
-
return
|
|
51
|
+
return mk('koa', 0.95);
|
|
34
52
|
}
|
|
35
53
|
}
|
|
36
54
|
// Fastify: inject method and route method
|
|
@@ -38,7 +56,7 @@ function detectFramework(app) {
|
|
|
38
56
|
typeof app === 'object' &&
|
|
39
57
|
typeof app.inject === 'function' &&
|
|
40
58
|
typeof app.route === 'function') {
|
|
41
|
-
return
|
|
59
|
+
return mk('fastify', 1.0);
|
|
42
60
|
}
|
|
43
61
|
// Hapi: server with route + start methods
|
|
44
62
|
if (app &&
|
|
@@ -46,43 +64,43 @@ function detectFramework(app) {
|
|
|
46
64
|
typeof app.route === 'function' &&
|
|
47
65
|
typeof app.start === 'function' &&
|
|
48
66
|
typeof app.stop === 'function') {
|
|
49
|
-
return
|
|
67
|
+
return mk('hapi', 0.95);
|
|
50
68
|
}
|
|
51
69
|
// NestJS application
|
|
52
70
|
if (app &&
|
|
53
71
|
typeof app === 'object' &&
|
|
54
72
|
typeof app.getHttpServer === 'function' &&
|
|
55
73
|
typeof app.listen === 'function') {
|
|
56
|
-
return
|
|
74
|
+
return mk('nestjs', 0.95);
|
|
57
75
|
}
|
|
58
76
|
// ── Filesystem detection ──────────────────────────────────────────────────
|
|
59
77
|
// Next.js App Router
|
|
60
78
|
if (fileExists('app') && (fileExists('app/layout.tsx') || fileExists('app/layout.js'))) {
|
|
61
|
-
return
|
|
79
|
+
return mk('nextjs-app', 0.92);
|
|
62
80
|
}
|
|
63
81
|
// Next.js Pages Router
|
|
64
82
|
if (fileExists('pages/api') || fileExists('src/pages/api')) {
|
|
65
|
-
return
|
|
83
|
+
return mk('nextjs-pages', 0.90);
|
|
66
84
|
}
|
|
67
85
|
if (fileExists('next.config.js') ||
|
|
68
86
|
fileExists('next.config.mjs') ||
|
|
69
87
|
fileExists('next.config.ts')) {
|
|
70
|
-
return
|
|
88
|
+
return mk('nextjs-pages', 0.75);
|
|
71
89
|
}
|
|
72
90
|
// ── Dependency-based detection (lowest confidence) ────────────────────────
|
|
73
91
|
if (tryResolve('express'))
|
|
74
|
-
return
|
|
92
|
+
return mk('express', 0.70);
|
|
75
93
|
if (tryResolve('fastify'))
|
|
76
|
-
return
|
|
94
|
+
return mk('fastify', 0.70);
|
|
77
95
|
if (tryResolve('koa'))
|
|
78
|
-
return
|
|
96
|
+
return mk('koa', 0.70);
|
|
79
97
|
if (tryResolve('@hapi/hapi') || tryResolve('hapi'))
|
|
80
|
-
return
|
|
98
|
+
return mk('hapi', 0.70);
|
|
81
99
|
if (tryResolve('@nestjs/core'))
|
|
82
|
-
return
|
|
100
|
+
return mk('nestjs', 0.70);
|
|
83
101
|
if (tryResolve('vite'))
|
|
84
|
-
return
|
|
102
|
+
return mk('vite', 0.60);
|
|
85
103
|
if (tryResolve('react-router-dom'))
|
|
86
|
-
return
|
|
87
|
-
return
|
|
104
|
+
return mk('react-router', 0.55);
|
|
105
|
+
return mk('unknown', 0);
|
|
88
106
|
}
|