peekinside 0.0.0-stage → 1.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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -2
  3. package/bin/peekinside.js +8 -0
  4. package/index.d.ts +389 -0
  5. package/index.js +3 -0
  6. package/package.json +77 -4
  7. package/src/PeekInside.js +731 -0
  8. package/src/capture/body-capture.js +86 -0
  9. package/src/capture/body-decoder.js +188 -0
  10. package/src/capture/exchange-recorder.js +263 -0
  11. package/src/capture/http-message.js +138 -0
  12. package/src/capture/ignored-paths.js +49 -0
  13. package/src/capture/request-body-tap.js +40 -0
  14. package/src/capture/response-tap.js +83 -0
  15. package/src/cli.js +254 -0
  16. package/src/config.js +183 -0
  17. package/src/crypto.js +189 -0
  18. package/src/deployment.js +183 -0
  19. package/src/exceptions/exception-builder.js +339 -0
  20. package/src/exceptions/exception-capture.js +407 -0
  21. package/src/exceptions/exception-record.js +118 -0
  22. package/src/exceptions/fingerprint.js +60 -0
  23. package/src/exceptions/frame-classifier.js +114 -0
  24. package/src/exceptions/limits.js +95 -0
  25. package/src/exceptions/process-monitor.js +192 -0
  26. package/src/exceptions/rate-limiter.js +50 -0
  27. package/src/exceptions/request-context.js +337 -0
  28. package/src/exceptions/safe-json.js +186 -0
  29. package/src/exceptions/source-context.js +144 -0
  30. package/src/exceptions/source-maps.js +313 -0
  31. package/src/exceptions/stack-parser.js +179 -0
  32. package/src/hub/brand/peekinside-span-core-mark-dark.svg +7 -0
  33. package/src/hub/brand/peekinside-span-core-mark-light.svg +7 -0
  34. package/src/hub/client.js +184 -0
  35. package/src/hub/entry.js +4 -0
  36. package/src/hub/exception-state.js +97 -0
  37. package/src/hub/ingest-payload.js +362 -0
  38. package/src/hub/process.js +175 -0
  39. package/src/hub/project-log.js +559 -0
  40. package/src/hub/segment-log.js +282 -0
  41. package/src/hub/server.js +561 -0
  42. package/src/hub/spool-ingester.js +110 -0
  43. package/src/hub/store.js +404 -0
  44. package/src/instrumentation/console.js +83 -0
  45. package/src/instrumentation/http.js +146 -0
  46. package/src/instrumentation/index.js +234 -0
  47. package/src/instrumentation/jobs.js +186 -0
  48. package/src/instrumentation/loggers.js +135 -0
  49. package/src/instrumentation/mongodb.js +221 -0
  50. package/src/instrumentation/mysql2.js +161 -0
  51. package/src/instrumentation/operation.js +70 -0
  52. package/src/instrumentation/patch.js +72 -0
  53. package/src/instrumentation/pg.js +218 -0
  54. package/src/instrumentation/prisma.js +181 -0
  55. package/src/integrations/express-errors.js +135 -0
  56. package/src/integrations/express.js +57 -0
  57. package/src/integrations/fastify.js +72 -0
  58. package/src/integrations/framework-adapter.js +53 -0
  59. package/src/integrations/http-server.js +32 -0
  60. package/src/integrations/koa.js +72 -0
  61. package/src/integrations/server-binder.js +120 -0
  62. package/src/jobs/job-context.js +40 -0
  63. package/src/jobs/job-run-record.js +104 -0
  64. package/src/jobs/job-runner.js +173 -0
  65. package/src/logs/log-capture.js +143 -0
  66. package/src/logs/log-record.js +74 -0
  67. package/src/project.js +76 -0
  68. package/src/record.js +181 -0
  69. package/src/release.js +157 -0
  70. package/src/runtime/loop-monitor.js +162 -0
  71. package/src/runtime/sampler.js +93 -0
  72. package/src/spool.js +195 -0
  73. package/src/timeline/caller.js +129 -0
  74. package/src/timeline/execution-hash.js +110 -0
  75. package/src/timeline/lru.js +50 -0
  76. package/src/timeline/masking.js +266 -0
  77. package/src/timeline/shape.js +77 -0
  78. package/src/timeline/sql.js +396 -0
  79. package/src/timeline/timeline.js +642 -0
  80. package/src/transport/batch.js +324 -0
  81. package/src/transport/cloud.js +363 -0
  82. package/src/transport/local.js +354 -0
  83. package/src/user.js +83 -0
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Bounded collection of body bytes.
3
+ *
4
+ * Bodies can be arbitrarily large (uploads, downloads, streams), so only the first `limit` bytes
5
+ * are kept, while the full size is still counted: the dashboard marks a body as truncated when
6
+ * the two differ.
7
+ */
8
+
9
+ /**
10
+ * Collects the bytes of one request or response body, up to a limit.
11
+ */
12
+ export class BodyCapture {
13
+ /**
14
+ * @param {number} limit - Most bytes kept (maxBodySize)
15
+ */
16
+ constructor(limit) {
17
+ this.limit = limit;
18
+ this._chunks = [];
19
+ this._captured = 0;
20
+ this._size = 0;
21
+ }
22
+
23
+ /**
24
+ * Adds a chunk as the app read or wrote it. Anything that is not body data, such as a missing
25
+ * chunk or a callback passed in its place, is ignored.
26
+ *
27
+ * @param {Buffer|Uint8Array|string|*} chunk
28
+ * @param {string} [encoding] - Encoding of a string chunk (utf8 when missing or unknown)
29
+ * @returns {void}
30
+ */
31
+ push(chunk, encoding) {
32
+ if (chunk === undefined || chunk === null || typeof chunk === 'function') {
33
+ return;
34
+ }
35
+ let buffer;
36
+ try {
37
+ buffer = toBuffer(chunk, encoding);
38
+ } catch (error) {
39
+ return;
40
+ }
41
+ this._size += buffer.length;
42
+ if (this._captured >= this.limit) {
43
+ return;
44
+ }
45
+ const take = Math.min(buffer.length, this.limit - this._captured);
46
+ // A copy: the app may reuse its buffer once written, and a slice would keep all of it alive.
47
+ this._chunks.push(Buffer.from(buffer.subarray(0, take)));
48
+ this._captured += take;
49
+ }
50
+
51
+ /**
52
+ * Full body size in bytes, including bytes beyond the limit.
53
+ *
54
+ * @type {number}
55
+ */
56
+ get size() {
57
+ return this._size;
58
+ }
59
+
60
+ /**
61
+ * Whether bytes beyond the limit were left out.
62
+ *
63
+ * @type {boolean}
64
+ */
65
+ get truncated() {
66
+ return this._size > this._captured;
67
+ }
68
+
69
+ /**
70
+ * @returns {Buffer} The bytes kept
71
+ */
72
+ buffer() {
73
+ return Buffer.concat(this._chunks, this._captured);
74
+ }
75
+ }
76
+
77
+ function toBuffer(chunk, encoding) {
78
+ if (Buffer.isBuffer(chunk)) {
79
+ return chunk;
80
+ }
81
+ if (chunk instanceof Uint8Array) {
82
+ return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
83
+ }
84
+ const charset = typeof encoding === 'string' && Buffer.isEncoding(encoding) ? encoding : 'utf8';
85
+ return Buffer.from(String(chunk), charset);
86
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Turns captured body bytes into what the dashboard shows: decompressed, and as text when the
3
+ * body is text, or only its size when it is binary.
4
+ *
5
+ * Also turns a body that a framework parsed before PeekInside could see the bytes into text,
6
+ * from the parsed value.
7
+ */
8
+
9
+ import zlib from 'zlib';
10
+ import { BodyCapture } from './body-capture.js';
11
+ import { headerValue } from './http-message.js';
12
+
13
+ const TEXTUAL_TYPE = /^(text\/|application\/(json|xml|javascript|ecmascript|x-www-form-urlencoded|graphql|x-ndjson|csv|yaml|x-yaml|toml)\b)|\+(json|xml)\b|^image\/svg\+xml/i;
14
+ const BINARY_TYPE = /^(image|audio|video|font)\/|^application\/(octet-stream|pdf|zip|gzip|x-gzip|x-tar|wasm|x-protobuf|protobuf|grpc|vnd\.ms-|vnd\.openxmlformats)/i;
15
+
16
+ /**
17
+ * @typedef {Object} BodyContent
18
+ * @property {string} text - The body as text; empty for binary or missing bodies
19
+ * @property {number} size - Full body size in bytes, as sent
20
+ * @property {boolean} truncated - Whether `text` holds only the start of the body
21
+ * @property {'none'|'utf8'|'binary'|'unavailable'} encoding - "unavailable" when the body was
22
+ * never read by the app, so there was nothing to capture
23
+ */
24
+
25
+ /**
26
+ * The content of an absent body.
27
+ *
28
+ * @type {Readonly<BodyContent>}
29
+ */
30
+ export const EMPTY_BODY = Object.freeze({ text: '', size: 0, truncated: false, encoding: 'none' });
31
+
32
+ /**
33
+ * Decodes captured bodies, keeping at most `limit` bytes of text.
34
+ */
35
+ export class BodyDecoder {
36
+ /**
37
+ * @param {number} limit - Most bytes of text kept per body, after decompression
38
+ */
39
+ constructor(limit) {
40
+ this.limit = limit;
41
+ }
42
+
43
+ /**
44
+ * Decodes captured bytes: decompressed per Content-Encoding, then kept as text unless they are
45
+ * binary.
46
+ *
47
+ * @param {BodyCapture|null} capture - Captured body
48
+ * @param {Object} headers - Headers of the same message, with lowercase names
49
+ * @returns {BodyContent}
50
+ */
51
+ decode(capture, headers) {
52
+ if (!capture || capture.size === 0) {
53
+ return EMPTY_BODY;
54
+ }
55
+
56
+ let buffer = capture.buffer();
57
+ let truncated = capture.truncated;
58
+ let trustContentType = true;
59
+
60
+ const contentEncoding = String(headerValue(headers, 'content-encoding') || '').trim().toLowerCase();
61
+ if (contentEncoding && contentEncoding !== 'identity') {
62
+ const decoded = this._decompress(buffer, contentEncoding);
63
+ if (decoded) {
64
+ buffer = decoded.buffer;
65
+ truncated = truncated || decoded.truncated;
66
+ } else {
67
+ // Not decodable (or already decoded by middleware below us): judge by content
68
+ trustContentType = false;
69
+ }
70
+ }
71
+
72
+ const contentType = String(headerValue(headers, 'content-type') || '');
73
+ if (!BodyDecoder.isTextual(contentType, buffer, trustContentType)) {
74
+ return { text: '', size: capture.size, truncated, encoding: 'binary' };
75
+ }
76
+ return { text: buffer.toString('utf8'), size: capture.size, truncated, encoding: 'utf8' };
77
+ }
78
+
79
+ /**
80
+ * Content of a body known only from the value a framework parsed it into, because the app read
81
+ * the bytes before PeekInside was tracking the request.
82
+ *
83
+ * @param {*} value - Parsed body: an object, a string, a Buffer, or undefined when never read
84
+ * @param {Object} headers - Request headers
85
+ * @returns {BodyContent} Objects are shown as JSON; the size is Content-Length when known
86
+ */
87
+ decodeValue(value, headers) {
88
+ const declared = Number(headers['content-length']) || 0;
89
+
90
+ if (value === undefined || value === null) {
91
+ // The application never read the body, so there was nothing to capture.
92
+ return { text: '', size: declared, truncated: false, encoding: 'unavailable' };
93
+ }
94
+ if (Buffer.isBuffer(value)) {
95
+ const capture = new BodyCapture(this.limit);
96
+ capture.push(value);
97
+ return this.decode(capture, headers);
98
+ }
99
+
100
+ let text;
101
+ if (typeof value === 'string') {
102
+ text = value;
103
+ } else {
104
+ try {
105
+ text = JSON.stringify(value);
106
+ } catch (error) {
107
+ text = String(value);
108
+ }
109
+ }
110
+
111
+ const bytes = Buffer.byteLength(text);
112
+ const truncated = bytes > this.limit;
113
+ return {
114
+ text: truncated ? Buffer.from(text).subarray(0, this.limit).toString('utf8') : text,
115
+ size: declared || bytes,
116
+ truncated,
117
+ encoding: 'utf8'
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Whether a body is text: by its Content-Type when that is trusted and conclusive, otherwise by
123
+ * looking at the bytes.
124
+ *
125
+ * @param {string} contentType
126
+ * @param {Buffer} buffer - The body, decompressed
127
+ * @param {boolean} [trustContentType=true] - False when the bytes may not match the headers
128
+ * @returns {boolean}
129
+ */
130
+ static isTextual(contentType, buffer, trustContentType = true) {
131
+ if (trustContentType && contentType) {
132
+ if (TEXTUAL_TYPE.test(contentType)) return true;
133
+ if (BINARY_TYPE.test(contentType)) return false;
134
+ }
135
+ return BodyDecoder.looksLikeText(buffer);
136
+ }
137
+
138
+ /**
139
+ * Heuristic for bodies without a conclusive Content-Type: text has no NUL bytes and few control
140
+ * characters in its first 2 KB.
141
+ *
142
+ * @param {Buffer} buffer
143
+ * @returns {boolean}
144
+ */
145
+ static looksLikeText(buffer) {
146
+ const length = Math.min(buffer.length, 2048);
147
+ let suspicious = 0;
148
+ for (let i = 0; i < length; i++) {
149
+ const byte = buffer[i];
150
+ if (byte === 0) return false;
151
+ if (byte < 7 || (byte > 13 && byte < 32)) suspicious++;
152
+ }
153
+ return suspicious <= length * 0.05;
154
+ }
155
+
156
+ // Decompresses gzip, deflate (zlib or raw) and brotli. Output is capped, so a small compressed
157
+ // body can't expand into gigabytes in the app's memory (a "zip bomb"), and a body cut off at
158
+ // the capture limit still yields the part that was captured (sync flush, not finish).
159
+ // Returns null when the body can't be decoded.
160
+ _decompress(buffer, encoding) {
161
+ const limit = this.limit;
162
+ const maxOutputLength = Math.max(limit * 16, 1024 * 1024);
163
+ const options = { finishFlush: zlib.constants.Z_SYNC_FLUSH, maxOutputLength };
164
+
165
+ try {
166
+ let output;
167
+ if (encoding === 'gzip' || encoding === 'x-gzip') {
168
+ output = zlib.gunzipSync(buffer, options);
169
+ } else if (encoding === 'deflate') {
170
+ try {
171
+ output = zlib.inflateSync(buffer, options);
172
+ } catch (error) {
173
+ output = zlib.inflateRawSync(buffer, options);
174
+ }
175
+ } else if (encoding === 'br') {
176
+ output = zlib.brotliDecompressSync(buffer, { finishFlush: zlib.constants.BROTLI_OPERATION_FLUSH, maxOutputLength });
177
+ } else {
178
+ return null;
179
+ }
180
+
181
+ return output.length > limit
182
+ ? { buffer: output.subarray(0, limit), truncated: true }
183
+ : { buffer: output, truncated: false };
184
+ } catch (error) {
185
+ return null;
186
+ }
187
+ }
188
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Records one request/response exchange: starts the clock, taps both bodies, and once the
3
+ * response has been sent (or the connection closed first) builds the exchange object that a
4
+ * transport turns into a stored record.
5
+ *
6
+ * Exceptions recorded during the request use the recorder too: its id (known from the start) and
7
+ * a snapshot of the request as received so far.
8
+ */
9
+
10
+ import { randomUUID } from 'crypto';
11
+ import http from 'http';
12
+ import { performance } from 'perf_hooks';
13
+ import { BodyDecoder, EMPTY_BODY } from './body-decoder.js';
14
+ import { hasBody, parseQuery } from './http-message.js';
15
+ import { RequestBodyTap } from './request-body-tap.js';
16
+ import { ResponseTap } from './response-tap.js';
17
+
18
+ const kBodySource = Symbol('peekInside.bodySource');
19
+
20
+ /**
21
+ * @typedef {Object} Exchange
22
+ * @property {string} id - Random UUID
23
+ * @property {string} method
24
+ * @property {string} url - Request target as received (originalUrl in Express)
25
+ * @property {string} path - Pathname of `url`
26
+ * @property {Object<string, string|string[]>} query
27
+ * @property {Object<string, string>} params - Route parameters set by the framework, if any
28
+ * @property {Object<string, string|string[]>} headers - Request headers
29
+ * @property {string} body - Request body as text (see bodyEncoding)
30
+ * @property {number} bodySize
31
+ * @property {boolean} bodyTruncated
32
+ * @property {'none'|'utf8'|'binary'|'unavailable'} bodyEncoding
33
+ * @property {string} ip
34
+ * @property {string} userAgent
35
+ * @property {'http'|'https'} protocol
36
+ * @property {string} httpVersion
37
+ * @property {number} timestamp - When the request started (ms since the epoch)
38
+ * @property {Object} response - statusCode (null when nothing was sent), statusMessage, headers,
39
+ * body, size, bodyTruncated, bodyEncoding, responseTime (ms) and aborted
40
+ * @property {number} [exceptionCount] - Exceptions recorded during the request, added by PeekInside
41
+ * @property {Array<{ id: string, name: string, message: string }>} [exceptions] - The first of them
42
+ * @property {Object} [stats] - Timeline counters (queries, outgoing calls, logs), added by PeekInside
43
+ * @property {Object} [timeline] - The request's timeline, when something was recorded on it
44
+ */
45
+
46
+ /**
47
+ * Records a single exchange. Create one per request, then call start() before the app reads the
48
+ * request body.
49
+ */
50
+ export class ExchangeRecorder {
51
+ /**
52
+ * Tells the recorder where a framework keeps a request's parsed body. It is used when the app
53
+ * read the body before tracking started (a body parser registered before PeekInside), so the
54
+ * bytes themselves were never seen.
55
+ *
56
+ * @param {import('http').IncomingMessage} req
57
+ * @param {() => *} getter - Returns the parsed body, such as Koa's ctx.request.body
58
+ * @returns {void}
59
+ */
60
+ static setBodySource(req, getter) {
61
+ req[kBodySource] = getter;
62
+ }
63
+
64
+ /**
65
+ * @param {import('http').IncomingMessage} req
66
+ * @param {import('http').ServerResponse} res
67
+ * @param {{ url: string, pathname: string, search: string }} target - The request target, split
68
+ * @param {Object} options
69
+ * @param {() => number} options.maxBodySize - Returns the current maxBodySize; it is read when
70
+ * the bodies are tapped and again when they are decoded, so later option changes apply
71
+ * @param {(exchange: Exchange) => void} options.onExchange - Receives the finished exchange
72
+ */
73
+ constructor(req, res, { url, pathname, search }, { maxBodySize, onExchange }) {
74
+ this.req = req;
75
+ this.res = res;
76
+ this.url = url;
77
+ this.pathname = pathname;
78
+ this.search = search;
79
+ this._maxBodySize = maxBodySize;
80
+ this._onExchange = onExchange;
81
+ this._recorded = false;
82
+
83
+ /**
84
+ * Id of the request record. Created now rather than when the exchange is built, so that
85
+ * exceptions recorded during the request can refer to it.
86
+ * @type {string}
87
+ */
88
+ this.id = randomUUID();
89
+ }
90
+
91
+ /**
92
+ * Whether the exchange has been built (the response finished, or the connection closed).
93
+ *
94
+ * @type {boolean}
95
+ */
96
+ get recorded() {
97
+ return this._recorded;
98
+ }
99
+
100
+ /**
101
+ * Starts the clock, taps both bodies and waits for the response to finish.
102
+ *
103
+ * @returns {ExchangeRecorder} This recorder
104
+ */
105
+ start() {
106
+ const res = this.res;
107
+ this.startedAt = Date.now();
108
+ // The timeline's clock: span offsets count from the same instant as occurredAt.
109
+ this.perfStart = performance.now();
110
+ this._start = process.hrtime.bigint();
111
+ this._requestTap = new RequestBodyTap(this.req, this._maxBodySize());
112
+ this._responseTap = new ResponseTap(res, this._maxBodySize());
113
+
114
+ this._onFinish = () => this._finish(false);
115
+ // 'close' without 'finish' means the client went away before the response was sent.
116
+ this._onClose = () => this._finish(!res.writableFinished);
117
+ res.on('finish', this._onFinish);
118
+ res.on('close', this._onClose);
119
+ return this;
120
+ }
121
+
122
+ /**
123
+ * Builds the exchange as it stands now.
124
+ *
125
+ * @param {boolean} aborted - Whether the connection closed before the response was sent
126
+ * @returns {Exchange}
127
+ */
128
+ buildExchange(aborted) {
129
+ const { req, res } = this;
130
+ const duration = Number(process.hrtime.bigint() - this._start) / 1e6;
131
+ const decoder = new BodyDecoder(this._maxBodySize());
132
+ const requestHeaders = { ...req.headers };
133
+ const responseHeaders = this._responseTap.headers();
134
+ const requestContent = this._requestContent(decoder, requestHeaders);
135
+ // HEAD responses never have a body, whatever the app wrote.
136
+ const responseContent = req.method === 'HEAD'
137
+ ? EMPTY_BODY
138
+ : decoder.decode(this._responseTap.body, responseHeaders);
139
+ const statusCode = aborted && !res.headersSent ? null : res.statusCode;
140
+
141
+ return {
142
+ id: this.id,
143
+ method: req.method,
144
+ url: this.url,
145
+ path: this.pathname,
146
+ query: parseQuery(this.search),
147
+ params: req.params && typeof req.params === 'object' ? { ...req.params } : {},
148
+ headers: requestHeaders,
149
+ body: requestContent.text,
150
+ bodySize: requestContent.size,
151
+ bodyTruncated: requestContent.truncated,
152
+ bodyEncoding: requestContent.encoding,
153
+ ip: req.ip || (req.socket && req.socket.remoteAddress) || '',
154
+ userAgent: requestHeaders['user-agent'] || '',
155
+ protocol: req.socket && req.socket.encrypted ? 'https' : 'http',
156
+ httpVersion: req.httpVersion || '1.1',
157
+ timestamp: this.startedAt,
158
+ response: {
159
+ statusCode,
160
+ statusMessage: statusCode === null ? '' : (res.statusMessage || http.STATUS_CODES[statusCode] || ''),
161
+ headers: responseHeaders,
162
+ body: responseContent.text,
163
+ size: responseContent.size,
164
+ bodyTruncated: responseContent.truncated,
165
+ bodyEncoding: responseContent.encoding,
166
+ responseTime: Math.round(duration * 100) / 100,
167
+ aborted: Boolean(aborted)
168
+ }
169
+ };
170
+ }
171
+
172
+ /**
173
+ * The request as an exception record shows it: what has been received so far, with a shorter
174
+ * body than the request record keeps.
175
+ *
176
+ * @param {number} bodyLimit - Most bytes of body text
177
+ * @returns {{ method: string, url: string, headers: Object, query: Object, params: Object,
178
+ * body: string, bodyTruncated: boolean, ip: string, userAgent: string }}
179
+ */
180
+ snapshot(bodyLimit) {
181
+ const req = this.req;
182
+ const headers = { ...req.headers };
183
+ const content = this._requestContent(new BodyDecoder(bodyLimit), headers);
184
+ return {
185
+ method: String(req.method || ''),
186
+ url: this.url,
187
+ headers,
188
+ query: parseQuery(this.search),
189
+ params: stringParams(req.params),
190
+ body: content.encoding === 'utf8' ? content.text : '',
191
+ bodyTruncated: content.encoding === 'utf8' && content.truncated,
192
+ ip: req.ip || (req.socket && req.socket.remoteAddress) || '',
193
+ userAgent: headers['user-agent'] || ''
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Builds the exchange right away, for a process that is about to exit: a response still in
199
+ * progress is recorded as aborted. Afterwards the recorder records nothing more.
200
+ *
201
+ * @returns {Exchange|null} null when the exchange was already recorded
202
+ */
203
+ takeExchange() {
204
+ if (this._recorded) {
205
+ return null;
206
+ }
207
+ this._recorded = true;
208
+ this.res.removeListener('finish', this._onFinish);
209
+ this.res.removeListener('close', this._onClose);
210
+ return this.buildExchange(!this.res.writableFinished);
211
+ }
212
+
213
+ // Runs once, on 'finish' or 'close', whichever comes first.
214
+ _finish(aborted) {
215
+ if (this._recorded) {
216
+ return;
217
+ }
218
+ this._recorded = true;
219
+ this.res.removeListener('finish', this._onFinish);
220
+ this.res.removeListener('close', this._onClose);
221
+
222
+ try {
223
+ this._onExchange(this.buildExchange(aborted));
224
+ } catch (error) {
225
+ // Ignore: a malformed exchange must not affect the application.
226
+ }
227
+ }
228
+
229
+ // The request body as the app read it, or, when the app read it before tracking started,
230
+ // what the framework parsed it into.
231
+ _requestContent(decoder, headers) {
232
+ const req = this.req;
233
+ const tapped = this._requestTap.body;
234
+ if (tapped.size > 0) {
235
+ return decoder.decode(tapped, headers);
236
+ }
237
+ if (!hasBody(headers)) {
238
+ return EMPTY_BODY;
239
+ }
240
+
241
+ let parsed;
242
+ try {
243
+ parsed = typeof req[kBodySource] === 'function' ? req[kBodySource]() : undefined;
244
+ } catch (error) {
245
+ parsed = undefined;
246
+ }
247
+ if (parsed === undefined) {
248
+ parsed = req.body !== undefined ? req.body : req.rawBody;
249
+ }
250
+ return decoder.decodeValue(parsed, headers);
251
+ }
252
+ }
253
+
254
+ // Route parameters as strings (Express keeps them as strings; other routers may not).
255
+ function stringParams(params) {
256
+ const result = {};
257
+ if (!params || typeof params !== 'object') return result;
258
+ for (const key of Object.keys(params)) {
259
+ const value = params[key];
260
+ if (value !== undefined && value !== null && typeof value !== 'object') result[key] = String(value);
261
+ }
262
+ return result;
263
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Pure helpers for the parts of HTTP messages that PeekInside records: request targets, query
3
+ * strings and header objects.
4
+ *
5
+ * Query keys and header names come from the network, so the objects built from them define
6
+ * every key as an own property: a key such as "__proto__" stays a plain entry instead of
7
+ * changing the object's prototype.
8
+ */
9
+
10
+ /**
11
+ * Splits a request target into pathname and search, without normalizing it: the dashboard shows
12
+ * the path exactly as the client sent it.
13
+ *
14
+ * @param {string} url - Request target, such as "/users?page=2" or an absolute URL (sent to proxies)
15
+ * @returns {{ pathname: string, search: string }} `search` includes the "?" when present
16
+ */
17
+ export function splitUrl(url) {
18
+ let target = typeof url === 'string' && url !== '' ? url : '/';
19
+ if (/^[a-z][a-z\d+.-]*:\/\//i.test(target)) {
20
+ // Absolute-form target, as sent to proxies
21
+ try {
22
+ const parsed = new URL(target);
23
+ target = parsed.pathname + parsed.search;
24
+ } catch (error) {
25
+ target = '/';
26
+ }
27
+ }
28
+ const index = target.indexOf('?');
29
+ return index === -1
30
+ ? { pathname: target, search: '' }
31
+ : { pathname: target.slice(0, index), search: target.slice(index) };
32
+ }
33
+
34
+ /**
35
+ * Parses a query string. Repeated keys become arrays, in order.
36
+ *
37
+ * @param {string} search - Query string, including the leading "?"
38
+ * @returns {Object<string, string|string[]>}
39
+ */
40
+ export function parseQuery(search) {
41
+ const query = {};
42
+ if (!search || search.length < 2) {
43
+ return query;
44
+ }
45
+
46
+ let params;
47
+ try {
48
+ params = new URLSearchParams(search.slice(1));
49
+ } catch (error) {
50
+ return query;
51
+ }
52
+
53
+ for (const [key, value] of params) {
54
+ const existing = Object.prototype.hasOwnProperty.call(query, key) ? query[key] : undefined;
55
+ setOwn(query, key, existing === undefined ? value : [].concat(existing, value));
56
+ }
57
+ return query;
58
+ }
59
+
60
+ /**
61
+ * Turns headers in any form writeHead() accepts into an object with lowercase names.
62
+ *
63
+ * @param {Object|Array} headers - An object, a flat array [name, value, ...] or an array of pairs
64
+ * @returns {Object<string, *>}
65
+ */
66
+ export function normalizeHeaders(headers) {
67
+ const normalized = {};
68
+ const add = (name, value) => {
69
+ if (name !== undefined && name !== null && value !== undefined) {
70
+ setOwn(normalized, String(name).toLowerCase(), value);
71
+ }
72
+ };
73
+
74
+ if (Array.isArray(headers)) {
75
+ if (headers.length > 0 && Array.isArray(headers[0])) {
76
+ for (const pair of headers) add(pair[0], pair[1]);
77
+ } else {
78
+ for (let i = 0; i + 1 < headers.length; i += 2) add(headers[i], headers[i + 1]);
79
+ }
80
+ } else if (headers && typeof headers === 'object') {
81
+ for (const name of Object.keys(headers)) add(name, headers[name]);
82
+ }
83
+ return normalized;
84
+ }
85
+
86
+ /**
87
+ * Merges header objects; later sources win.
88
+ *
89
+ * @param {...(Object|null|undefined)} sources
90
+ * @returns {Object<string, *>}
91
+ */
92
+ export function mergeHeaders(...sources) {
93
+ const merged = {};
94
+ for (const source of sources) {
95
+ if (!source) continue;
96
+ for (const name of Object.keys(source)) {
97
+ setOwn(merged, name, source[name]);
98
+ }
99
+ }
100
+ return merged;
101
+ }
102
+
103
+ /**
104
+ * @param {Object} headers - Headers with lowercase names
105
+ * @param {string} name - Lowercase header name
106
+ * @returns {*} The header's value, or its first value when it was sent several times
107
+ */
108
+ export function headerValue(headers, name) {
109
+ const value = headers[name];
110
+ return Array.isArray(value) ? value[0] : value;
111
+ }
112
+
113
+ /**
114
+ * Whether request headers announce a body: chunked, or a positive Content-Length.
115
+ *
116
+ * @param {Object} headers - Request headers
117
+ * @returns {boolean}
118
+ */
119
+ export function hasBody(headers) {
120
+ if (headers['transfer-encoding'] !== undefined) {
121
+ return true;
122
+ }
123
+ const length = Number(headers['content-length']);
124
+ return Number.isFinite(length) && length > 0;
125
+ }
126
+
127
+ /**
128
+ * Sets a property as an own, enumerable entry, even for keys such as "__proto__" that would
129
+ * otherwise change the object's prototype.
130
+ *
131
+ * @param {Object} target
132
+ * @param {string} key
133
+ * @param {*} value
134
+ * @returns {void}
135
+ */
136
+ export function setOwn(target, key, value) {
137
+ Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true });
138
+ }