botversion-sdk 2.1.1 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/scanner.js CHANGED
@@ -29,10 +29,6 @@ function scanExpressRoutes(app, cwd) {
29
29
  }
30
30
  }
31
31
 
32
- endpoints.forEach((ep) => {
33
- const status = ep.requestBody ? "✅" : "❌ NULL";
34
- });
35
-
36
32
  return endpoints;
37
33
  }
38
34
 
@@ -70,14 +66,27 @@ function scanExpressFileStatically(filePath, seen) {
70
66
  const needsBody = ["POST", "PUT", "PATCH"].includes(effectiveMethod);
71
67
  const bodyFields = needsBody ? extractBodyFieldsFromFile(content) : null;
72
68
 
69
+ // GET inputs come from the query string; read them from this route's own code
70
+ const queryFields =
71
+ effectiveMethod === "GET"
72
+ ? extractQueryFieldsFromFile(
73
+ sliceRouteSegment(content, match.index, routePattern.source),
74
+ )
75
+ : null;
76
+
73
77
  const routeParamMap = buildRouteParamMap(routePath, []);
74
78
 
75
79
  endpoints.push({
76
80
  method: effectiveMethod,
77
81
  path: routePath,
78
82
  description: "",
79
- requestBody: bodyFields,
83
+ requestBody: bodyFields || queryFields,
80
84
  routeParamMap: routeParamMap,
85
+ responseBody: extractResponseFieldsForRoute(
86
+ content,
87
+ match.index,
88
+ routePattern.source,
89
+ ),
81
90
  detectedBy: "static-scan-file",
82
91
  });
83
92
  }
@@ -136,6 +145,11 @@ function extractRoutes(stack, prefix, endpoints, seen, bodyMap) {
136
145
  }
137
146
  }
138
147
 
148
+ // GET inputs come from the query string, so read them from the handler code
149
+ if (method === "GET") {
150
+ requestBody = extractQueryFieldsFromHandlers(layer.route.stack);
151
+ }
152
+
139
153
  const routeParamMap = buildRouteParamMap(routePath, []);
140
154
 
