@req-inspect/core 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +34 -2
- package/dist/index.cjs +589 -0
- package/dist/index.d.cts +139 -0
- package/dist/index.d.ts +139 -0
- package/dist/index.js +541 -0
- package/dist/ui/assets/index-CJ7HUFGZ.css +1 -0
- package/dist/ui/assets/index-Czt8I2nd.js +8 -0
- package/dist/ui/index.html +14 -0
- package/package.json +47 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Luis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @req-inspect/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Framework-agnostic core of [req-inspect](https://github.com/Hurishihara/req-inspect): record storage, redaction and the dashboard handler.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm i @req-inspect/core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
Using Express? Install [`@req-inspect/express`](https://www.npmjs.com/package/@req-inspect/express) instead; it captures requests for you.
|
|
14
|
+
|
|
15
|
+
The core package is for writing an adapter for another framework. It serves the dashboard and stores the records you hand it:
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
import http from 'node:http'
|
|
19
|
+
import { createInspector } from '@req-inspect/core'
|
|
20
|
+
|
|
21
|
+
const inspector = createInspector() // same options as the Express middleware
|
|
22
|
+
const app = (req, res) => res.end('hello')
|
|
23
|
+
|
|
24
|
+
http.createServer((req, res) => {
|
|
25
|
+
const { pathname } = new URL(req.url, 'http://x')
|
|
26
|
+
if (inspector.options.enabled && inspector.isInspectorPath(pathname)) return inspector.handle(req, res)
|
|
27
|
+
app(req, res)
|
|
28
|
+
}).listen(3000)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
To capture traffic, build a `RecordDraft` for each finished request and pass it to `inspector.record(draft)`. Use `inspector.captureRequestBody(parsedBody, contentType)` for the request body and `captureRawBody(...)` for the response body. Redaction of headers, query strings and bodies happens inside `record`; drafts are unredacted.
|
|
32
|
+
|
|
33
|
+
Open `http://localhost:3000/_inspect/` in your browser. Without `auth` it is only reachable from localhost.
|
|
34
|
+
|
|
35
|
+
See the [root README](https://github.com/Hurishihara/req-inspect#readme) for options, security and known limitations.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
BODY_KEYS_DEFAULT: () => BODY_KEYS_DEFAULT,
|
|
24
|
+
DEFAULT_MAX_BODY_BYTES: () => DEFAULT_MAX_BODY_BYTES,
|
|
25
|
+
DEFAULT_MAX_REQUESTS: () => DEFAULT_MAX_REQUESTS,
|
|
26
|
+
DEFAULT_PATH: () => DEFAULT_PATH,
|
|
27
|
+
REDACTED: () => REDACTED,
|
|
28
|
+
REDACTED_HEADERS_DEFAULT: () => REDACTED_HEADERS_DEFAULT,
|
|
29
|
+
RingStore: () => RingStore,
|
|
30
|
+
captureParsedBody: () => captureParsedBody,
|
|
31
|
+
captureRawBody: () => captureRawBody,
|
|
32
|
+
checkAccess: () => checkAccess,
|
|
33
|
+
createHandler: () => createHandler,
|
|
34
|
+
createInspector: () => createInspector,
|
|
35
|
+
normalizeHeaders: () => normalizeHeaders,
|
|
36
|
+
redactBody: () => redactBody,
|
|
37
|
+
redactHeaders: () => redactHeaders,
|
|
38
|
+
resolveOptions: () => resolveOptions,
|
|
39
|
+
toSummary: () => toSummary,
|
|
40
|
+
warnOnce: () => warnOnce
|
|
41
|
+
});
|
|
42
|
+
module.exports = __toCommonJS(index_exports);
|
|
43
|
+
|
|
44
|
+
// ../../node_modules/.bun/tsup@9.0.0+c1a88b5bd57970a6/node_modules/tsup/assets/cjs_shims.js
|
|
45
|
+
var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
|
|
46
|
+
var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
|
|
47
|
+
|
|
48
|
+
// src/options.ts
|
|
49
|
+
var DEFAULT_PATH = "/_inspect";
|
|
50
|
+
var DEFAULT_MAX_REQUESTS = 500;
|
|
51
|
+
var DEFAULT_MAX_BODY_BYTES = 65536;
|
|
52
|
+
var REDACTED_HEADERS_DEFAULT = ["authorization", "cookie", "set-cookie", "x-api-key"];
|
|
53
|
+
var BODY_KEYS_DEFAULT = [/password|token|secret|apikey|api_key/i];
|
|
54
|
+
function normalizePath(path) {
|
|
55
|
+
let p = path.startsWith("/") ? path : `/${path}`;
|
|
56
|
+
while (p.length > 1 && p.endsWith("/")) p = p.slice(0, -1);
|
|
57
|
+
return p;
|
|
58
|
+
}
|
|
59
|
+
var isNonEmptyString = (v) => typeof v === "string" && v.length > 0;
|
|
60
|
+
function resolveOptions(opts = {}, env = process.env) {
|
|
61
|
+
const auth = opts.auth ?? null;
|
|
62
|
+
if (auth !== null && !(isNonEmptyString(auth.user) && isNonEmptyString(auth.pass))) {
|
|
63
|
+
throw new TypeError("[req-inspect] auth.user and auth.pass must be non-empty strings");
|
|
64
|
+
}
|
|
65
|
+
const path = normalizePath(opts.path ?? DEFAULT_PATH);
|
|
66
|
+
if (path === "/") throw new TypeError("[req-inspect] path must not be the root");
|
|
67
|
+
const enabled = opts.enabled ?? (env.NODE_ENV !== "production" || auth !== null);
|
|
68
|
+
return {
|
|
69
|
+
path,
|
|
70
|
+
enabled,
|
|
71
|
+
auth,
|
|
72
|
+
maxRequests: opts.maxRequests ?? DEFAULT_MAX_REQUESTS,
|
|
73
|
+
maxBodyBytes: opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES,
|
|
74
|
+
ignore: opts.ignore ?? [],
|
|
75
|
+
redact: {
|
|
76
|
+
headers: /* @__PURE__ */ new Set([...REDACTED_HEADERS_DEFAULT, ...(opts.redact?.headers ?? []).map((h) => h.toLowerCase())]),
|
|
77
|
+
bodyKeys: [...BODY_KEYS_DEFAULT, ...opts.redact?.bodyKeys ?? []]
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// src/store.ts
|
|
83
|
+
var DEFAULT_LIST_LIMIT = 100;
|
|
84
|
+
var MAX_LIST_LIMIT = 500;
|
|
85
|
+
var numericId = (id) => Number(id.slice(2));
|
|
86
|
+
var RingStore = class {
|
|
87
|
+
constructor(capacity) {
|
|
88
|
+
this.capacity = capacity;
|
|
89
|
+
this.buf = new Array(Math.max(1, Math.floor(capacity)));
|
|
90
|
+
}
|
|
91
|
+
capacity;
|
|
92
|
+
buf;
|
|
93
|
+
head = 0;
|
|
94
|
+
// index of the next write
|
|
95
|
+
size = 0;
|
|
96
|
+
counter = 0;
|
|
97
|
+
subs = /* @__PURE__ */ new Set();
|
|
98
|
+
nextId() {
|
|
99
|
+
this.counter += 1;
|
|
100
|
+
return `r_${String(this.counter).padStart(6, "0")}`;
|
|
101
|
+
}
|
|
102
|
+
add(record) {
|
|
103
|
+
this.buf[this.head] = record;
|
|
104
|
+
this.head = (this.head + 1) % this.buf.length;
|
|
105
|
+
if (this.size < this.buf.length) this.size += 1;
|
|
106
|
+
for (const fn of [...this.subs]) {
|
|
107
|
+
try {
|
|
108
|
+
fn(record);
|
|
109
|
+
} catch {
|
|
110
|
+
this.subs.delete(fn);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
get(id) {
|
|
115
|
+
for (let i = 0; i < this.size; i++) {
|
|
116
|
+
const r = this.at(i);
|
|
117
|
+
if (r && r.id === id) return r;
|
|
118
|
+
}
|
|
119
|
+
return void 0;
|
|
120
|
+
}
|
|
121
|
+
/** Newest first. */
|
|
122
|
+
list(q = {}) {
|
|
123
|
+
const limit = Math.min(MAX_LIST_LIMIT, Math.max(1, Math.floor(q.limit ?? DEFAULT_LIST_LIMIT)));
|
|
124
|
+
const before = q.before === void 0 ? NaN : numericId(q.before);
|
|
125
|
+
const out = [];
|
|
126
|
+
for (let i = this.size - 1; i >= 0 && out.length < limit; i--) {
|
|
127
|
+
const r = this.at(i);
|
|
128
|
+
if (!r) continue;
|
|
129
|
+
if (!Number.isNaN(before) && numericId(r.id) >= before) continue;
|
|
130
|
+
out.push(r);
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
clear() {
|
|
135
|
+
this.buf.fill(void 0);
|
|
136
|
+
this.head = 0;
|
|
137
|
+
this.size = 0;
|
|
138
|
+
}
|
|
139
|
+
subscribe(fn) {
|
|
140
|
+
this.subs.add(fn);
|
|
141
|
+
return () => {
|
|
142
|
+
this.subs.delete(fn);
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** i-th oldest record (0 = oldest). */
|
|
146
|
+
at(i) {
|
|
147
|
+
const len = this.buf.length;
|
|
148
|
+
return this.buf[(this.head - this.size + i + len * 2) % len];
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// src/body.ts
|
|
153
|
+
var OCTET = "application/octet-stream";
|
|
154
|
+
function baseType(ct) {
|
|
155
|
+
return (ct ?? "").split(";")[0].trim().toLowerCase();
|
|
156
|
+
}
|
|
157
|
+
function classifyContentType(ct) {
|
|
158
|
+
const t = baseType(ct);
|
|
159
|
+
if (t === "application/json" || t.endsWith("+json")) return "json";
|
|
160
|
+
if (t.startsWith("text/") || t === "application/x-www-form-urlencoded" || t === "application/xml" || t.endsWith("+xml")) return "text";
|
|
161
|
+
return "binary";
|
|
162
|
+
}
|
|
163
|
+
function truncateText(buf, maxBytes) {
|
|
164
|
+
const truncated = buf.length > maxBytes;
|
|
165
|
+
return { value: (truncated ? buf.subarray(0, maxBytes) : buf).toString("utf8"), truncated };
|
|
166
|
+
}
|
|
167
|
+
function captureParsedBody(value, contentType, maxBytes) {
|
|
168
|
+
if (value === void 0) return { kind: "none" };
|
|
169
|
+
if (Buffer.isBuffer(value)) {
|
|
170
|
+
return { kind: "binary", contentType: baseType(contentType) || OCTET, size: value.length };
|
|
171
|
+
}
|
|
172
|
+
if (typeof value === "string") {
|
|
173
|
+
const buf2 = Buffer.from(value, "utf8");
|
|
174
|
+
return { kind: "text", ...truncateText(buf2, maxBytes), size: buf2.length };
|
|
175
|
+
}
|
|
176
|
+
if (typeof value === "object" && value !== null && !contentType && !Array.isArray(value) && Object.keys(value).length === 0) {
|
|
177
|
+
return { kind: "none" };
|
|
178
|
+
}
|
|
179
|
+
let json;
|
|
180
|
+
try {
|
|
181
|
+
json = JSON.stringify(value);
|
|
182
|
+
} catch {
|
|
183
|
+
return { kind: "text", value: "[unserializable]", truncated: false, size: 0 };
|
|
184
|
+
}
|
|
185
|
+
if (json === void 0) return { kind: "text", value: "[unserializable]", truncated: false, size: 0 };
|
|
186
|
+
const buf = Buffer.from(json, "utf8");
|
|
187
|
+
if (buf.length <= maxBytes) return { kind: "json", value, truncated: false, size: buf.length };
|
|
188
|
+
return { kind: "text", ...truncateText(buf, maxBytes), size: buf.length };
|
|
189
|
+
}
|
|
190
|
+
function captureRawBody(chunks, totalSize, contentType, contentEncoding, maxBytes) {
|
|
191
|
+
if (totalSize === 0) return { kind: "none" };
|
|
192
|
+
if (contentEncoding) {
|
|
193
|
+
return { kind: "binary", contentType: baseType(contentType) || OCTET, size: totalSize, encoding: contentEncoding };
|
|
194
|
+
}
|
|
195
|
+
const kind = classifyContentType(contentType);
|
|
196
|
+
if (kind === "binary") return { kind: "binary", contentType: baseType(contentType) || OCTET, size: totalSize };
|
|
197
|
+
const { value, truncated } = truncateText(Buffer.concat(chunks), maxBytes);
|
|
198
|
+
const isTruncated = totalSize > maxBytes;
|
|
199
|
+
if (kind === "json" && !isTruncated) {
|
|
200
|
+
try {
|
|
201
|
+
return { kind: "json", value: JSON.parse(value), truncated: false, size: totalSize };
|
|
202
|
+
} catch {
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return { kind: "text", value, truncated: isTruncated || truncated, size: totalSize };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// src/redact.ts
|
|
209
|
+
var REDACTED = "[redacted]";
|
|
210
|
+
function normalizeHeaders(h) {
|
|
211
|
+
const out = {};
|
|
212
|
+
for (const [name, value] of Object.entries(h)) {
|
|
213
|
+
if (value === void 0) continue;
|
|
214
|
+
out[name.toLowerCase()] = Array.isArray(value) ? value.join(", ") : String(value);
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
function redactHeaders(h, names) {
|
|
219
|
+
const out = {};
|
|
220
|
+
for (const [name, value] of Object.entries(h)) {
|
|
221
|
+
out[name] = names.has(name.toLowerCase()) ? REDACTED : value;
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
function isPlainObject(v) {
|
|
226
|
+
if (typeof v !== "object" || v === null) return false;
|
|
227
|
+
const proto = Object.getPrototypeOf(v);
|
|
228
|
+
return proto === Object.prototype || proto === null;
|
|
229
|
+
}
|
|
230
|
+
function keyMatcher(keys) {
|
|
231
|
+
const strings = /* @__PURE__ */ new Set();
|
|
232
|
+
const patterns = [];
|
|
233
|
+
for (const k of keys) {
|
|
234
|
+
if (typeof k === "string") strings.add(k.toLowerCase());
|
|
235
|
+
else patterns.push(k);
|
|
236
|
+
}
|
|
237
|
+
return (key) => {
|
|
238
|
+
if (strings.has(key.toLowerCase())) return true;
|
|
239
|
+
return patterns.some((re) => {
|
|
240
|
+
re.lastIndex = 0;
|
|
241
|
+
const hit = re.test(key);
|
|
242
|
+
re.lastIndex = 0;
|
|
243
|
+
return hit;
|
|
244
|
+
});
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
var JSON_PAIR = /"((?:[^"\\]|\\.)*)"(\s*:\s*)(?:"(?:[^"\\]|\\.)*(?:"|\\?$)|-?\d[\d.eE+-]*|true|false|null)/g;
|
|
248
|
+
function decodeJsonKey(raw) {
|
|
249
|
+
try {
|
|
250
|
+
return JSON.parse(`"${raw}"`);
|
|
251
|
+
} catch {
|
|
252
|
+
return raw;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function scrubJsonText(text, keys) {
|
|
256
|
+
const matches = keyMatcher(keys);
|
|
257
|
+
return text.replace(JSON_PAIR, (whole, rawKey, sep2) => matches(decodeJsonKey(rawKey)) ? `"${rawKey}"${sep2}"${REDACTED}"` : whole);
|
|
258
|
+
}
|
|
259
|
+
function decodeQueryKey(raw) {
|
|
260
|
+
for (const k of new URLSearchParams(`${raw}=`).keys()) return k;
|
|
261
|
+
return raw;
|
|
262
|
+
}
|
|
263
|
+
function redactUrl(url, keys) {
|
|
264
|
+
const q = url.indexOf("?");
|
|
265
|
+
if (q < 0) return url;
|
|
266
|
+
const matches = keyMatcher(keys);
|
|
267
|
+
let changed = false;
|
|
268
|
+
const parts = url.slice(q + 1).split("&").map((part) => {
|
|
269
|
+
const eq = part.indexOf("=");
|
|
270
|
+
const rawKey = eq < 0 ? part : part.slice(0, eq);
|
|
271
|
+
if (rawKey === "" || !matches(decodeQueryKey(rawKey))) return part;
|
|
272
|
+
changed = true;
|
|
273
|
+
return `${rawKey}=${REDACTED}`;
|
|
274
|
+
});
|
|
275
|
+
return changed ? `${url.slice(0, q + 1)}${parts.join("&")}` : url;
|
|
276
|
+
}
|
|
277
|
+
function redactBody(value, keys) {
|
|
278
|
+
const matches = keyMatcher(keys);
|
|
279
|
+
const ancestors = /* @__PURE__ */ new Set();
|
|
280
|
+
const walk = (v) => {
|
|
281
|
+
const isArr = Array.isArray(v);
|
|
282
|
+
if (!isArr && !isPlainObject(v)) return v;
|
|
283
|
+
if (ancestors.has(v)) return "[circular]";
|
|
284
|
+
ancestors.add(v);
|
|
285
|
+
let out;
|
|
286
|
+
if (isArr) {
|
|
287
|
+
out = v.map(walk);
|
|
288
|
+
} else {
|
|
289
|
+
const obj = Object.getPrototypeOf(v) === null ? /* @__PURE__ */ Object.create(null) : {};
|
|
290
|
+
for (const [k, val] of Object.entries(v)) {
|
|
291
|
+
obj[k] = matches(k) ? REDACTED : walk(val);
|
|
292
|
+
}
|
|
293
|
+
out = obj;
|
|
294
|
+
}
|
|
295
|
+
ancestors.delete(v);
|
|
296
|
+
return out;
|
|
297
|
+
};
|
|
298
|
+
return walk(value);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// src/guard.ts
|
|
302
|
+
var import_node_crypto = require("crypto");
|
|
303
|
+
var LOOPBACK = /* @__PURE__ */ new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
|
|
304
|
+
var BASIC = /^basic\s+([A-Za-z0-9+/]+={0,2})\s*$/i;
|
|
305
|
+
var digest = (s) => (0, import_node_crypto.createHash)("sha256").update(s).digest();
|
|
306
|
+
var safeEqual = (a, b) => (0, import_node_crypto.timingSafeEqual)(digest(a), digest(b));
|
|
307
|
+
function firstValue(v) {
|
|
308
|
+
return Array.isArray(v) ? v[0] : v;
|
|
309
|
+
}
|
|
310
|
+
function hostname(host) {
|
|
311
|
+
const h = host.trim().toLowerCase();
|
|
312
|
+
if (h.startsWith("[")) {
|
|
313
|
+
const end = h.indexOf("]");
|
|
314
|
+
return end < 0 ? "" : h.slice(1, end);
|
|
315
|
+
}
|
|
316
|
+
const colon = h.indexOf(":");
|
|
317
|
+
return colon >= 0 && colon === h.lastIndexOf(":") ? h.slice(0, colon) : h;
|
|
318
|
+
}
|
|
319
|
+
function isLocalHost(host) {
|
|
320
|
+
if (host === void 0) return false;
|
|
321
|
+
const name = hostname(host);
|
|
322
|
+
return name === "localhost" || name.endsWith(".localhost") || name === "127.0.0.1" || name === "::1";
|
|
323
|
+
}
|
|
324
|
+
function parseBasic(header) {
|
|
325
|
+
const m = header === void 0 ? null : BASIC.exec(header);
|
|
326
|
+
if (!m) return null;
|
|
327
|
+
const decoded = Buffer.from(m[1], "base64").toString("utf8");
|
|
328
|
+
const i = decoded.indexOf(":");
|
|
329
|
+
if (i < 0) return null;
|
|
330
|
+
return { user: decoded.slice(0, i), pass: decoded.slice(i + 1) };
|
|
331
|
+
}
|
|
332
|
+
function checkAccess(req, auth) {
|
|
333
|
+
if (!auth) {
|
|
334
|
+
const forwarded = req.headers["x-forwarded-for"] !== void 0 || req.headers["forwarded"] !== void 0;
|
|
335
|
+
if (forwarded || req.remoteAddress === void 0 || !LOOPBACK.has(req.remoteAddress)) return { ok: false, status: 403 };
|
|
336
|
+
if (!isLocalHost(firstValue(req.headers["host"]))) return { ok: false, status: 403 };
|
|
337
|
+
return { ok: true };
|
|
338
|
+
}
|
|
339
|
+
const creds = parseBasic(firstValue(req.headers["authorization"]));
|
|
340
|
+
if (!creds) return { ok: false, status: 401 };
|
|
341
|
+
const userOk = safeEqual(creds.user, auth.user);
|
|
342
|
+
const passOk = safeEqual(creds.pass, auth.pass);
|
|
343
|
+
return userOk && passOk ? { ok: true } : { ok: false, status: 401 };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// src/inspector.ts
|
|
347
|
+
var import_node_path2 = require("path");
|
|
348
|
+
var import_node_url = require("url");
|
|
349
|
+
|
|
350
|
+
// src/http.ts
|
|
351
|
+
var import_promises = require("fs/promises");
|
|
352
|
+
var import_node_path = require("path");
|
|
353
|
+
|
|
354
|
+
// src/warn.ts
|
|
355
|
+
var seen = /* @__PURE__ */ new Set();
|
|
356
|
+
function warnOnce(key, err) {
|
|
357
|
+
if (seen.has(key)) return;
|
|
358
|
+
seen.add(key);
|
|
359
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
360
|
+
console.warn(`[req-inspect] ${key}: ${message}`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// src/http.ts
|
|
364
|
+
var CONTENT_TYPES = {
|
|
365
|
+
".js": "text/javascript; charset=utf-8",
|
|
366
|
+
".css": "text/css; charset=utf-8",
|
|
367
|
+
".svg": "image/svg+xml",
|
|
368
|
+
".png": "image/png",
|
|
369
|
+
".ico": "image/x-icon",
|
|
370
|
+
".woff2": "font/woff2",
|
|
371
|
+
".html": "text/html; charset=utf-8",
|
|
372
|
+
".json": "application/json; charset=utf-8"
|
|
373
|
+
};
|
|
374
|
+
function sendJson(res, status, body) {
|
|
375
|
+
const payload = JSON.stringify(body);
|
|
376
|
+
res.writeHead(status, {
|
|
377
|
+
"content-type": "application/json; charset=utf-8",
|
|
378
|
+
"cache-control": "no-store",
|
|
379
|
+
"content-length": Buffer.byteLength(payload)
|
|
380
|
+
});
|
|
381
|
+
res.end(payload);
|
|
382
|
+
}
|
|
383
|
+
function fail(res, err) {
|
|
384
|
+
warnOnce("handler", err);
|
|
385
|
+
try {
|
|
386
|
+
if (!res.headersSent) sendJson(res, 500, { error: "internal error" });
|
|
387
|
+
else res.destroy();
|
|
388
|
+
} catch {
|
|
389
|
+
res.destroy();
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
var notFound = (res) => sendJson(res, 404, { error: "not found" });
|
|
393
|
+
function decode(s) {
|
|
394
|
+
try {
|
|
395
|
+
const d = decodeURIComponent(s);
|
|
396
|
+
return d.includes("\0") ? null : d;
|
|
397
|
+
} catch {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async function sendFile(res, file) {
|
|
402
|
+
let data;
|
|
403
|
+
try {
|
|
404
|
+
data = await (0, import_promises.readFile)(file);
|
|
405
|
+
} catch {
|
|
406
|
+
return notFound(res);
|
|
407
|
+
}
|
|
408
|
+
res.writeHead(200, {
|
|
409
|
+
"content-type": CONTENT_TYPES[(0, import_node_path.extname)(file).toLowerCase()] ?? "application/octet-stream",
|
|
410
|
+
"content-length": data.length,
|
|
411
|
+
"x-content-type-options": "nosniff"
|
|
412
|
+
});
|
|
413
|
+
res.end(data);
|
|
414
|
+
}
|
|
415
|
+
async function serveIndex(res, uiDir) {
|
|
416
|
+
let data;
|
|
417
|
+
try {
|
|
418
|
+
data = await (0, import_promises.readFile)((0, import_node_path.resolve)(uiDir, "index.html"));
|
|
419
|
+
} catch {
|
|
420
|
+
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
|
|
421
|
+
res.end(`req-inspect: dashboard files missing from package (expected at ${uiDir})`);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
res.writeHead(200, {
|
|
425
|
+
"content-type": CONTENT_TYPES[".html"],
|
|
426
|
+
"content-length": data.length,
|
|
427
|
+
"x-content-type-options": "nosniff"
|
|
428
|
+
});
|
|
429
|
+
res.end(data);
|
|
430
|
+
}
|
|
431
|
+
async function serveAsset(res, uiDir, rawRest) {
|
|
432
|
+
const rel = decode(rawRest);
|
|
433
|
+
if (rel === null || rel === "") return notFound(res);
|
|
434
|
+
const assetsDir = (0, import_node_path.resolve)(uiDir, "assets");
|
|
435
|
+
const target = (0, import_node_path.resolve)(uiDir, "assets", rel);
|
|
436
|
+
if (!target.startsWith(assetsDir + import_node_path.sep)) return notFound(res);
|
|
437
|
+
return sendFile(res, target);
|
|
438
|
+
}
|
|
439
|
+
function stream(res, req, store, keepAliveMs) {
|
|
440
|
+
res.writeHead(200, {
|
|
441
|
+
"content-type": "text/event-stream",
|
|
442
|
+
"cache-control": "no-store, no-transform",
|
|
443
|
+
connection: "keep-alive"
|
|
444
|
+
});
|
|
445
|
+
res.flushHeaders();
|
|
446
|
+
res.write("retry: 2000\n\n");
|
|
447
|
+
const unsubscribe = store.subscribe((r) => {
|
|
448
|
+
res.write(`event: record
|
|
449
|
+
data: ${JSON.stringify(toSummary(r))}
|
|
450
|
+
|
|
451
|
+
`);
|
|
452
|
+
});
|
|
453
|
+
const timer = setInterval(() => res.write(": ping\n\n"), keepAliveMs);
|
|
454
|
+
req.on("close", () => {
|
|
455
|
+
clearInterval(timer);
|
|
456
|
+
unsubscribe();
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
function createHandler(store, options, uiDir, keepAliveMs = 15e3) {
|
|
460
|
+
const mount = options.path === "/" ? "" : options.path;
|
|
461
|
+
const root = (0, import_node_path.resolve)(uiDir);
|
|
462
|
+
const dispatch = (req, res) => {
|
|
463
|
+
const access = checkAccess({ remoteAddress: req.socket.remoteAddress, headers: req.headers }, options.auth);
|
|
464
|
+
if (!access.ok) {
|
|
465
|
+
if (access.status === 401) res.setHeader("WWW-Authenticate", 'Basic realm="req-inspect"');
|
|
466
|
+
return sendJson(res, access.status, { error: access.status === 401 ? "unauthorized" : "forbidden" });
|
|
467
|
+
}
|
|
468
|
+
const url = req.originalUrl ?? req.url ?? "/";
|
|
469
|
+
const q = url.indexOf("?");
|
|
470
|
+
const pathname = q < 0 ? url : url.slice(0, q);
|
|
471
|
+
const query = new URLSearchParams(q < 0 ? "" : url.slice(q + 1));
|
|
472
|
+
if (mount !== "" && pathname === mount) {
|
|
473
|
+
res.writeHead(302, { Location: `${mount}/` });
|
|
474
|
+
return res.end();
|
|
475
|
+
}
|
|
476
|
+
if (!pathname.startsWith(`${mount}/`)) return notFound(res);
|
|
477
|
+
const rest = pathname.slice(mount.length);
|
|
478
|
+
const method = req.method ?? "GET";
|
|
479
|
+
if (method === "GET") {
|
|
480
|
+
if (rest === "/") {
|
|
481
|
+
serveIndex(res, root).catch((e) => fail(res, e));
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (rest.startsWith("/assets/")) {
|
|
485
|
+
serveAsset(res, root, rest.slice("/assets/".length)).catch((e) => fail(res, e));
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (rest === "/api/stream") return stream(res, req, store, keepAliveMs);
|
|
489
|
+
if (rest === "/api/requests") {
|
|
490
|
+
const n = Number(query.get("limit"));
|
|
491
|
+
const limit = query.get("limit") !== null && query.get("limit") !== "" && Number.isFinite(n) ? n : void 0;
|
|
492
|
+
const before = query.get("before") ?? void 0;
|
|
493
|
+
return sendJson(res, 200, store.list({ limit, before }).map(toSummary));
|
|
494
|
+
}
|
|
495
|
+
if (rest.startsWith("/api/requests/")) {
|
|
496
|
+
const id = decode(rest.slice("/api/requests/".length));
|
|
497
|
+
const record = id === null ? void 0 : store.get(id);
|
|
498
|
+
return record ? sendJson(res, 200, record) : notFound(res);
|
|
499
|
+
}
|
|
500
|
+
} else if (method === "DELETE" && rest === "/api/requests") {
|
|
501
|
+
store.clear();
|
|
502
|
+
res.writeHead(204, { "cache-control": "no-store" });
|
|
503
|
+
return res.end();
|
|
504
|
+
}
|
|
505
|
+
return notFound(res);
|
|
506
|
+
};
|
|
507
|
+
return (req, res) => {
|
|
508
|
+
try {
|
|
509
|
+
dispatch(req, res);
|
|
510
|
+
} catch (err) {
|
|
511
|
+
fail(res, err);
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// src/inspector.ts
|
|
517
|
+
function toSummary(r) {
|
|
518
|
+
const { request: _request, response: _response, ...summary } = r;
|
|
519
|
+
return summary;
|
|
520
|
+
}
|
|
521
|
+
function createInspector(options = {}, internal = {}) {
|
|
522
|
+
const opts = resolveOptions(options);
|
|
523
|
+
const store = new RingStore(opts.maxRequests);
|
|
524
|
+
const uiDir = internal.uiDir ?? (0, import_node_path2.join)((0, import_node_path2.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl)), "ui");
|
|
525
|
+
const handle = createHandler(store, opts, uiDir);
|
|
526
|
+
const keys = opts.redact.bodyKeys;
|
|
527
|
+
const redactCaptured = (b, contentType) => {
|
|
528
|
+
if (b.kind !== "json" && b.kind !== "text") return b;
|
|
529
|
+
if (typeof b.value !== "string") return { ...b, value: redactBody(b.value, keys) };
|
|
530
|
+
if (classifyContentType(contentType) === "json") return { ...b, value: scrubJsonText(b.value, keys) };
|
|
531
|
+
return b;
|
|
532
|
+
};
|
|
533
|
+
return {
|
|
534
|
+
options: opts,
|
|
535
|
+
store,
|
|
536
|
+
handle,
|
|
537
|
+
isInspectorPath: (p) => p === opts.path || p.startsWith(`${opts.path}/`),
|
|
538
|
+
shouldIgnore: (p) => opts.ignore.some((i) => typeof i === "string" ? p.startsWith(i) : i.test(p)),
|
|
539
|
+
captureRequestBody: (value, contentType) => captureParsedBody(redactBody(value, keys), contentType, opts.maxBodyBytes),
|
|
540
|
+
record(draft) {
|
|
541
|
+
setImmediate(() => {
|
|
542
|
+
try {
|
|
543
|
+
const { request, response } = draft;
|
|
544
|
+
const record = {
|
|
545
|
+
...draft,
|
|
546
|
+
v: 1,
|
|
547
|
+
id: store.nextId(),
|
|
548
|
+
url: redactUrl(draft.url, keys),
|
|
549
|
+
request: {
|
|
550
|
+
...request,
|
|
551
|
+
headers: redactHeaders(request.headers, opts.redact.headers),
|
|
552
|
+
query: redactBody(request.query, keys),
|
|
553
|
+
body: redactCaptured(request.body, request.headers["content-type"])
|
|
554
|
+
},
|
|
555
|
+
response: {
|
|
556
|
+
...response,
|
|
557
|
+
headers: redactHeaders(response.headers, opts.redact.headers),
|
|
558
|
+
body: redactCaptured(response.body, response.headers["content-type"])
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
store.add(record);
|
|
562
|
+
} catch (err) {
|
|
563
|
+
warnOnce("record", err);
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
570
|
+
0 && (module.exports = {
|
|
571
|
+
BODY_KEYS_DEFAULT,
|
|
572
|
+
DEFAULT_MAX_BODY_BYTES,
|
|
573
|
+
DEFAULT_MAX_REQUESTS,
|
|
574
|
+
DEFAULT_PATH,
|
|
575
|
+
REDACTED,
|
|
576
|
+
REDACTED_HEADERS_DEFAULT,
|
|
577
|
+
RingStore,
|
|
578
|
+
captureParsedBody,
|
|
579
|
+
captureRawBody,
|
|
580
|
+
checkAccess,
|
|
581
|
+
createHandler,
|
|
582
|
+
createInspector,
|
|
583
|
+
normalizeHeaders,
|
|
584
|
+
redactBody,
|
|
585
|
+
redactHeaders,
|
|
586
|
+
resolveOptions,
|
|
587
|
+
toSummary,
|
|
588
|
+
warnOnce
|
|
589
|
+
});
|