@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
|
@@ -1,266 +0,0 @@
|
|
|
1
|
-
import http from 'node:http';
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { dirname, join } from 'node:path';
|
|
5
|
-
import { WebSocketServer } from 'ws';
|
|
6
|
-
import { getLogs, setInspectorSubscriber } from './logstore.js';
|
|
7
|
-
|
|
8
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
-
const INSPECTOR_PAGE_HTML = readFileSync(join(__dirname, 'inspector-page.html'), 'utf8');
|
|
10
|
-
|
|
11
|
-
const defaultInspectorAddr = ':4040';
|
|
12
|
-
|
|
13
|
-
/** @param {{ inspectorAddr?: string }} opts */
|
|
14
|
-
export function inspectorHTTPBaseURL(opts) {
|
|
15
|
-
let addr = String(opts.inspectorAddr ?? '').trim();
|
|
16
|
-
if (!addr) addr = defaultInspectorAddr;
|
|
17
|
-
if (addr.startsWith('http://') || addr.startsWith('https://')) return addr;
|
|
18
|
-
if (addr.startsWith(':')) return `http://127.0.0.1${addr}`;
|
|
19
|
-
return `http://${addr}`;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** @param {string | undefined} s */
|
|
23
|
-
function normalizeInspectorTheme(s) {
|
|
24
|
-
const t = String(s ?? '')
|
|
25
|
-
.trim()
|
|
26
|
-
.toLowerCase();
|
|
27
|
-
if (t === 'terminal') return 'theme-terminal';
|
|
28
|
-
if (t === 'light') return 'theme-light';
|
|
29
|
-
if (t === 'dark' || t === '') return 'theme-dark';
|
|
30
|
-
return 'theme-dark';
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const replayHeaderBlocklist = new Set([
|
|
34
|
-
'connection',
|
|
35
|
-
'keep-alive',
|
|
36
|
-
'proxy-authenticate',
|
|
37
|
-
'proxy-authorization',
|
|
38
|
-
'te',
|
|
39
|
-
'trailers',
|
|
40
|
-
'transfer-encoding',
|
|
41
|
-
'upgrade',
|
|
42
|
-
'host',
|
|
43
|
-
]);
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* @param {string} localPort
|
|
47
|
-
* @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse) => Promise<void>}
|
|
48
|
-
*/
|
|
49
|
-
function createReplayHandler(localPort) {
|
|
50
|
-
return async (req, res) => {
|
|
51
|
-
res.setHeader('Content-Type', 'application/json');
|
|
52
|
-
if (req.method !== 'POST') {
|
|
53
|
-
res.writeHead(405);
|
|
54
|
-
res.end(JSON.stringify({ error: 'use POST' }));
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
const chunks = [];
|
|
58
|
-
for await (const c of req) chunks.push(c);
|
|
59
|
-
const raw = Buffer.concat(chunks);
|
|
60
|
-
if (raw.length > 10 << 20) {
|
|
61
|
-
res.writeHead(400);
|
|
62
|
-
res.end(JSON.stringify({ error: 'body too large' }));
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
let payload;
|
|
66
|
-
try {
|
|
67
|
-
payload = JSON.parse(raw.toString('utf8'));
|
|
68
|
-
} catch (e) {
|
|
69
|
-
res.writeHead(400);
|
|
70
|
-
res.end(JSON.stringify({ error: `invalid JSON: ${e instanceof Error ? e.message : String(e)}` }));
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
let method = String(payload.method ?? 'GET')
|
|
74
|
-
.trim()
|
|
75
|
-
.toUpperCase();
|
|
76
|
-
if (!method) method = 'GET';
|
|
77
|
-
let path = String(payload.path ?? '/').trim();
|
|
78
|
-
if (!path) path = '/';
|
|
79
|
-
if (!path.startsWith('/')) path = `/${path}`;
|
|
80
|
-
const target = `http://127.0.0.1:${localPort}${path}`;
|
|
81
|
-
const headers = new Headers();
|
|
82
|
-
const h = payload.headers && typeof payload.headers === 'object' ? payload.headers : {};
|
|
83
|
-
for (const [k, vals] of Object.entries(h)) {
|
|
84
|
-
if (replayHeaderBlocklist.has(k.toLowerCase())) continue;
|
|
85
|
-
const arr = Array.isArray(vals) ? vals : [vals];
|
|
86
|
-
for (const v of arr) {
|
|
87
|
-
if (v != null) headers.append(k, String(v));
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
/** @type {{ method: string; headers: Headers; body?: string; signal?: AbortSignal }} */
|
|
91
|
-
const init = { method, headers };
|
|
92
|
-
const bodyStr = payload.body != null ? String(payload.body) : '';
|
|
93
|
-
// Forward any non-empty body for arbitrary methods (DELETE, PUT, PATCH, POST, etc.).
|
|
94
|
-
// The Fetch API rejects a body on GET and HEAD only — match that so replay works for the rest.
|
|
95
|
-
if (bodyStr.length > 0 && method !== 'GET' && method !== 'HEAD') {
|
|
96
|
-
init.body = bodyStr;
|
|
97
|
-
}
|
|
98
|
-
const ac = new AbortController();
|
|
99
|
-
const to = setTimeout(() => ac.abort(), 60_000);
|
|
100
|
-
try {
|
|
101
|
-
const resp = await fetch(target, { ...init, signal: ac.signal });
|
|
102
|
-
clearTimeout(to);
|
|
103
|
-
const b = Buffer.from(await resp.arrayBuffer());
|
|
104
|
-
const slice = b.length > 10 << 20 ? b.subarray(0, 10 << 20) : b;
|
|
105
|
-
const bodyOut = slice.toString('utf8');
|
|
106
|
-
/** @type {Record<string, string[]>} */
|
|
107
|
-
const headersOut = {};
|
|
108
|
-
resp.headers.forEach((value, key) => {
|
|
109
|
-
const canon = key;
|
|
110
|
-
if (!headersOut[canon]) headersOut[canon] = [];
|
|
111
|
-
headersOut[canon].push(value);
|
|
112
|
-
});
|
|
113
|
-
res.writeHead(200);
|
|
114
|
-
res.end(
|
|
115
|
-
JSON.stringify({
|
|
116
|
-
status: resp.status,
|
|
117
|
-
headers: headersOut,
|
|
118
|
-
body: bodyOut,
|
|
119
|
-
}),
|
|
120
|
-
);
|
|
121
|
-
} catch (e) {
|
|
122
|
-
clearTimeout(to);
|
|
123
|
-
res.writeHead(502);
|
|
124
|
-
res.end(JSON.stringify({ error: String(e instanceof Error ? e.message : e) }));
|
|
125
|
-
}
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* @param {string} addr
|
|
131
|
-
* @returns {{ host?: string; port: number }}
|
|
132
|
-
*/
|
|
133
|
-
function parseListenAddr(addr) {
|
|
134
|
-
const s = String(addr).trim();
|
|
135
|
-
if (!s) return { port: 4040 };
|
|
136
|
-
if (s.startsWith('http://') || s.startsWith('https://')) {
|
|
137
|
-
const u = new URL(s);
|
|
138
|
-
const port = Number(u.port);
|
|
139
|
-
return {
|
|
140
|
-
host: u.hostname || 'localhost',
|
|
141
|
-
port: Number.isFinite(port) && port > 0 ? port : u.protocol === 'https:' ? 443 : 80,
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
if (s.startsWith(':')) {
|
|
145
|
-
const port = Number(s.slice(1));
|
|
146
|
-
return { port: Number.isFinite(port) ? port : 4040 };
|
|
147
|
-
}
|
|
148
|
-
const lastColon = s.lastIndexOf(':');
|
|
149
|
-
if (lastColon > 0) {
|
|
150
|
-
const host = s.slice(0, lastColon);
|
|
151
|
-
const port = Number(s.slice(lastColon + 1));
|
|
152
|
-
if (Number.isFinite(port)) return { host, port };
|
|
153
|
-
}
|
|
154
|
-
if (/^\d+$/.test(s)) return { port: Number(s) };
|
|
155
|
-
return { port: 4040 };
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* @param {{ inspector?: boolean; themes?: string; inspectorAddr?: string }} opts
|
|
160
|
-
* @param {string} localPort
|
|
161
|
-
* @returns {() => void}
|
|
162
|
-
*/
|
|
163
|
-
export function startInspector(opts, localPort) {
|
|
164
|
-
if (opts.inspector === false) {
|
|
165
|
-
return () => {};
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const themeClass = normalizeInspectorTheme(opts.themes);
|
|
169
|
-
let addr = String(opts.inspectorAddr ?? '').trim();
|
|
170
|
-
if (!addr) addr = defaultInspectorAddr;
|
|
171
|
-
|
|
172
|
-
/** @type {Set<import('ws').WebSocket>} */
|
|
173
|
-
const clients = new Set();
|
|
174
|
-
|
|
175
|
-
function broadcast(entry) {
|
|
176
|
-
const msg = JSON.stringify(entry);
|
|
177
|
-
for (const ws of clients) {
|
|
178
|
-
try {
|
|
179
|
-
ws.send(msg);
|
|
180
|
-
} catch {
|
|
181
|
-
clients.delete(ws);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
setInspectorSubscriber(broadcast);
|
|
187
|
-
|
|
188
|
-
const replay = createReplayHandler(localPort);
|
|
189
|
-
|
|
190
|
-
const server = http.createServer((req, res) => {
|
|
191
|
-
const url = new URL(req.url || '/', 'http://localhost');
|
|
192
|
-
const pathname = url.pathname;
|
|
193
|
-
if (req.method === 'GET' && pathname === '/') {
|
|
194
|
-
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
195
|
-
const page = INSPECTOR_PAGE_HTML.replace('__THEME_CLASS__', themeClass);
|
|
196
|
-
res.end(page);
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
if (req.method === 'GET' && pathname === '/logs') {
|
|
200
|
-
res.setHeader('Content-Type', 'application/json');
|
|
201
|
-
res.end(JSON.stringify(getLogs(), null, 2));
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
if (req.method === 'POST' && pathname === '/replay') {
|
|
205
|
-
replay(req, res).catch(() => {
|
|
206
|
-
try {
|
|
207
|
-
if (!res.headersSent) res.writeHead(500);
|
|
208
|
-
res.end(JSON.stringify({ error: 'replay failed' }));
|
|
209
|
-
} catch {
|
|
210
|
-
/* ignore */
|
|
211
|
-
}
|
|
212
|
-
});
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
res.writeHead(404);
|
|
216
|
-
res.end();
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
server.maxHeadersCount = 2000;
|
|
220
|
-
|
|
221
|
-
const wss = new WebSocketServer({ noServer: true });
|
|
222
|
-
wss.on('connection', (ws) => {
|
|
223
|
-
clients.add(ws);
|
|
224
|
-
ws.on('close', () => clients.delete(ws));
|
|
225
|
-
ws.on('error', () => clients.delete(ws));
|
|
226
|
-
ws.on('message', () => {});
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
server.on('upgrade', (request, socket, head) => {
|
|
230
|
-
const path = new URL(request.url || '/', 'http://127.0.0.1').pathname;
|
|
231
|
-
if (path === '/ws') {
|
|
232
|
-
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
233
|
-
wss.emit('connection', ws, request);
|
|
234
|
-
});
|
|
235
|
-
} else {
|
|
236
|
-
socket.destroy();
|
|
237
|
-
}
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
const listenOpts = parseListenAddr(addr);
|
|
241
|
-
server.listen(listenOpts, () => {
|
|
242
|
-
console.error(`nodetunnel: traffic inspector → ${inspectorHTTPBaseURL(opts)}`);
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
server.on('error', (err) => {
|
|
246
|
-
console.error(`nodetunnel: inspector stopped: ${err.message}`);
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
return () => {
|
|
250
|
-
setInspectorSubscriber(null);
|
|
251
|
-
for (const ws of clients) {
|
|
252
|
-
try {
|
|
253
|
-
ws.close();
|
|
254
|
-
} catch {
|
|
255
|
-
/* ignore */
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
clients.clear();
|
|
259
|
-
try {
|
|
260
|
-
wss.close();
|
|
261
|
-
} catch {
|
|
262
|
-
/* ignore */
|
|
263
|
-
}
|
|
264
|
-
server.close();
|
|
265
|
-
};
|
|
266
|
-
}
|