141
155
  endpoints.push({
@@ -143,6 +157,10 @@ function extractRoutes(stack, prefix, endpoints, seen, bodyMap) {
143
157
  path: routePath,
144
158
  description: "",
145
159
  requestBody: requestBody,
160
+ responseBody: extractResponseFieldsFromHandlers(
161
+ layer.route.stack,
162
+ method,
163
+ ),
146
164
  routeParamMap: routeParamMap,
147
165
  detectedBy: "static-scan",
148
166
  });
@@ -191,24 +209,52 @@ function scanNextJsRoutes(pagesDir) {
191
209
 
192
210
  if (!/\.(js|ts)$/.test(file)) return;
193
211
  if (file.startsWith("_")) return;
212
+ // test files and type-declaration files are never endpoints
213
+ if (/\.(test|spec|d)\.(js|ts)$/.test(file)) return;
194
214
 
195
215
  const routeName = file.replace(/\.(js|ts)$/, "");
196
216
  const routePath =
197
217
  routeName === "index" ? prefix : prefix + "/" + routeName;
198
218
  const normalizedPath = routePath.replace(/\[([^\]]+)\]/g, ":$1");
199
219
 
200
- const methods = detectMethodsFromFile(fullPath);
201
220
  const fileContent = fs.readFileSync(fullPath, "utf8");
221
+ // adapter files (tRPC): the real endpoints are the procedures behind them, learned from live traffic
222
+ if (/\bcreateNextApiHandler\b|@trpc\/server\/adapters/.test(fileContent))
223
+ return;
224
+ const methods = detectMethodsFromFile(fullPath);
202
225
 
203
226
  methods.forEach(function (method) {
227
+ // In files that handle several methods, read only this method's own code
228
+ // (the whole file is used when the methods cannot be told apart)
229
+ const methodCode =
230
+ getMethodSegment(fileContent, method, methods, "pages") ||
231
+ fileContent;
232
+ // Body methods fall back to the whole file if their own code has no fields
204
233
  const bodyFields =
205
- method !== "GET" ? extractBodyFieldsFromFile(fileContent) : null;
206
- const queryFields = extractQueryFieldsFromFile(fileContent);
234
+ method === "GET"
235
+ ? null
236
+ : extractBodyFieldsFromFile(methodCode) ||
237
+ (method !== "DELETE"
238
+ ? extractBodyFieldsFromFile(fileContent)
239
+ : null);
240
+ const queryFields = extractQueryFieldsFromFile(methodCode);
241
+
242
+ // GET inputs come from the query string; only this method's own code is read
243
+ const getQueryFields =
244
+ method === "GET"
245
+ ? removePathParamFields(
246
+ extractQueryFieldsFromFile(
247
+ getMethodSegment(fileContent, "GET", methods, "pages") || "",
248
+ ),
249
+ normalizedPath,
250
+ )
251
+ : null;
207
252
 
208
253
  // For DELETE with no body fields, query params are the input
209
254
  const effectiveRequestBody =
210
255
  bodyFields ||
211
- (method === "DELETE" && queryFields ? queryFields : null);
256
+ (method === "DELETE" && queryFields ? queryFields : null) ||
257
+ getQueryFields;
212
258
 
213
259
  const routeParamMap = buildRouteParamMap(normalizedPath, []);
214
260
 
@@ -217,6 +263,12 @@ function scanNextJsRoutes(pagesDir) {
217
263
  path: normalizedPath,
218
264
  description: "",
219
265
  requestBody: effectiveRequestBody,
266
+ responseBody: extractResponseFieldsForMethod(
267
+ fileContent,
268
+ method,
269
+ methods,
270
+ "pages",
271
+ ),
220
272
  routeParamMap: routeParamMap,
221
273
  detectedBy: "static-scan",
222
274
  });
@@ -231,49 +283,60 @@ function scanNextJsRoutes(pagesDir) {
231
283
  /**
232
284
  * Reads a file and detects which HTTP methods it handles
233
285
  */
286
+ // Matches any variable that holds the request method (req.method, method, httpMethod, ...)
287
+ const METHOD_REF =
288
+ "[\\w$.?]*[mM]ethod[\\w$]*(?:\\s*\\.\\s*toUpperCase\\s*\\(\\s*\\))?";
289
+ const HTTP_METHOD_NAMES = ["GET", "POST", "PUT", "DELETE", "PATCH"];
290
+
291
+ // Reads which HTTP methods a pages-style route file handles, whatever the variable is called
234
292
  function detectMethodsFromFile(filePath) {
235
293
  try {
236
294
  const fs = require("fs");
237
295
  const content = fs.readFileSync(filePath, "utf8");
238
- const methods = [];
239
-
240
- const methodPatterns = [
241
- { pattern: /req\.method\s*!==?\s*['"]GET['"]/i, method: "GET" },
242
- { pattern: /req\.method\s*!==?\s*['"]POST['"]/i, method: "POST" },
243
- { pattern: /req\.method\s*!==?\s*['"]PUT['"]/i, method: "PUT" },
244
- { pattern: /req\.method\s*!==?\s*['"]DELETE['"]/i, method: "DELETE" },
245
- { pattern: /req\.method\s*!==?\s*['"]PATCH['"]/i, method: "PATCH" },
246
- { pattern: /req\.method\s*===?\s*['"]GET['"]/i, method: "GET" },
247
- { pattern: /req\.method\s*===?\s*['"]POST['"]/i, method: "POST" },
248
- { pattern: /req\.method\s*===?\s*['"]PUT['"]/i, method: "PUT" },
249
- { pattern: /req\.method\s*===?\s*['"]DELETE['"]/i, method: "DELETE" },
250
- { pattern: /req\.method\s*===?\s*['"]PATCH['"]/i, method: "PATCH" },
251
- { pattern: /case\s*['"]GET['"]/i, method: "GET" },
252
- { pattern: /case\s*['"]POST['"]/i, method: "POST" },
253
- { pattern: /case\s*['"]PUT['"]/i, method: "PUT" },
254
- { pattern: /case\s*['"]DELETE['"]/i, method: "DELETE" },
255
- { pattern: /case\s*['"]PATCH['"]/i, method: "PATCH" },
256
- ];
257
-
258
- const detectedMethods = new Set();
259
-
260
- methodPatterns.forEach(function (mp) {
261
- if (mp.pattern.test(content)) {
262
- detectedMethods.add(mp.method);
296
+ const detected = new Set();
297
+
298
+ HTTP_METHOD_NAMES.forEach(function (m) {
299
+ const patterns = [
300
+ // method === "X" / method !== "X"
301
+ new RegExp(
302
+ METHOD_REF + "\\s*(?:===?|!==?)\\s*['\"`]" + m + "['\"`]",
303
+ "i",
304
+ ),
305
+ // "X" === method
306
+ new RegExp(
307
+ "['\"`]" + m + "['\"`]\\s*(?:===?|!==?)\\s*" + METHOD_REF,
308
+ "i",
309
+ ),
310
+ // switch (...) { case "X": }
311
+ new RegExp("case\\s*['\"`]" + m + "['\"`]", "i"),
312
+ ];
313
+ if (
314
+ patterns.some(function (p) {
315
+ return p.test(content);
316
+ })
317
+ ) {
318
+ detected.add(m);
263
319
  }
264
320
  });
265
321
 
266
- // For "!== POST" pattern, the file ONLY handles POST — not all methods
267
- // So if we detected via !== check, use just that method
268
- if (detectedMethods.size > 0) {
269
- detectedMethods.forEach(function (m) {
270
- methods.push(m);
322
+ // ["GET", "POST"].includes(method)
323
+ const includesRe = new RegExp(
324
+ "\\[([^\\]]*)\\]\\s*\\.includes\\s*\\(\\s*" + METHOD_REF,
325
+ "gi",
326
+ );
327
+ for (const found of content.matchAll(includesRe)) {
328
+ HTTP_METHOD_NAMES.forEach(function (m) {
329
+ if (new RegExp("['\"`]" + m + "['\"`]", "i").test(found[1])) {
330
+ detected.add(m);
331
+ }
271
332
  });
272
- } else {
273
- methods.push("GET");
274
333
  }
275
334
 
276
- return methods;
335
+ // no recognizable method check means the file is treated as GET-only
336
+ if (detected.size === 0) {
337
+ return ["GET"];
338
+ }
339
+ return Array.from(detected);
277
340
  } catch (e) {
278
341
  return ["GET", "POST"];
279
342
  }
@@ -431,9 +494,19 @@ function scanNextJsAppRoutes(appDir) {
431
494
  const methods = detectAppRouterMethods(content);
432
495
 
433
496
  methods.forEach(function (method) {
497
+ // In files that handle several methods, read only this method's own code
498
+ // (the whole file is used when the methods cannot be told apart)
499
+ const methodCode =
500
+ getMethodSegment(content, method, methods, "app") || content;
501
+ // Body methods fall back to the whole file if their own code has no fields
434
502
  const bodyFields =
435
- method !== "GET" ? extractAppRouterBodyFields(content) : null;
436
- const queryFields = extractQueryFieldsFromFile(content);
503
+ method === "GET"
504
+ ? null
505
+ : extractAppRouterBodyFields(methodCode) ||
506
+ (method !== "DELETE"
507
+ ? extractAppRouterBodyFields(content)
508
+ : null);
509
+ const queryFields = extractQueryFieldsFromFile(methodCode);
437
510
 
438
511
  const routeParamMap = buildRouteParamMap(routePath, []);
439
512
 
@@ -443,7 +516,21 @@ function scanNextJsAppRoutes(appDir) {
443
516
  description: "",
444
517
  requestBody:
445
518
  bodyFields ||
446
- (method === "DELETE" && queryFields ? queryFields : null),
519
+ (method === "DELETE" && queryFields ? queryFields : null) ||
520
+ (method === "GET"
521
+ ? removePathParamFields(
522
+ extractQueryFieldsFromFile(
523
+ getMethodSegment(content, "GET", methods, "app") || "",
524
+ ),
525
+ routePath,
526
+ )
527
+ : null),
528
+ responseBody: extractResponseFieldsForMethod(
529
+ content,
530
+ method,
531
+ methods,
532
+ "app",
533
+ ),
447
534
  routeParamMap: routeParamMap,
448
535
  detectedBy: "static-scan",
449
536
  });
@@ -455,26 +542,21 @@ function scanNextJsAppRoutes(appDir) {
455
542
  return endpoints;
456
543
  }
457
544
 
545
+ // Reads every common way a route file exports its HTTP handlers (function, const, re-export, destructured)
458
546
  function detectAppRouterMethods(content) {
459
- const methods = [];
460
- const patterns = [
461
- { pattern: /export\s+async\s+function\s+GET\b/, method: "GET" },
462
- { pattern: /export\s+async\s+function\s+POST\b/, method: "POST" },
463
- { pattern: /export\s+async\s+function\s+PUT\b/, method: "PUT" },
464
- { pattern: /export\s+async\s+function\s+DELETE\b/, method: "DELETE" },
465
- { pattern: /export\s+async\s+function\s+PATCH\b/, method: "PATCH" },
466
- // named exports too: export { POST }
467
- { pattern: /export\s+function\s+GET\b/, method: "GET" },
468
- { pattern: /export\s+function\s+POST\b/, method: "POST" },
469
- { pattern: /export\s+function\s+PUT\b/, method: "PUT" },
470
- { pattern: /export\s+function\s+DELETE\b/, method: "DELETE" },
471
- { pattern: /export\s+function\s+PATCH\b/, method: "PATCH" },
472
- ];
473
-
474
- patterns.forEach(function (p) {
475
- if (p.pattern.test(content)) methods.push(p.method);
547
+ const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"];
548
+ const methods = HTTP_METHODS.filter(function (m) {
549
+ return [
550
+ new RegExp("export\\s+(?:async\\s+)?function\\s+" + m + "\\b"),
551
+ new RegExp("export\\s+(?:const|let|var)\\s+" + m + "\\b"),
552
+ new RegExp("export\\s*\\{[^}]*\\b" + m + "\\b[^}]*\\}"),
553
+ new RegExp(
554
+ "export\\s+(?:const|let|var)\\s*\\{[^}]*\\b" + m + "\\b[^}]*\\}",
555
+ ),
556
+ ].some(function (re) {
557
+ return re.test(content);
558
+ });
476
559
  });
477
-
478
560
  return methods.length > 0 ? methods : ["GET"];
479
561
  }
480
562
 
@@ -573,23 +655,33 @@ function buildRouteParamMap(routePath, segments) {
573
655
 
574
656
  function extractQueryFieldsFromFile(content) {
575
657
  const fields = new Set();
576
-
577
- // Pattern 1: const { id } = req.query
578
- const destructureMatches = content.matchAll(
579
- /const\s*\{([^}]+)\}\s*=\s*req\.query/g,
580
- );
581
- for (const destructureMatch of destructureMatches) {
582
- destructureMatch[1].split(",").forEach(function (f) {
583
- const clean = f.trim().split(":")[0].trim();
584
- if (clean) fields.add(clean);
585
- });
658
+ const NAME = /^[A-Za-z_$][\w$.-]*$/;
659
+ const addField = function (raw) {
660
+ const name = String(raw).trim().split(":")[0].split("=")[0].trim();
661
+ if (name && NAME.test(name)) fields.add(name);
662
+ };
663
+
664
+ // const { id, page = 1 } = req.query
665
+ const destructureRe =
666
+ /(?:const|let|var)\s*\{([^}]+)\}\s*=\s*(?:await\s+)?(?:req|request|ctx(?:\.request)?)\.query\b/g;
667
+ for (const m of String(content).matchAll(destructureRe)) {
668
+ m[1].split(",").forEach(addField);
586
669
  }
587
670
 
588
- // Pattern 2: req.query.id
589
- const dotMatches = content.matchAll(/req\.query\.([a-zA-Z_][a-zA-Z0-9_]*)/g);
590
- for (const match of dotMatches) {
591
- fields.add(match[1]);
592
- }
671
+ // req.query.id / req.query?.id
672
+ const dotRe =
673
+ /(?:req|request|ctx(?:\.request)?)\.query(?:\?\.|\.)([A-Za-z_$][\w$]*)/g;
674
+ for (const m of String(content).matchAll(dotRe)) addField(m[1]);
675
+
676
+ // req.query["id"]
677
+ const bracketRe =
678
+ /(?:req|request|ctx(?:\.request)?)\.query\s*\??\.?\[\s*['"`]([^'"`]+)['"`]\s*\]/g;
679
+ for (const m of String(content).matchAll(bracketRe)) addField(m[1]);
680
+
681
+ // url.searchParams.get("id") / nextUrl.searchParams.get("id")
682
+ const searchParamsRe =
683
+ /searchParams\s*\??\.\s*(?:get|getAll|has)\s*\(\s*['"`]([^'"`]+)['"`]/g;
684
+ for (const m of String(content).matchAll(searchParamsRe)) addField(m[1]);
593
685
 
594
686
  if (fields.size === 0) return null;
595
687
 
@@ -601,6 +693,83 @@ function extractQueryFieldsFromFile(content) {
601
693
  return { type: "object", properties };
602
694
  }
603
695
 
696
+ // Drops fields that are really path params (Next.js Pages puts [id] into req.query too)
697
+ function removePathParamFields(schema, routePath) {
698
+ if (!schema || !schema.properties) return null;
699
+ const pathParams = new Set(extractPathParams(routePath));
700
+ const properties = {};
701
+ Object.keys(schema.properties).forEach(function (k) {
702
+ if (!pathParams.has(k)) properties[k] = schema.properties[k];
703
+ });
704
+ return Object.keys(properties).length > 0
705
+ ? { type: "object", properties }
706
+ : null;
707
+ }
708
+
709
+ // Returns only the code belonging to one HTTP method in a multi-method file.
710
+ // Returns null when it cannot be told apart, so no guess is made.
711
+ function getMethodSegment(content, method, allMethods, style) {
712
+ if (!allMethods || allMethods.length < 2) return content;
713
+ const markers = [];
714
+ allMethods.forEach(function (m) {
715
+ const index = findStaticMethodMarkerIndex(content, style, m);
716
+ if (index !== -1) markers.push({ method: m, index: index });
717
+ });
718
+ const own = markers.find(function (x) {
719
+ return x.method === method;
720
+ });
721
+ if (!own) return null;
722
+ if (style === "app") {
723
+ const handlerCode = findStaticHandlerCode(content, own.index);
724
+ if (handlerCode) return handlerCode;
725
+ }
726
+ markers.sort(function (a, b) {
727
+ return a.index - b.index;
728
+ });
729
+ const pos = markers.indexOf(own);
730
+ const end =
731
+ pos + 1 < markers.length ? markers[pos + 1].index : content.length;
732
+ return content.slice(own.index, end);
733
+ }
734
+
735
+ // The code from one route definition (app.get(...), router.get(...)) up to the next one
736
+ function sliceRouteSegment(content, startIndex, routePatternSource) {
737
+ try {
738
+ const re = new RegExp(routePatternSource, "gi");
739
+ let end = content.length;
740
+ let hit;
741
+ while ((hit = re.exec(content)) !== null) {
742
+ if (hit.index > startIndex) {
743
+ end = hit.index;
744
+ break;
745
+ }
746
+ }
747
+ return content.slice(startIndex, end);
748
+ } catch (e) {
749
+ return "";
750
+ }
751
+ }
752
+
753
+ // For live Express routes: reads query fields from every handler's source code
754
+ function extractQueryFieldsFromHandlers(stack) {
755
+ try {
756
+ const merged = {};
757
+ for (const layer of stack) {
758
+ const fn = layer.handle || layer;
759
+ if (typeof fn !== "function") continue;
760
+ const fields = extractQueryFieldsFromFile(
761
+ Function.prototype.toString.call(fn),
762
+ );
763
+ if (fields) Object.assign(merged, fields.properties);
764
+ }
765
+ return Object.keys(merged).length > 0
766
+ ? { type: "object", properties: merged }
767
+ : null;
768
+ } catch (e) {
769
+ return null;
770
+ }
771
+ }
772
+
604
773
  /**
605
774
  * Convert Express regexp back to a mount path string
606
775
  * Used for nested routers (app.use('/api', router))
@@ -2192,6 +2361,347 @@ function scanRemixServerRoutes(cwd) {
2192
2361
  return endpoints;
2193
2362
  }
2194
2363
 
2364
+ // ─── Static response-shape guessing ───────────────────────────────────────────
2365
+ // Best-effort: reads field names from reply objects written out directly in the code
2366
+ // (e.g. res.json({ id, name })). Replies built from variables are left empty on purpose;
2367
+ // real runtime replies fill those in later and replace any guess made here.
2368
+ const MAX_STATIC_RESPONSE_FIELDS = 50;
2369
+ const MAX_STATIC_LITERAL_LENGTH = 6000;
2370
+
2371
+ const STATIC_REPLY_PATTERN =
2372
+ /\b(?:res|resp|response|reply|NextResponse|Response)\s*(?:\.\s*(?:status|code)\s*\(\s*(\d{3})\s*\))?\s*\.\s*(?:json|send)\s*\(\s*\{|\bctx\.body\s*=\s*\{|\bh\.response\s*\(\s*\{/g;
2373
+
2374
+ // Returns the index of the closing quote of the string starting at "start" (-1 if not found)
2375
+ function skipStaticString(text, start, limit) {
2376
+ const quote = text[start];
2377
+ for (let i = start + 1; i < limit; i++) {
2378
+ const ch = text[i];
2379
+ if (ch === "\\") {
2380
+ i++;
2381
+ continue;
2382
+ }
2383
+ if (ch === quote) return i;
2384
+ if (quote === "`" && ch === "$" && text[i + 1] === "{") {
2385
+ let depth = 1;
2386
+ i += 2;
2387
+ for (; i < limit && depth > 0; i++) {
2388
+ const c = text[i];
2389
+ if (c === '"' || c === "'" || c === "`") {
2390
+ i = skipStaticString(text, i, limit);
2391
+ if (i === -1) return -1;
2392
+ continue;
2393
+ }
2394
+ if (c === "{") depth++;
2395
+ else if (c === "}") depth--;
2396
+ }
2397
+ i--;
2398
+ continue;
2399
+ }
2400
+ if (quote !== "`" && ch === "\n") return -1;
2401
+ }
2402
+ return -1;
2403
+ }
2404
+
2405
+ // Returns the index of the "}" that closes the "{" at openIndex (null if it cannot be matched safely)
2406
+ function findStaticClosingBrace(text, openIndex) {
2407
+ let depth = 0;
2408
+ const limit = Math.min(text.length, openIndex + MAX_STATIC_LITERAL_LENGTH);
2409
+ for (let i = openIndex; i < limit; i++) {
2410
+ const ch = text[i];
2411
+ const next = text[i + 1];
2412
+ if (ch === "/" && next === "/") {
2413
+ const nl = text.indexOf("\n", i);
2414
+ if (nl === -1) return null;
2415
+ i = nl;
2416
+ continue;
2417
+ }
2418
+ if (ch === "/" && next === "*") {
2419
+ const end = text.indexOf("*/", i + 2);
2420
+ if (end === -1) return null;
2421
+ i = end + 1;
2422
+ continue;
2423
+ }
2424
+ if (ch === '"' || ch === "'" || ch === "`") {
2425
+ i = skipStaticString(text, i, limit);
2426
+ if (i === -1) return null;
2427
+ continue;
2428
+ }
2429
+ if (ch === "{" || ch === "[" || ch === "(") depth++;
2430
+ else if (ch === "}" || ch === "]" || ch === ")") {
2431
+ depth--;
2432
+ if (depth === 0) return ch === "}" ? i : null;
2433
+ if (depth < 0) return null;
2434
+ }
2435
+ }
2436
+ return null;
2437
+ }
2438
+
2439
+ // Splits the inside of an object literal on top-level commas (comments removed)
2440
+ function splitStaticEntries(inner) {
2441
+ const entries = [];
2442
+ let current = "";
2443
+ let depth = 0;
2444
+ for (let i = 0; i < inner.length; i++) {
2445
+ const ch = inner[i];
2446
+ const next = inner[i + 1];
2447
+ if (ch === "/" && next === "/") {
2448
+ const nl = inner.indexOf("\n", i);
2449
+ if (nl === -1) break;
2450
+ i = nl;
2451
+ continue;
2452
+ }
2453
+ if (ch === "/" && next === "*") {
2454
+ const end = inner.indexOf("*/", i + 2);
2455
+ if (end === -1) break;
2456
+ i = end + 1;
2457
+ continue;
2458
+ }
2459
+ if (ch === '"' || ch === "'" || ch === "`") {
2460
+ const end = skipStaticString(inner, i, inner.length);
2461
+ if (end === -1) return null;
2462
+ current += inner.slice(i, end + 1);
2463
+ i = end;
2464
+ continue;
2465
+ }
2466
+ if (ch === "{" || ch === "[" || ch === "(") depth++;
2467
+ else if (ch === "}" || ch === "]" || ch === ")") depth--;
2468
+ if (ch === "," && depth === 0) {
2469
+ entries.push(current.trim());
2470
+ current = "";
2471
+ continue;
2472
+ }
2473
+ current += ch;
2474
+ }
2475
+ if (current.trim()) entries.push(current.trim());
2476
+ return entries;
2477
+ }
2478
+
2479
+ // Reads one "key: value" or shorthand "key" entry; spreads, computed keys and methods are skipped
2480
+ function parseStaticEntry(entry) {
2481
+ if (!entry || entry.startsWith("...") || entry.startsWith("[")) return null;
2482
+ let m = entry.match(
2483
+ /^(?:"([^"]+)"|'([^']+)'|([A-Za-z_$][\w$]*))\s*:\s*([\s\S]+)$/,
2484
+ );
2485
+ if (m) return { key: m[1] || m[2] || m[3], value: m[4] };
2486
+ m = entry.match(/^([A-Za-z_$][\w$]*)$/);
2487
+ if (m) return { key: m[1], value: null };
2488
+ return null;
2489
+ }
2490
+
2491
+ // Only literal values reveal their type; anything computed is marked "unknown" instead of guessed
2492
+ function guessStaticValueType(valueExpr) {
2493
+ const v = String(valueExpr || "").trim();
2494
+ if (/^["'`]/.test(v)) return { type: "string" };
2495
+ if (/^-?\d+(\.\d+)?$/.test(v)) return { type: "number" };
2496
+ if (/^(true|false)$/.test(v)) return { type: "boolean" };
2497
+ if (/^\[/.test(v)) return { type: "array", items: { type: "unknown" } };
2498
+ if (/^\{/.test(v)) return { type: "object" };
2499
+ return { type: "unknown" };
2500
+ }
2501
+
2502
+ function parseStaticObjectFields(inner) {
2503
+ const entries = splitStaticEntries(inner);
2504
+ if (!entries) return null;
2505
+ const properties = {};
2506
+ let count = 0;
2507
+ for (const entry of entries) {
2508
+ if (count >= MAX_STATIC_RESPONSE_FIELDS) break;
2509
+ const parsed = parseStaticEntry(entry);
2510
+ if (!parsed) continue;
2511
+ properties[parsed.key] =
2512
+ parsed.value === null
2513
+ ? { type: "unknown" }
2514
+ : guessStaticValueType(parsed.value);
2515
+ count++;
2516
+ }
2517
+ const keys = Object.keys(properties);
2518
+ if (keys.length === 0) return null;
2519
+ if (keys.length === 1 && keys[0] === "error") return null; // looks like an error reply
2520
+ // "_source" lets the server replace this guess with a real runtime reply later
2521
+ return { type: "object", properties, _source: "static" };
2522
+ }
2523
+
2524
+ function extractResponseFieldsFromSegment(segment) {
2525
+ try {
2526
+ if (!segment || typeof segment !== "string") return null;
2527
+ const pattern = new RegExp(STATIC_REPLY_PATTERN.source, "g");
2528
+ let m;
2529
+ while ((m = pattern.exec(segment)) !== null) {
2530
+ const status = m[1] ? parseInt(m[1], 10) : 200;
2531
+ if (status >= 400) continue;
2532
+ const open = m.index + m[0].length - 1;
2533
+ const close = findStaticClosingBrace(segment, open);
2534
+ if (close === null) continue;
2535
+ const after = segment.slice(close + 1, close + 120);
2536
+ const afterStatus = after.match(/^\s*,\s*\{[^}]*\bstatus\s*:\s*(\d{3})/);
2537
+ if (afterStatus && parseInt(afterStatus[1], 10) >= 400) continue;
2538
+ // a status that is not a plain number (variable/expression) cannot be known to be a success
2539
+ if (!afterStatus && /^\s*,\s*\{[^}]*\bstatus\b/.test(after)) continue;
2540
+ const fields = parseStaticObjectFields(segment.slice(open + 1, close));
2541
+ if (fields) return fields;
2542
+ }
2543
+ } catch (e) {}
2544
+ return null;
2545
+ }
2546
+
2547
+ // Marker that starts one method's code inside a file
2548
+ function staticMethodMarker(style, method) {
2549
+ if (style === "app") {
2550
+ return new RegExp(
2551
+ "export\\s+(?:async\\s+)?function\\s+" +
2552
+ method +
2553
+ "\\b|export\\s+(?:const|let)\\s+" +
2554
+ method +
2555
+ "\\s*=",
2556
+ );
2557
+ }
2558
+ // handles any variable that holds the request method, not just req.method
2559
+ return new RegExp(
2560
+ "(?:" +
2561
+ METHOD_REF +
2562
+ "\\s*===?\\s*['\"]" +
2563
+ method +
2564
+ "['\"]|case\\s*['\"]" +
2565
+ method +
2566
+ "['\"])",
2567
+ "i",
2568
+ );
2569
+ }
2570
+
2571
+ // Finds where one HTTP method's code starts in a file (-1 if it cannot be told apart).
2572
+ // Reads the same method checks that detectMethodsFromFile reads, including the early-exit
2573
+ // guard "if (req.method !== 'X') return ..." (code after that guard belongs to X).
2574
+ function findStaticMethodMarkerIndex(content, style, method) {
2575
+ const direct = staticMethodMarker(style, method).exec(content);
2576
+ if (direct) return direct.index;
2577
+ if (style === "app") return -1;
2578
+
2579
+ // handles any variable that holds the request method, not just req.method
2580
+ const guardRe = new RegExp(
2581
+ METHOD_REF + "\\s*!==?\\s*['\"]" + method + "['\"]",
2582
+ "gi",
2583
+ );
2584
+ let hit;
2585
+ while ((hit = guardRe.exec(content)) !== null) {
2586
+ // A guard listing several allowed methods is not specific to one method, so skip it
2587
+ const before = content.slice(Math.max(0, hit.index - 60), hit.index);
2588
+ const afterStart = hit.index + hit[0].length;
2589
+ const after = content.slice(afterStart, afterStart + 60);
2590
+ const listsOtherMethods =
2591
+ /[\w$.?]*method[\w$]*\s*!==?\s*['"][A-Za-z]+['"]\s*&&\s*$/i.test(
2592
+ before,
2593
+ ) ||
2594
+ /^\s*&&\s*[\w$.?]*method[\w$]*\s*!==?\s*['"][A-Za-z]+['"]/i.test(after);
2595
+ if (!listsOtherMethods) return hit.index;
2596
+ }
2597
+ return -1;
2598
+ }
2599
+
2600
+ // For "export const GET = wrapper(handlerName)", returns the code of handlerName when it is
2601
+ // declared at the top level of the same file (null when it cannot be found, e.g. imported from elsewhere).
2602
+ function findStaticHandlerCode(content, markerIndex) {
2603
+ const ref =
2604
+ /^export\s+(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*(?::[^=]+)?=\s*(?:[A-Za-z_$][\w$.]*\s*\(\s*)*([A-Za-z_$][\w$]*)\s*(?:[,);]|\n|$)/.exec(
2605
+ content.slice(markerIndex, markerIndex + 300),
2606
+ );
2607
+ if (!ref) return null;
2608
+ const name = ref[1];
2609
+ if (name === "async" || name === "function" || name === "await") return null;
2610
+ const safe = name.replace(/[$]/g, "\\$&");
2611
+ const declRe = new RegExp(
2612
+ "(?:^|\\n)(?:export\\s+)?(?:async\\s+)?function\\s+" +
2613
+ safe +
2614
+ "\\s*[(<]|(?:^|\\n)(?:export\\s+)?(?:const|let|var)\\s+" +
2615
+ safe +
2616
+ "\\b[^=\\n]*=",
2617
+ );
2618
+ const decl = declRe.exec(content);
2619
+ if (!decl) return null;
2620
+ const bodyStart = decl.index + decl[0].length;
2621
+ // the handler's code runs until the next top-level statement
2622
+ const next =
2623
+ /\n(?:export\s|async\s+function\s|function\s|const\s|let\s|var\s|class\s|interface\s|type\s)/.exec(
2624
+ content.slice(bodyStart),
2625
+ );
2626
+ const end = next ? bodyStart + next.index : content.length;
2627
+ return content.slice(decl.index, end);
2628
+ }
2629
+
2630
+ // For files that handle several HTTP methods: returns only the code that belongs to one method,
2631
+ // or null when it cannot be told apart safely (so no guess is made)
2632
+ function extractResponseFieldsForMethod(content, method, allMethods, style) {
2633
+ try {
2634
+ let segment = content;
2635
+ if (allMethods && allMethods.length > 1) {
2636
+ const markers = [];
2637
+ allMethods.forEach(function (m) {
2638
+ // Uses the same method checks as method detection, so the file is split consistently
2639
+ const index = findStaticMethodMarkerIndex(content, style, m);
2640
+ if (index !== -1) markers.push({ method: m, index: index });
2641
+ });
2642
+ const own = markers.find(function (x) {
2643
+ return x.method === method;
2644
+ });
2645
+ if (!own) return null;
2646
+ // handler declared elsewhere in the same file and exported through a wrapper
2647
+ if (style === "app") {
2648
+ const handlerCode = findStaticHandlerCode(content, own.index);
2649
+ if (handlerCode) return extractResponseFieldsFromSegment(handlerCode);
2650
+ }
2651
+ markers.sort(function (a, b) {
2652
+ return a.index - b.index;
2653
+ });
2654
+ const pos = markers.indexOf(own);
2655
+ const end =
2656
+ pos + 1 < markers.length ? markers[pos + 1].index : content.length;
2657
+ segment = content.slice(own.index, end);
2658
+ }
2659
+ return extractResponseFieldsFromSegment(segment);
2660
+ } catch (e) {
2661
+ return null;
2662
+ }
2663
+ }
2664
+
2665
+ // For route definitions in one file (app.get(...), router.post(...)): the code from this route
2666
+ // definition up to the next one
2667
+ function extractResponseFieldsForRoute(
2668
+ content,
2669
+ startIndex,
2670
+ routePatternSource,
2671
+ ) {
2672
+ try {
2673
+ const re = new RegExp(routePatternSource, "gi");
2674
+ let end = content.length;
2675
+ let hit;
2676
+ while ((hit = re.exec(content)) !== null) {
2677
+ if (hit.index > startIndex) {
2678
+ end = hit.index;
2679
+ break;
2680
+ }
2681
+ }
2682
+ return extractResponseFieldsFromSegment(content.slice(startIndex, end));
2683
+ } catch (e) {
2684
+ return null;
2685
+ }
2686
+ }
2687
+
2688
+ // For live Express route handlers: reads the handler source, last handler first
2689
+ function extractResponseFieldsFromHandlers(stack, method) {
2690
+ try {
2691
+ for (let i = stack.length - 1; i >= 0; i--) {
2692
+ const layer = stack[i];
2693
+ if (layer.method && String(layer.method).toUpperCase() !== method)
2694
+ continue;
2695
+ const fn = layer.handle || layer;
2696
+ if (typeof fn !== "function") continue;
2697
+ const src = Function.prototype.toString.call(fn);
2698
+ const fields = extractResponseFieldsFromSegment(src);
2699
+ if (fields) return fields;
2700
+ }
2701
+ } catch (e) {}
2702
+ return null;
2703
+ }
2704
+
2195
2705
  module.exports = {
2196
2706
  scanExpressRoutes,
2197
2707
  scanNextJsRoutes,