botversion-sdk 2.1.1 → 2.2.2
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/client.js +32 -9
- package/index.js +3 -0
- package/interceptor.js +535 -7
- package/package.json +1 -1
- package/scanner.js +588 -78
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
|
|
206
|
-
|
|
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
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
|
436
|
-
|
|
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
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
//
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
|
|
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,
|