botversion-sdk 1.0.6 → 1.1.7

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/interceptor.js CHANGED
@@ -3,6 +3,29 @@
3
3
 
4
4
  const reportedEndpoints = new Set();
5
5
 
6
+ function structureToJsonSchema(bodyStructure) {
7
+ if (!bodyStructure) return null;
8
+ return {
9
+ type: "object",
10
+ properties: Object.fromEntries(
11
+ Object.entries(bodyStructure).map(function ([key, typeOrObj]) {
12
+ if (typeOrObj && typeof typeOrObj === "object" && typeOrObj.type) {
13
+ return [key, typeOrObj];
14
+ }
15
+ return [
16
+ key,
17
+ {
18
+ type:
19
+ typeOrObj === "null" || typeOrObj === "[redacted]"
20
+ ? "string"
21
+ : typeOrObj,
22
+ },
23
+ ];
24
+ }),
25
+ ),
26
+ };
27
+ }
28
+
6
29
  /**
7
30
  * Attaches a middleware to the Express app that
8
31
  * silently intercepts every request and reports
@@ -18,6 +41,7 @@ function attachInterceptor(app, client, options) {
18
41
  "/_next",
19
42
  "/static",
20
43
  "/public",
44
+ "/admin",
21
45
  ].concat(options.exclude || []);
22
46
 
23
47
  app.use(function botVersionInterceptor(req, res, next) {
@@ -49,24 +73,7 @@ function attachInterceptor(app, client, options) {
49
73
  if (!reportedEndpoints.has(bodyKey)) {
50
74
  reportedEndpoints.add(bodyKey);
51
75
 
52
- const jsonSchema = bodyStructure
53
- ? {
54
- type: "object",
55
- properties: Object.fromEntries(
56
- Object.entries(bodyStructure).map(function ([key, type]) {
57
- return [
58
- key,
59
- {
60
- type:
61
- type === "null" || type === "[redacted]"
62
- ? "string"
63
- : type,
64
- },
65
- ];
66
- }),
67
- ),
68
- }
69
- : null;
76
+ const jsonSchema = structureToJsonSchema(bodyStructure);
70
77
 
71
78
  // Report async — never block the request
72
79
  setImmediate(function () {
@@ -77,9 +84,7 @@ function attachInterceptor(app, client, options) {
77
84
  requestBody: jsonSchema,
78
85
  detectedBy: "runtime",
79
86
  })
80
- .catch(function (err) {
81
- console.error("[botversion] update_endpoint failed:", err.message);
82
- });
87
+ .catch(function (err) {});
83
88
  });
84
89
  }
85
90
 
@@ -127,6 +132,25 @@ function normalizePath(path) {
127
132
  .join("/");
128
133
  }
129
134
 
135
+ /**
136
+ * Unwraps a single (non-batched) tRPC/superjson envelope.
137
+ * tRPC sends single calls as { json: {...realFields...} }
138
+ * or with superjson as { json: {...realFields...}, meta: {...} }
139
+ */
140
+ function unwrapTrpcJsonEnvelope(obj) {
141
+ if (
142
+ obj &&
143
+ typeof obj === "object" &&
144
+ !Array.isArray(obj) &&
145
+ obj.json &&
146
+ typeof obj.json === "object" &&
147
+ !Array.isArray(obj.json)
148
+ ) {
149
+ return obj.json;
150
+ }
151
+ return obj;
152
+ }
153
+
130
154
  /**
131
155
  * Extract just the structure of a request body
132
156
  * (keys and value types — never actual values for security)
@@ -134,6 +158,37 @@ function normalizePath(path) {
134
158
  function buildBodyStructure(body) {
135
159
  if (!body || typeof body !== "object") return null;
136
160
 
161
+ // Unwrap tRPC envelope
162
+ // Case A: single tRPC call — { json: { ...realFields... } } (optionally with a "meta" sibling)
163
+ // Case B: batched tRPC call — { "0": { json: {...} }, "1": { json: {...} } } or { "0": {...realFields...} }
164
+ // We detect whichever shape is present and flatten it to the real fields before processing
165
+ let keys = Object.keys(body);
166
+
167
+ const isTrpcEnvelope =
168
+ keys.length > 0 &&
169
+ keys.every(function (k) {
170
+ return (
171
+ /^\d+$/.test(k) &&
172
+ body[k] !== null &&
173
+ typeof body[k] === "object" &&
174
+ !Array.isArray(body[k])
175
+ );
176
+ });
177
+
178
+ if (isTrpcEnvelope) {
179
+ const unwrapped = {};
180
+ keys.forEach(function (k) {
181
+ // Each batched entry may itself be a { json: {...} } envelope
182
+ Object.assign(unwrapped, unwrapTrpcJsonEnvelope(body[k]));
183
+ });
184
+ body = unwrapped;
185
+ } else {
186
+ // Not a numeric batch — check for the single { json: {...} } shape
187
+ body = unwrapTrpcJsonEnvelope(body);
188
+ }
189
+
190
+ keys = Object.keys(body);
191
+
137
192
  const structure = {};
138
193
 
139
194
  Object.keys(body).forEach(function (key) {
@@ -164,6 +219,17 @@ function buildBodyStructure(body) {
164
219
  structure[key] = "array";
165
220
  } else if (val === null) {
166
221
  structure[key] = "null";
222
+ } else if (typeof val === "object") {
223
+ const nestedProps = {};
224
+ Object.keys(val).forEach(function (nk) {
225
+ nestedProps[nk] = {
226
+ type: val[nk] === null ? "string" : typeof val[nk],
227
+ };
228
+ });
229
+ structure[key] =
230
+ Object.keys(nestedProps).length > 0
231
+ ? { type: "object", properties: nestedProps }
232
+ : "object";
167
233
  } else {
168
234
  structure[key] = typeof val;
169
235
  }
@@ -183,7 +249,14 @@ function attachNextJsInterceptor(client, options) {
183
249
  const method = req.method ? req.method.toUpperCase() : "";
184
250
 
185
251
  const shouldIgnore = (options.exclude || [])
186
- .concat(["/health", "/favicon.ico", "/_next", "/static", "/public"])
252
+ .concat([
253
+ "/health",
254
+ "/favicon.ico",
255
+ "/_next",
256
+ "/static",
257
+ "/public",
258
+ "/admin",
259
+ ])
187
260
  .some(function (p) {
188
261
  return path.startsWith(p);
189
262
  });
@@ -197,34 +270,445 @@ function attachNextJsInterceptor(client, options) {
197
270
  if (!reportedEndpoints.has(endpointKey)) {
198
271
  reportedEndpoints.add(endpointKey);
199
272
 
200
- setImmediate(function () {
273
+ // Skip file uploads — too large and not JSON
274
+ const contentType = req.headers["content-type"] || "";
275
+ if (contentType.includes("multipart/form-data")) {
201
276
  client
202
277
  .updateEndpoint({
203
278
  method: method,
204
279
  path: normalizedPath,
280
+ requestBody: null,
205
281
  detectedBy: "runtime",
206
282
  })
207
- .catch(function (err) {
208
- console.error("[botversion] update failed:", err.message);
209
- });
210
- });
283
+ .catch(function () {});
284
+ return originalEmit.apply(this, arguments);
285
+ }
286
+
287
+ // Helper to send body structure to platform
288
+ function reportBody(bodyObj) {
289
+ try {
290
+ // ── tRPC GET input extraction ──────────────────────────────────────
291
+ // tRPC GET requests send their input as a URL-encoded JSON query
292
+ // parameter called "input". The body is always empty for GET requests.
293
+ // We only attempt this extraction when:
294
+ // 1. The body is empty (GET requests have no body)
295
+ // 2. The URL contains an "input" query parameter
296
+ // 3. The path looks like a tRPC endpoint (/trpc/)
297
+ // This prevents accidentally treating regular REST query params
298
+ // (e.g. ?filter=active&limit=10) as a body schema.
299
+ const isTrpcPath = path.includes("/trpc/");
300
+ const hasInputParam = req.url && req.url.includes("input=");
301
+
302
+ if (
303
+ isTrpcPath &&
304
+ hasInputParam &&
305
+ (!bodyObj || Object.keys(bodyObj).length === 0)
306
+ ) {
307
+ try {
308
+ // Use URL constructor to safely parse query params
309
+ const urlObj = new URL(req.url, "http://localhost");
310
+ const inputParam = urlObj.searchParams.get("input");
311
+
312
+ if (inputParam) {
313
+ let decoded;
314
+ try {
315
+ decoded = JSON.parse(decodeURIComponent(inputParam));
316
+ } catch (e) {
317
+ // input param exists but is not valid JSON — skip
318
+ decoded = null;
319
+ }
320
+
321
+ if (
322
+ decoded &&
323
+ typeof decoded === "object" &&
324
+ !Array.isArray(decoded)
325
+ ) {
326
+ const keys = Object.keys(decoded);
327
+
328
+ // Format 1 — batched: { "0": { json: {...} }, "1": { json: {...} } }
329
+ // All keys are numeric strings
330
+ const isBatch =
331
+ keys.length > 0 && keys.every((k) => /^\d+$/.test(k));
332
+
333
+ if (isBatch) {
334
+ // Merge all batch entries into one flat object
335
+ // so we capture the full schema across all procedures
336
+ const merged = {};
337
+ keys.forEach((k) => {
338
+ const entry = decoded[k];
339
+ if (entry && typeof entry === "object") {
340
+ // Unwrap { json: {...} } envelope if present
341
+ const unwrapped =
342
+ entry.json && typeof entry.json === "object"
343
+ ? entry.json
344
+ : entry;
345
+ Object.assign(merged, unwrapped);
346
+ }
347
+ });
348
+ if (Object.keys(merged).length > 0) {
349
+ bodyObj = merged;
350
+ }
351
+ }
352
+ // Format 2 — single with superjson: { json: {...}, meta: {...} }
353
+ else if (
354
+ decoded.json &&
355
+ typeof decoded.json === "object"
356
+ ) {
357
+ bodyObj = decoded.json;
358
+ }
359
+ // Format 3 — already unwrapped: { group: {...}, limit: 10 }
360
+ // Only use this if it doesn't look like a tRPC envelope
361
+ else if (!decoded.meta && !decoded.json) {
362
+ bodyObj = decoded;
363
+ }
364
+ }
365
+ }
366
+ } catch (e) {
367
+ // URL parsing failed — fall through to normal body handling
368
+ }
369
+ }
370
+
371
+ // ── Normal body processing (unchanged) ────────────────────────────
372
+ const parsedBody = structureToJsonSchema(
373
+ buildBodyStructure(bodyObj),
374
+ );
375
+ client
376
+ .updateEndpoint({
377
+ method: method,
378
+ path: normalizedPath,
379
+ requestBody: parsedBody,
380
+ detectedBy: "runtime",
381
+ })
382
+ .catch(function () {});
383
+ } catch (e) {}
384
+ }
385
+
386
+ // APP ROUTER — req.body is a Web Stream, read it early
387
+ // and put it back as a new stream so App Router can still read it
388
+ if (
389
+ typeof req.body !== "undefined" &&
390
+ req.body &&
391
+ typeof req.body.getReader === "function"
392
+ ) {
393
+ try {
394
+ const reader = req.body.getReader();
395
+ const chunks = [];
396
+ function readChunk() {
397
+ reader
398
+ .read()
399
+ .then(function (result) {
400
+ if (result.done) {
401
+ const fullBuffer = Buffer.concat(
402
+ chunks.map(function (c) {
403
+ return Buffer.from(c);
404
+ }),
405
+ );
406
+ // Put the body back as a new ReadableStream
407
+ const { ReadableStream } = require("stream/web");
408
+ req.body = new ReadableStream({
409
+ start: function (controller) {
410
+ controller.enqueue(fullBuffer);
411
+ controller.close();
412
+ },
413
+ });
414
+ // Parse and report
415
+ try {
416
+ const parsed = JSON.parse(fullBuffer.toString());
417
+ reportBody(parsed);
418
+ } catch (e) {}
419
+ } else {
420
+ chunks.push(result.value);
421
+ readChunk();
422
+ }
423
+ })
424
+ .catch(function () {});
425
+ }
426
+ readChunk();
427
+ } catch (e) {}
428
+ return originalEmit.apply(this, arguments);
429
+ }
430
+
431
+ // PAGES ROUTER — req.body is populated by Next.js after parsing
432
+ // We intercept res.end because by then req.body is fully available
433
+ const originalResEnd = res.end.bind(res);
434
+ res.end = function (chunk, encoding, callback) {
435
+ reportBody(req.body || null);
436
+ return originalResEnd(chunk, encoding, callback);
437
+ };
211
438
  }
212
439
  }
213
440
  }
214
441
 
215
442
  return originalEmit.apply(this, arguments);
216
443
  };
217
- } catch (err) {
218
- console.error(
219
- "[botversion] failed to attach Next.js interceptor:",
220
- err.message,
221
- );
222
- }
444
+ } catch (err) {}
445
+ }
446
+
447
+ /**
448
+ * Attaches a Fastify hook that silently intercepts every request
449
+ * and reports new endpoints to BotVersion platform.
450
+ */
451
+ function attachFastifyInterceptor(fastify, client, options) {
452
+ options = options || {};
453
+
454
+ const ignorePaths = [
455
+ "/health",
456
+ "/favicon.ico",
457
+ "/_next",
458
+ "/static",
459
+ "/public",
460
+ "/admin",
461
+ ].concat(options.exclude || []);
462
+
463
+ fastify.addHook("onRequest", async function (request, reply) {
464
+ const path = request.url ? request.url.split("?")[0] : "";
465
+ const method = request.method ? request.method.toUpperCase() : "";
466
+
467
+ const shouldIgnore = ignorePaths.some(function (p) {
468
+ return path.startsWith(p);
469
+ });
470
+
471
+ if (shouldIgnore) return;
472
+
473
+ if (options.apiPrefix && !path.startsWith(options.apiPrefix)) return;
474
+
475
+ const normalizedPath = normalizePath(path);
476
+ const endpointKey = method + ":" + normalizedPath;
477
+
478
+ if (!reportedEndpoints.has(endpointKey)) {
479
+ reportedEndpoints.add(endpointKey);
480
+
481
+ setImmediate(function () {
482
+ client
483
+ .updateEndpoint({
484
+ method,
485
+ path: normalizedPath,
486
+ requestBody: null,
487
+ detectedBy: "runtime-fastify",
488
+ })
489
+ .catch(function () {});
490
+ });
491
+ }
492
+ });
493
+
494
+ // Use onSend hook to capture body after parsing
495
+ fastify.addHook("preHandler", async function (request, reply) {
496
+ const path = request.url ? request.url.split("?")[0] : "";
497
+ const method = request.method ? request.method.toUpperCase() : "";
498
+ const normalizedPath = normalizePath(path);
499
+ const bodyKey = method + ":" + normalizedPath + ":body";
500
+
501
+ if (reportedEndpoints.has(bodyKey)) return;
502
+ reportedEndpoints.add(bodyKey);
503
+
504
+ const body = request.body;
505
+ if (!body) return;
506
+
507
+ const bodyStructure = buildBodyStructure(body);
508
+ if (!bodyStructure) return;
509
+
510
+ const jsonSchema = structureToJsonSchema(bodyStructure);
511
+
512
+ setImmediate(function () {
513
+ client
514
+ .updateEndpoint({
515
+ method,
516
+ path: normalizedPath,
517
+ requestBody: jsonSchema,
518
+ detectedBy: "runtime-fastify",
519
+ })
520
+ .catch(function () {});
521
+ });
522
+ });
523
+ }
524
+
525
+ /**
526
+ * Attaches a Koa middleware that silently intercepts every request
527
+ * and reports new endpoints to BotVersion platform.
528
+ */
529
+ function attachKoaInterceptor(app, client, options) {
530
+ options = options || {};
531
+
532
+ const ignorePaths = [
533
+ "/health",
534
+ "/favicon.ico",
535
+ "/_next",
536
+ "/static",
537
+ "/public",
538
+ "/admin",
539
+ ].concat(options.exclude || []);
540
+
541
+ // IMPORTANT: This interceptor must be added AFTER koa-bodyparser middleware
542
+ // so that ctx.request.body is already populated when we read it.
543
+ app.use(async function botVersionKoaInterceptor(ctx, next) {
544
+ await next();
545
+
546
+ const path = ctx.path || "";
547
+ const method = ctx.method ? ctx.method.toUpperCase() : "";
548
+
549
+ const shouldIgnore = ignorePaths.some(function (p) {
550
+ return path.startsWith(p);
551
+ });
552
+
553
+ if (shouldIgnore) return;
554
+
555
+ if (options.apiPrefix && !path.startsWith(options.apiPrefix)) return;
556
+
557
+ const normalizedPath = normalizePath(path);
558
+ const endpointKey = method + ":" + normalizedPath;
559
+
560
+ const body = ctx.request.body;
561
+ const bodyStructure = buildBodyStructure(body);
562
+ const bodyKey =
563
+ endpointKey +
564
+ ":" +
565
+ Object.keys(bodyStructure || {})
566
+ .sort()
567
+ .join(",");
568
+
569
+ if (!reportedEndpoints.has(bodyKey)) {
570
+ reportedEndpoints.add(bodyKey);
571
+
572
+ const jsonSchema = structureToJsonSchema(bodyStructure);
573
+
574
+ setImmediate(function () {
575
+ client
576
+ .updateEndpoint({
577
+ method,
578
+ path: normalizedPath,
579
+ requestBody: jsonSchema,
580
+ detectedBy: "runtime-koa",
581
+ })
582
+ .catch(function () {});
583
+ });
584
+ }
585
+ });
586
+ }
587
+
588
+ /**
589
+ * Attaches a Hapi lifecycle extension that silently intercepts every request
590
+ * and reports new endpoints to BotVersion platform.
591
+ */
592
+ function attachHapiInterceptor(server, client, options) {
593
+ options = options || {};
594
+
595
+ const ignorePaths = [
596
+ "/health",
597
+ "/favicon.ico",
598
+ "/_next",
599
+ "/static",
600
+ "/public",
601
+ "/admin",
602
+ ].concat(options.exclude || []);
603
+
604
+ server.ext("onPostAuth", function (request, h) {
605
+ const path = request.path || "";
606
+ const method = request.method ? request.method.toUpperCase() : "";
607
+
608
+ const shouldIgnore = ignorePaths.some(function (p) {
609
+ return path.startsWith(p);
610
+ });
611
+
612
+ if (shouldIgnore) return h.continue;
613
+
614
+ if (options.apiPrefix && !path.startsWith(options.apiPrefix))
615
+ return h.continue;
616
+
617
+ const normalizedPath = normalizePath(path);
618
+ const endpointKey = method + ":" + normalizedPath;
619
+
620
+ const body = request.payload;
621
+ const bodyStructure = buildBodyStructure(body);
622
+ const bodyKey =
623
+ endpointKey +
624
+ ":" +
625
+ Object.keys(bodyStructure || {})
626
+ .sort()
627
+ .join(",");
628
+
629
+ if (!reportedEndpoints.has(bodyKey)) {
630
+ reportedEndpoints.add(bodyKey);
631
+
632
+ const jsonSchema = structureToJsonSchema(bodyStructure);
633
+
634
+ setImmediate(function () {
635
+ client
636
+ .updateEndpoint({
637
+ method,
638
+ path: normalizedPath,
639
+ requestBody: jsonSchema,
640
+ detectedBy: "runtime-hapi",
641
+ })
642
+ .catch(function () {});
643
+ });
644
+ }
645
+
646
+ return h.continue;
647
+ });
648
+ }
649
+
650
+ /**
651
+ * Attaches a NestJS interceptor by patching the underlying http server.
652
+ * NestJS runs on top of Express or Fastify under the hood so we can
653
+ * patch the Node http server the same way we do for Next.js.
654
+ */
655
+ function attachNestJsInterceptor(client, options) {
656
+ // NestJS runs on Node http server under the hood
657
+ // So we reuse the same approach as Next.js
658
+ attachNextJsInterceptor(client, {
659
+ exclude: (options || {}).exclude || [],
660
+ apiPrefix: (options || {}).apiPrefix || "/",
661
+ debug: (options || {}).debug || false,
662
+ });
663
+ }
664
+
665
+ /**
666
+ * SvelteKit, Nuxt and Remix all run on Node http server
667
+ * so we reuse the Next.js interceptor approach for all of them.
668
+ */
669
+ function attachSvelteKitInterceptor(client, options) {
670
+ attachNextJsInterceptor(client, {
671
+ exclude: (options || {}).exclude || [],
672
+ apiPrefix: (options || {}).apiPrefix || "/",
673
+ debug: (options || {}).debug || false,
674
+ });
675
+ }
676
+
677
+ function attachNuxtInterceptor(client, options) {
678
+ attachNextJsInterceptor(client, {
679
+ exclude: (options || {}).exclude || [],
680
+ apiPrefix: (options || {}).apiPrefix || "/api",
681
+ debug: (options || {}).debug || false,
682
+ });
683
+ }
684
+
685
+ function attachRemixInterceptor(client, options) {
686
+ attachNextJsInterceptor(client, {
687
+ exclude: (options || {}).exclude || [],
688
+ apiPrefix: (options || {}).apiPrefix || "/",
689
+ debug: (options || {}).debug || false,
690
+ });
691
+ }
692
+
693
+ function attachAdonisInterceptor(client, options) {
694
+ attachNextJsInterceptor(client, {
695
+ exclude: (options || {}).exclude || [],
696
+ apiPrefix: (options || {}).apiPrefix || "/",
697
+ debug: (options || {}).debug || false,
698
+ });
223
699
  }
224
700
 
225
701
  module.exports = {
226
702
  attachInterceptor,
227
703
  attachNextJsInterceptor,
704
+ attachFastifyInterceptor,
705
+ attachKoaInterceptor,
706
+ attachHapiInterceptor,
707
+ attachNestJsInterceptor,
708
+ attachSvelteKitInterceptor,
709
+ attachNuxtInterceptor,
710
+ attachRemixInterceptor,
711
+ attachAdonisInterceptor,
228
712
  normalizePath,
229
713
  buildBodyStructure,
230
714
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botversion-sdk",
3
- "version": "1.0.6",
3
+ "version": "1.1.7",
4
4
  "description": "BotVersion SDK — auto-detect and register your API endpoints",
5
5
  "main": "index.js",
6
6
  "bin": {