@dpkrn/nodetunnel 1.0.9 → 1.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/CHANGELOG.md +63 -0
- package/README.md +34 -11
- package/cmd/test-lib/main.js +15 -0
- package/internal/inspector/index.js +1012 -0
- package/internal/inspector/inspector.css +1085 -0
- package/internal/inspector/inspector.html +263 -0
- package/internal/inspector/inspector.js +601 -0
- package/internal/{tunnel → inspector}/logstore.js +13 -0
- package/internal/inspector/theme-postman.css +38 -0
- package/internal/inspector/theme-terminal.css +38 -0
- package/internal/tunnel/tunnel.js +15 -12
- package/package.json +3 -1
- package/pkg/tunnel/tunnel.js +1 -1
- package/internal/tunnel/inspector-page.html +0 -482
- package/internal/tunnel/inspector.js +0 -266
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { request } from 'undici';
|
|
7
|
+
import { WebSocketServer } from 'ws';
|
|
8
|
+
import { getLogs, getLogById, setInspectorSubscriber, addLog } from './logstore.js';
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
|
|
12
|
+
const inspectorHTML = readFileSync(join(__dirname, 'inspector.html'), 'utf8');
|
|
13
|
+
const inspectorCSS = readFileSync(join(__dirname, 'inspector.css'), 'utf8');
|
|
14
|
+
const themePostmanCSS = readFileSync(join(__dirname, 'theme-postman.css'), 'utf8');
|
|
15
|
+
const themeTerminalCSS = readFileSync(join(__dirname, 'theme-terminal.css'), 'utf8');
|
|
16
|
+
const indexJS = readFileSync(join(__dirname, 'index.js'), 'utf8');
|
|
17
|
+
|
|
18
|
+
const defaultInspectorAddr = ':4040';
|
|
19
|
+
|
|
20
|
+
/** Same header as gotunnel inspector. */
|
|
21
|
+
const HeaderLogReplay = 'X-Inspector-Log-Replay';
|
|
22
|
+
|
|
23
|
+
/** Dropped on replay so upstream length matches the body we send (stale Content-Length breaks POST JSON). */
|
|
24
|
+
const replayHeaderBlocklist = new Set([
|
|
25
|
+
'connection',
|
|
26
|
+
'content-length',
|
|
27
|
+
'keep-alive',
|
|
28
|
+
'proxy-authenticate',
|
|
29
|
+
'proxy-authorization',
|
|
30
|
+
'te',
|
|
31
|
+
'trailers',
|
|
32
|
+
'transfer-encoding',
|
|
33
|
+
'upgrade',
|
|
34
|
+
'host',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/** @param {{ inspectorAddr?: string }} opts */
|
|
38
|
+
export function inspectorHTTPBaseURL(opts) {
|
|
39
|
+
let addr = String(opts.inspectorAddr ?? '').trim();
|
|
40
|
+
if (!addr) addr = defaultInspectorAddr;
|
|
41
|
+
if (addr.startsWith('http://') || addr.startsWith('https://')) return addr;
|
|
42
|
+
if (addr.startsWith(':')) return `http://127.0.0.1${addr}`;
|
|
43
|
+
return `http://${addr}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** @param {string | undefined} themes */
|
|
47
|
+
function themeSeedFromOpts(themes) {
|
|
48
|
+
const t = String(themes ?? '')
|
|
49
|
+
.trim()
|
|
50
|
+
.toLowerCase();
|
|
51
|
+
if (t === 'terminal') return 'terminal';
|
|
52
|
+
return 'postman';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @param {string} host */
|
|
56
|
+
function isLoopbackHost(host) {
|
|
57
|
+
const h = String(host)
|
|
58
|
+
.toLowerCase()
|
|
59
|
+
.replace(/^\[|\]$/g, '')
|
|
60
|
+
.trim();
|
|
61
|
+
return h === 'localhost' || h === '127.0.0.1' || h === '::1';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** @param {URL} u */
|
|
65
|
+
function allowReplayURL(u) {
|
|
66
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
|
|
67
|
+
return isLoopbackHost(u.hostname);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @param {import('http').IncomingMessage} req */
|
|
71
|
+
function logReplayRequested(req) {
|
|
72
|
+
const v = String(req.headers['x-inspector-log-replay'] ?? '')
|
|
73
|
+
.trim()
|
|
74
|
+
.toLowerCase();
|
|
75
|
+
if (!v) return false;
|
|
76
|
+
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @param {Record<string, unknown> | null | undefined} h */
|
|
80
|
+
function cloneHeaderMap(h) {
|
|
81
|
+
if (!h || typeof h !== 'object') return {};
|
|
82
|
+
const out = {};
|
|
83
|
+
for (const [k, vals] of Object.entries(h)) {
|
|
84
|
+
out[k] = Array.isArray(vals) ? vals.map((v) => String(v)) : [String(vals)];
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @param {URL} u */
|
|
90
|
+
function pathForReplayLog(u) {
|
|
91
|
+
let p = u.pathname || '/';
|
|
92
|
+
if (!p) p = '/';
|
|
93
|
+
return u.search ? `${p}${u.search}` : p;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @param {import('undici').IncomingHttpHeaders} h */
|
|
97
|
+
function headersObjectFromUndici(h) {
|
|
98
|
+
/** @type {Record<string, string[]>} */
|
|
99
|
+
const out = {};
|
|
100
|
+
if (!h || typeof h !== 'object') return out;
|
|
101
|
+
for (const [k, v] of Object.entries(h)) {
|
|
102
|
+
if (v == null) continue;
|
|
103
|
+
if (Array.isArray(v)) out[k] = v.map((x) => String(x));
|
|
104
|
+
else out[k] = [String(v)];
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** @param {unknown} body */
|
|
110
|
+
function normalizeReplayBody(body) {
|
|
111
|
+
if (body == null || body === '') return '';
|
|
112
|
+
if (typeof body === 'string') return body;
|
|
113
|
+
if (typeof body === 'object' && body !== null && !Buffer.isBuffer(body)) {
|
|
114
|
+
try {
|
|
115
|
+
return JSON.stringify(body);
|
|
116
|
+
} catch {
|
|
117
|
+
return String(body);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return String(body);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Headers for the upstream fetch (no hop-by-hop / wrong length).
|
|
125
|
+
* @param {Record<string, unknown>} h from replay JSON
|
|
126
|
+
*/
|
|
127
|
+
function buildUpstreamHeaders(h) {
|
|
128
|
+
/** @type {Record<string, string | string[]>} */
|
|
129
|
+
const out = {};
|
|
130
|
+
const src = h && typeof h === 'object' ? h : {};
|
|
131
|
+
for (const [k, vals] of Object.entries(src)) {
|
|
132
|
+
if (replayHeaderBlocklist.has(k.toLowerCase())) continue;
|
|
133
|
+
const arr = Array.isArray(vals) ? vals : [vals];
|
|
134
|
+
const strs = arr.filter((v) => v != null).map((v) => String(v));
|
|
135
|
+
if (!strs.length) continue;
|
|
136
|
+
out[k] = strs.length === 1 ? strs[0] : strs;
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Express (and many servers) only parse JSON bodies when Content-Type is JSON.
|
|
143
|
+
* Captured replays often omit Content-Type or keep a wrong type (e.g. GET capture).
|
|
144
|
+
* @param {Record<string, string | string[]>} headers
|
|
145
|
+
* @param {string} method
|
|
146
|
+
* @param {string} bodyStr
|
|
147
|
+
*/
|
|
148
|
+
function finalizeUpstreamContentType(headers, method, bodyStr) {
|
|
149
|
+
if (!bodyStr.length || method === 'GET' || method === 'HEAD') return headers;
|
|
150
|
+
|
|
151
|
+
const trimmed = bodyStr.trim();
|
|
152
|
+
const looksJson =
|
|
153
|
+
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
|
154
|
+
(trimmed.startsWith('[') && trimmed.endsWith(']'));
|
|
155
|
+
|
|
156
|
+
/** @type {Record<string, string | string[]>} */
|
|
157
|
+
const out = { ...headers };
|
|
158
|
+
let ctKey = null;
|
|
159
|
+
for (const k of Object.keys(out)) {
|
|
160
|
+
if (k.toLowerCase() === 'content-type') {
|
|
161
|
+
ctKey = k;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!ctKey) {
|
|
167
|
+
out['Content-Type'] = looksJson
|
|
168
|
+
? 'application/json; charset=utf-8'
|
|
169
|
+
: 'text/plain; charset=utf-8';
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const raw = out[ctKey];
|
|
174
|
+
const ctVal = Array.isArray(raw) ? String(raw[0]) : String(raw);
|
|
175
|
+
if (looksJson && !ctVal.toLowerCase().includes('json')) {
|
|
176
|
+
delete out[ctKey];
|
|
177
|
+
out['Content-Type'] = 'application/json; charset=utf-8';
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** @param {import('stream').Readable} stream */
|
|
183
|
+
async function readStreamToBuffer(stream) {
|
|
184
|
+
const chunks = [];
|
|
185
|
+
for await (const chunk of stream) {
|
|
186
|
+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk);
|
|
187
|
+
}
|
|
188
|
+
return Buffer.concat(chunks);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* @param {boolean} logReplay
|
|
193
|
+
* @param {string} method
|
|
194
|
+
* @param {URL} u
|
|
195
|
+
* @param {Record<string, string[]>} headers
|
|
196
|
+
* @param {string} bodyStr
|
|
197
|
+
* @param {number} statusCode
|
|
198
|
+
* @param {Record<string, string[]>} respHeaders
|
|
199
|
+
* @param {Buffer} respBody
|
|
200
|
+
* @param {number} durationMs
|
|
201
|
+
*/
|
|
202
|
+
function recordReplay(
|
|
203
|
+
logReplay,
|
|
204
|
+
method,
|
|
205
|
+
u,
|
|
206
|
+
headers,
|
|
207
|
+
bodyStr,
|
|
208
|
+
statusCode,
|
|
209
|
+
respHeaders,
|
|
210
|
+
respBody,
|
|
211
|
+
durationMs,
|
|
212
|
+
) {
|
|
213
|
+
if (!logReplay) return;
|
|
214
|
+
addLog({
|
|
215
|
+
id: `req_${randomUUID()}`,
|
|
216
|
+
source: 'replay',
|
|
217
|
+
request: {
|
|
218
|
+
method,
|
|
219
|
+
path: pathForReplayLog(u),
|
|
220
|
+
body: Buffer.from(bodyStr, 'utf8').toString('base64'),
|
|
221
|
+
headers: cloneHeaderMap(headers),
|
|
222
|
+
},
|
|
223
|
+
response: {
|
|
224
|
+
statusCode,
|
|
225
|
+
headers: cloneHeaderMap(respHeaders),
|
|
226
|
+
body: respBody.length ? respBody.toString('base64') : '',
|
|
227
|
+
},
|
|
228
|
+
durationMs,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse) => Promise<void>}
|
|
234
|
+
*/
|
|
235
|
+
function createReplayHandler() {
|
|
236
|
+
return async (req, res) => {
|
|
237
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
238
|
+
if (req.method === 'OPTIONS') {
|
|
239
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
|
240
|
+
res.setHeader('Access-Control-Allow-Headers', `Content-Type, ${HeaderLogReplay}`);
|
|
241
|
+
res.writeHead(204);
|
|
242
|
+
res.end();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (req.method !== 'POST') {
|
|
246
|
+
res.setHeader('Content-Type', 'application/json');
|
|
247
|
+
res.writeHead(405);
|
|
248
|
+
res.end(JSON.stringify({ error: 'method not allowed' }));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const chunks = [];
|
|
253
|
+
for await (const c of req) chunks.push(c);
|
|
254
|
+
const raw = Buffer.concat(chunks);
|
|
255
|
+
if (raw.length > 10 << 20) {
|
|
256
|
+
res.setHeader('Content-Type', 'application/json');
|
|
257
|
+
res.writeHead(400);
|
|
258
|
+
res.end(JSON.stringify({ error: 'body too large' }));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** @type {{ method?: string; url?: string; headers?: Record<string, unknown>; body?: string }} */
|
|
263
|
+
let p;
|
|
264
|
+
try {
|
|
265
|
+
p = JSON.parse(raw.toString('utf8'));
|
|
266
|
+
} catch (e) {
|
|
267
|
+
res.setHeader('Content-Type', 'application/json');
|
|
268
|
+
res.writeHead(400);
|
|
269
|
+
res.end(
|
|
270
|
+
JSON.stringify({
|
|
271
|
+
error: `invalid JSON: ${e instanceof Error ? e.message : String(e)}`,
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const logReplay = logReplayRequested(req);
|
|
278
|
+
let method = String(p.method ?? 'GET')
|
|
279
|
+
.trim()
|
|
280
|
+
.toUpperCase();
|
|
281
|
+
if (!method) method = 'GET';
|
|
282
|
+
|
|
283
|
+
const localhostUrl = String(p.url ?? '').trim();
|
|
284
|
+
let u;
|
|
285
|
+
try {
|
|
286
|
+
u = new URL(localhostUrl);
|
|
287
|
+
} catch {
|
|
288
|
+
res.setHeader('Content-Type', 'application/json');
|
|
289
|
+
res.writeHead(400);
|
|
290
|
+
res.end(JSON.stringify({ error: 'invalid url' }));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (!u.host) {
|
|
294
|
+
res.setHeader('Content-Type', 'application/json');
|
|
295
|
+
res.writeHead(400);
|
|
296
|
+
res.end(JSON.stringify({ error: 'invalid url' }));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (!allowReplayURL(u)) {
|
|
300
|
+
res.setHeader('Content-Type', 'application/json');
|
|
301
|
+
res.writeHead(403);
|
|
302
|
+
res.end(JSON.stringify({ error: 'only http(s) URLs on localhost are allowed' }));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const h = p.headers && typeof p.headers === 'object' ? p.headers : {};
|
|
307
|
+
const bodyStr = normalizeReplayBody(p.body);
|
|
308
|
+
const upstreamHeaders = finalizeUpstreamContentType(
|
|
309
|
+
buildUpstreamHeaders(h),
|
|
310
|
+
method,
|
|
311
|
+
bodyStr,
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
const start = Date.now();
|
|
315
|
+
const ac = new AbortController();
|
|
316
|
+
const to = setTimeout(() => ac.abort(), 120_000);
|
|
317
|
+
|
|
318
|
+
res.setHeader('Content-Type', 'application/json');
|
|
319
|
+
|
|
320
|
+
/** @type {import('undici').RequestOptions} */
|
|
321
|
+
const reqOpts = {
|
|
322
|
+
method,
|
|
323
|
+
headers: upstreamHeaders,
|
|
324
|
+
signal: ac.signal,
|
|
325
|
+
};
|
|
326
|
+
if (bodyStr.length > 0 && method !== 'GET' && method !== 'HEAD') {
|
|
327
|
+
reqOpts.body = bodyStr;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
const { statusCode, headers: respHdrs, body: respStream } = await request(
|
|
332
|
+
localhostUrl,
|
|
333
|
+
reqOpts,
|
|
334
|
+
);
|
|
335
|
+
clearTimeout(to);
|
|
336
|
+
const dur = Date.now() - start;
|
|
337
|
+
const b = await readStreamToBuffer(respStream);
|
|
338
|
+
const slice = b.length > 10 << 20 ? b.subarray(0, 10 << 20) : b;
|
|
339
|
+
const headersOut = headersObjectFromUndici(respHdrs);
|
|
340
|
+
recordReplay(
|
|
341
|
+
logReplay,
|
|
342
|
+
method,
|
|
343
|
+
u,
|
|
344
|
+
cloneHeaderMap(h),
|
|
345
|
+
bodyStr,
|
|
346
|
+
statusCode,
|
|
347
|
+
headersOut,
|
|
348
|
+
slice,
|
|
349
|
+
dur,
|
|
350
|
+
);
|
|
351
|
+
res.writeHead(200);
|
|
352
|
+
res.end(
|
|
353
|
+
JSON.stringify({
|
|
354
|
+
statusCode,
|
|
355
|
+
headers: headersOut,
|
|
356
|
+
body: slice.toString('base64'),
|
|
357
|
+
durationMs: dur,
|
|
358
|
+
}),
|
|
359
|
+
);
|
|
360
|
+
} catch (e) {
|
|
361
|
+
clearTimeout(to);
|
|
362
|
+
const dur = Date.now() - start;
|
|
363
|
+
const errText = e instanceof Error ? e.message : String(e);
|
|
364
|
+
const hdrs = /** @type {Record<string, string[]>} */ ({});
|
|
365
|
+
recordReplay(
|
|
366
|
+
logReplay,
|
|
367
|
+
method,
|
|
368
|
+
u,
|
|
369
|
+
cloneHeaderMap(h),
|
|
370
|
+
bodyStr,
|
|
371
|
+
502,
|
|
372
|
+
hdrs,
|
|
373
|
+
Buffer.from(errText, 'utf8'),
|
|
374
|
+
dur,
|
|
375
|
+
);
|
|
376
|
+
res.writeHead(502);
|
|
377
|
+
res.end(JSON.stringify({ error: errText, durationMs: dur }));
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* @param {string} addr
|
|
384
|
+
* @returns {{ host?: string; port: number }}
|
|
385
|
+
*/
|
|
386
|
+
function parseListenAddr(addr) {
|
|
387
|
+
const s = String(addr).trim();
|
|
388
|
+
if (!s) return { port: 4040 };
|
|
389
|
+
if (s.startsWith('http://') || s.startsWith('https://')) {
|
|
390
|
+
const u = new URL(s);
|
|
391
|
+
const port = Number(u.port);
|
|
392
|
+
return {
|
|
393
|
+
host: u.hostname || 'localhost',
|
|
394
|
+
port: Number.isFinite(port) && port > 0 ? port : u.protocol === 'https:' ? 443 : 80,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
if (s.startsWith(':')) {
|
|
398
|
+
const port = Number(s.slice(1));
|
|
399
|
+
return { port: Number.isFinite(port) ? port : 4040 };
|
|
400
|
+
}
|
|
401
|
+
const lastColon = s.lastIndexOf(':');
|
|
402
|
+
if (lastColon > 0) {
|
|
403
|
+
const host = s.slice(0, lastColon);
|
|
404
|
+
const port = Number(s.slice(lastColon + 1));
|
|
405
|
+
if (Number.isFinite(port)) return { host, port };
|
|
406
|
+
}
|
|
407
|
+
if (/^\d+$/.test(s)) return { port: Number(s) };
|
|
408
|
+
return { port: 4040 };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* @param {{ inspector?: boolean; themes?: string; inspectorAddr?: string }} opts
|
|
413
|
+
* @param {string} localPort digits — forwarded app port for default replay base in UI
|
|
414
|
+
* @returns {() => void}
|
|
415
|
+
*/
|
|
416
|
+
export function startInspector(opts, localPort) {
|
|
417
|
+
if (opts.inspector === false) {
|
|
418
|
+
return () => {};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const themeSeed = themeSeedFromOpts(opts.themes);
|
|
422
|
+
let addr = String(opts.inspectorAddr ?? '').trim();
|
|
423
|
+
if (!addr) addr = defaultInspectorAddr;
|
|
424
|
+
|
|
425
|
+
const localAppPort = String(localPort ?? '')
|
|
426
|
+
.trim()
|
|
427
|
+
.replace(/^:/, '') || '8080';
|
|
428
|
+
|
|
429
|
+
/** @type {Set<import('ws').WebSocket>} */
|
|
430
|
+
const viewers = new Set();
|
|
431
|
+
|
|
432
|
+
function broadcast(entry) {
|
|
433
|
+
const msg = JSON.stringify({
|
|
434
|
+
eventType: 'request',
|
|
435
|
+
payload: entry,
|
|
436
|
+
});
|
|
437
|
+
for (const ws of viewers) {
|
|
438
|
+
try {
|
|
439
|
+
ws.send(msg);
|
|
440
|
+
} catch {
|
|
441
|
+
viewers.delete(ws);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
setInspectorSubscriber(broadcast);
|
|
447
|
+
|
|
448
|
+
const replay = createReplayHandler();
|
|
449
|
+
|
|
450
|
+
function serveText(pathname, body, contentType) {
|
|
451
|
+
return (req, res) => {
|
|
452
|
+
if (req.method !== 'GET' || new URL(req.url || '/', 'http://localhost').pathname !== pathname) {
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
res.setHeader('Content-Type', contentType);
|
|
456
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
457
|
+
res.end(body);
|
|
458
|
+
return true;
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const tryInspectorCSS = serveText('/inspector.css', inspectorCSS, 'text/css; charset=utf-8');
|
|
463
|
+
const tryThemePostman = serveText(
|
|
464
|
+
'/theme-postman.css',
|
|
465
|
+
themePostmanCSS,
|
|
466
|
+
'text/css; charset=utf-8',
|
|
467
|
+
);
|
|
468
|
+
const tryThemeTerminal = serveText(
|
|
469
|
+
'/theme-terminal.css',
|
|
470
|
+
themeTerminalCSS,
|
|
471
|
+
'text/css; charset=utf-8',
|
|
472
|
+
);
|
|
473
|
+
const tryIndexJS = serveText('/index.js', indexJS, 'application/javascript; charset=utf-8');
|
|
474
|
+
|
|
475
|
+
const server = http.createServer((req, res) => {
|
|
476
|
+
const url = new URL(req.url || '/', 'http://localhost');
|
|
477
|
+
const pathname = url.pathname;
|
|
478
|
+
|
|
479
|
+
if (tryInspectorCSS(req, res)) return;
|
|
480
|
+
if (tryThemePostman(req, res)) return;
|
|
481
|
+
if (tryThemeTerminal(req, res)) return;
|
|
482
|
+
if (tryIndexJS(req, res)) return;
|
|
483
|
+
|
|
484
|
+
if (req.method === 'GET' && pathname === '/') {
|
|
485
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
486
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
487
|
+
const page = inspectorHTML
|
|
488
|
+
.replace(/__NT_PORT_VALUE__/g, localAppPort)
|
|
489
|
+
.replace(/__THEME_SEED__/g, themeSeed);
|
|
490
|
+
res.end(page);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (req.method === 'GET' && pathname === '/logs') {
|
|
494
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
495
|
+
res.setHeader('Content-Type', 'application/json');
|
|
496
|
+
res.end(JSON.stringify(getLogs()));
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (req.method === 'GET' && pathname === '/log') {
|
|
500
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
501
|
+
res.setHeader('Content-Type', 'application/json');
|
|
502
|
+
const id = url.searchParams.get('id');
|
|
503
|
+
if (!id) {
|
|
504
|
+
res.writeHead(400);
|
|
505
|
+
res.end(JSON.stringify({ error: 'missing id' }));
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const log = getLogById(id);
|
|
509
|
+
if (!log) {
|
|
510
|
+
res.writeHead(404);
|
|
511
|
+
res.end(JSON.stringify({ error: 'log not found' }));
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
res.end(JSON.stringify(log));
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (pathname === '/replay') {
|
|
518
|
+
replay(req, res).catch(() => {
|
|
519
|
+
try {
|
|
520
|
+
if (!res.headersSent) res.writeHead(500);
|
|
521
|
+
res.end(JSON.stringify({ error: 'replay failed' }));
|
|
522
|
+
} catch {
|
|
523
|
+
/* ignore */
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
res.writeHead(404);
|
|
529
|
+
res.end();
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
server.maxHeadersCount = 2000;
|
|
533
|
+
|
|
534
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
535
|
+
wss.on('connection', (ws) => {
|
|
536
|
+
viewers.add(ws);
|
|
537
|
+
ws.on('close', () => viewers.delete(ws));
|
|
538
|
+
ws.on('error', () => viewers.delete(ws));
|
|
539
|
+
ws.on('message', () => {});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
const ingestWss = new WebSocketServer({ noServer: true });
|
|
543
|
+
ingestWss.on('connection', (ws) => {
|
|
544
|
+
ws.on('message', (data) => {
|
|
545
|
+
try {
|
|
546
|
+
const text = typeof data === 'string' ? data : data.toString('utf8');
|
|
547
|
+
const ev = JSON.parse(text);
|
|
548
|
+
addLog(ev);
|
|
549
|
+
} catch {
|
|
550
|
+
/* ignore */
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
server.on('upgrade', (request, socket, head) => {
|
|
556
|
+
const path = new URL(request.url || '/', 'http://127.0.0.1').pathname;
|
|
557
|
+
if (path === '/ws') {
|
|
558
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
559
|
+
wss.emit('connection', ws, request);
|
|
560
|
+
});
|
|
561
|
+
} else if (path === '/ingest') {
|
|
562
|
+
ingestWss.handleUpgrade(request, socket, head, (ws) => {
|
|
563
|
+
ingestWss.emit('connection', ws, request);
|
|
564
|
+
});
|
|
565
|
+
} else {
|
|
566
|
+
socket.destroy();
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
const listenOpts = parseListenAddr(addr);
|
|
571
|
+
server.listen(listenOpts, () => {
|
|
572
|
+
console.error(`nodetunnel: traffic inspector → ${inspectorHTTPBaseURL(opts)}`);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
server.on('error', (err) => {
|
|
576
|
+
console.error(`nodetunnel: inspector stopped: ${err.message}`);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
return () => {
|
|
580
|
+
setInspectorSubscriber(null);
|
|
581
|
+
for (const ws of viewers) {
|
|
582
|
+
try {
|
|
583
|
+
ws.close();
|
|
584
|
+
} catch {
|
|
585
|
+
/* ignore */
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
viewers.clear();
|
|
589
|
+
try {
|
|
590
|
+
wss.close();
|
|
591
|
+
} catch {
|
|
592
|
+
/* ignore */
|
|
593
|
+
}
|
|
594
|
+
try {
|
|
595
|
+
ingestWss.close();
|
|
596
|
+
} catch {
|
|
597
|
+
/* ignore */
|
|
598
|
+
}
|
|
599
|
+
server.close();
|
|
600
|
+
};
|
|
601
|
+
}
|
|
@@ -57,6 +57,19 @@ export function getLogs() {
|
|
|
57
57
|
return requestLogs.slice();
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* @param {string} id
|
|
62
|
+
* @returns {Record<string, unknown> | null}
|
|
63
|
+
*/
|
|
64
|
+
export function getLogById(id) {
|
|
65
|
+
const want = String(id);
|
|
66
|
+
for (let i = requestLogs.length - 1; i >= 0; i--) {
|
|
67
|
+
const e = requestLogs[i];
|
|
68
|
+
if (e && String(/** @type {{ id?: string }} */ (e).id) === want) return e;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
60
73
|
/**
|
|
61
74
|
* @returns {string}
|
|
62
75
|
*/
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/* Postman-inspired dark UI (orange accent, neutral grays). */
|
|
2
|
+
html[data-theme="postman"] {
|
|
3
|
+
--inspector-font-ui: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
4
|
+
--inspector-font-mono: ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
|
5
|
+
--inspector-bg: #1e1e1e;
|
|
6
|
+
--inspector-panel: #252526;
|
|
7
|
+
--inspector-border: #3c3c3c;
|
|
8
|
+
--inspector-text: #e0e0e0;
|
|
9
|
+
--inspector-muted: #858585;
|
|
10
|
+
--inspector-path: #cccccc;
|
|
11
|
+
--inspector-accent: #f97316;
|
|
12
|
+
--inspector-accent-hover: #ea580c;
|
|
13
|
+
--inspector-accent-contrast: #ffffff;
|
|
14
|
+
--inspector-get: #22c55e;
|
|
15
|
+
--inspector-post: #eab308;
|
|
16
|
+
--inspector-put: #38bdf8;
|
|
17
|
+
--inspector-patch: #a78bfa;
|
|
18
|
+
--inspector-del: #ef4444;
|
|
19
|
+
--inspector-input-bg: #1e1e1e;
|
|
20
|
+
--inspector-code-bg: #121212;
|
|
21
|
+
--inspector-code-text: #d4d4d4;
|
|
22
|
+
--inspector-row-hover: #2a2d2e;
|
|
23
|
+
--inspector-row-active: #37373d;
|
|
24
|
+
--inspector-list-border: #2a2a2a;
|
|
25
|
+
--inspector-resizer-hover: #404040;
|
|
26
|
+
--inspector-resizer-track: #2d2d2d;
|
|
27
|
+
--inspector-origin-bg: #2d2419;
|
|
28
|
+
--inspector-origin-fg: #fdba74;
|
|
29
|
+
--inspector-btn-modify-hover: #333333;
|
|
30
|
+
--inspector-badge-bg: #3d3d3d;
|
|
31
|
+
--inspector-badge-text: #aaaaaa;
|
|
32
|
+
--inspector-table-border: #2a2a2a;
|
|
33
|
+
--inspector-status-ok-bg: #1a3a2a;
|
|
34
|
+
--inspector-status-ok-text: #49cc90;
|
|
35
|
+
--inspector-status-err-bg: #3a1a1a;
|
|
36
|
+
--inspector-status-err-text: #f87171;
|
|
37
|
+
--inspector-json-string: #9ecbff;
|
|
38
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/* Retro terminal: green/amber on black, monospace everywhere. */
|
|
2
|
+
html[data-theme="terminal"] {
|
|
3
|
+
--inspector-font-ui: ui-monospace, "SF Mono", Menlo, "Cascadia Code", Consolas, monospace;
|
|
4
|
+
--inspector-font-mono: ui-monospace, "SF Mono", Menlo, "Cascadia Code", Consolas, monospace;
|
|
5
|
+
--inspector-bg: #0a0d0a;
|
|
6
|
+
--inspector-panel: #0f140f;
|
|
7
|
+
--inspector-border: #1e3d28;
|
|
8
|
+
--inspector-text: #b4f0b4;
|
|
9
|
+
--inspector-muted: #5a8f6a;
|
|
10
|
+
--inspector-path: #7fd87f;
|
|
11
|
+
--inspector-accent: #ffb020;
|
|
12
|
+
--inspector-accent-hover: #ffc040;
|
|
13
|
+
--inspector-accent-contrast: #0a0d0a;
|
|
14
|
+
--inspector-get: #00ff66;
|
|
15
|
+
--inspector-post: #ffcc00;
|
|
16
|
+
--inspector-put: #00ccff;
|
|
17
|
+
--inspector-patch: #cc88ff;
|
|
18
|
+
--inspector-del: #ff5555;
|
|
19
|
+
--inspector-input-bg: #050805;
|
|
20
|
+
--inspector-code-bg: #050805;
|
|
21
|
+
--inspector-code-text: #9fef9f;
|
|
22
|
+
--inspector-row-hover: #122016;
|
|
23
|
+
--inspector-row-active: #1a3020;
|
|
24
|
+
--inspector-list-border: #152818;
|
|
25
|
+
--inspector-resizer-hover: #1a5030;
|
|
26
|
+
--inspector-resizer-track: #0f1f14;
|
|
27
|
+
--inspector-origin-bg: #1a2808;
|
|
28
|
+
--inspector-origin-fg: #ffdd88;
|
|
29
|
+
--inspector-btn-modify-hover: #122016;
|
|
30
|
+
--inspector-badge-bg: #1a3020;
|
|
31
|
+
--inspector-badge-text: #7fd87f;
|
|
32
|
+
--inspector-table-border: #152818;
|
|
33
|
+
--inspector-status-ok-bg: #0d2818;
|
|
34
|
+
--inspector-status-ok-text: #00ff66;
|
|
35
|
+
--inspector-status-err-bg: #280d0d;
|
|
36
|
+
--inspector-status-err-text: #ff8888;
|
|
37
|
+
--inspector-json-string: #9fef9f;
|
|
38
|
+
}
|