@dpkrn/nodetunnel 1.0.8 → 1.0.9

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/README.md CHANGED
@@ -9,6 +9,7 @@ Give your **local** HTTP server a **public URL** from Node.js — useful for web
9
9
  - **No separate tunnel process** — call one function from your app.
10
10
  - **Works with your existing server** — Express, Fastify, or plain `http`.
11
11
  - **Simple API** — you get a public `url` and a `stop()` when you are done.
12
+ - **Optional traffic inspector** — local dashboard on loopback to browse captures, replay requests, modify and pick a theme (see below).
12
13
 
13
14
  ---
14
15
 
@@ -83,6 +84,72 @@ Run with `node app.js`. Open the printed URL in a browser or share it for webhoo
83
84
 
84
85
  ---
85
86
 
87
+ ## Traffic inspector (local dashboard)
88
+
89
+ When enabled (default), nodetunnel starts a small **HTTP server on your machine** (default `http://localhost:4040`) with:
90
+
91
+ - **Live traffic** — requests proxied through the tunnel appear in the UI (WebSocket updates).
92
+ - **History** — recent captures kept in memory (configurable count); reload the page to fetch `/logs`.
93
+ - **Inspect** — request/response headers and bodies for each capture.
94
+ - **Modify** — modify request header/path and can replay.
95
+ - **Replay** — send a capture again to your local app, or edit method/path/headers/body and replay (aligned with the **gotunnel** inspector behavior).
96
+
97
+ The startup banner prints **Inspector →** with that URL. Set `inspector: false` if you do not want the UI or an extra listen port.
98
+
99
+ ### Themes
100
+
101
+ The UI supports three built-in palettes via `themes` in `startTunnel` options:
102
+
103
+ | Value | Appearance |
104
+ |--------|----------------|
105
+ | **`"dark"`** (default) | Dark panels, blue accents — similar to GitHub-dark style. |
106
+ | **`"terminal"`** | Green-on-black “CRT” / terminal aesthetic, monospace UI font. |
107
+ | **`"light"`** | Light gray/white background, high-contrast text for bright environments. |
108
+
109
+ ### Example: themes and inspector options
110
+
111
+ ```js
112
+ import http from "node:http";
113
+ import { startTunnel } from "@dpkrn/nodetunnel";
114
+
115
+ const PORT = 3000;
116
+
117
+ const server = http.createServer((req, res) => {
118
+ res.setHeader("Content-Type", "text/plain");
119
+ res.end("hello\n");
120
+ });
121
+
122
+ server.listen(PORT, async () => {
123
+ const { url, stop } = await startTunnel(String(PORT), {
124
+ // Inspector (defaults: enabled, :4040, dark theme, 100 logs)
125
+ inspector: true,
126
+ inspectorAddr: ":4040",
127
+ themes: "terminal", // try: "dark" | "terminal" | "light"
128
+ logs: 100,
129
+ });
130
+
131
+ // console.log("Public:", url);
132
+ // Open the Inspector URL from stderr in a browser (e.g. http://127.0.0.1:4040)
133
+
134
+ process.once("SIGINT", () => {
135
+ stop();
136
+ server.close(() => process.exit(0));
137
+ });
138
+ });
139
+ ```
140
+
141
+ ### Example: tunnel only (no inspector)
142
+
143
+ ```js
144
+ import { startTunnel } from "@dpkrn/nodetunnel";
145
+
146
+ const { url, stop } = await startTunnel("8080", {
147
+ inspector: false,
148
+ });
149
+ ```
150
+
151
+ ---
152
+
86
153
  ## Express (same idea)
87
154
 
88
155
  ```js
@@ -117,7 +184,7 @@ app.listen(PORT, async () => {
117
184
  | Argument | Description |
118
185
  |----------|-------------|
119
186
  | `port` | String, e.g. `"8080"` — must match the port your HTTP server uses. |
120
- | `options` | Optional. `{ host?: string, serverPort?: number }` where to reach your tunnel server (defaults: `localhost` and `9000`). |
187
+ | `options` | Optional. Objectsee **Options** below (tunnel server address, inspector, themes, etc.). |
121
188
 
122
189
  **Returns:** `{ url, stop }`
123
190
 
@@ -128,6 +195,17 @@ app.listen(PORT, async () => {
128
195
 
129
196
  Errors **reject** the promise — use `try/catch`.
130
197
 
198
+ ### Options (`startTunnel` second argument)
199
+
200
+ | Field | Type | Default | Description |
201
+ |-------|------|---------|-------------|
202
+ | `host` | `string` | `'clickly.cv'` | Hostname of the tunnel **control** server. |
203
+ | `serverPort` | `number` | `9000` | TCP port of the tunnel server. |
204
+ | `inspector` | `boolean` | `true` | If `true`, start the local traffic inspector UI (see [Traffic inspector](#traffic-inspector-local-dashboard)). If `false`, no extra HTTP server and no Inspector line in the banner. |
205
+ | `themes` | `string` | `'dark'` | Inspector palette: `'dark'`, `'terminal'`, or `'light'`. |
206
+ | `logs` | `number` | `100` | Maximum number of request/response captures kept in memory for the inspector. |
207
+ | `inspectorAddr` | `string` | `':4040'` | Listen address for the inspector (e.g. `':4040'`, `'127.0.0.1:9090'`). Display URL follows the same rules as the public banner. |
208
+
131
209
  ---
132
210
 
133
211
  ## Troubleshooting
@@ -1,5 +1,5 @@
1
1
  import express from "express";
2
- import { startTunnel } from "@dpkrn/nodetunnel";
2
+ import { startTunnel } from "../../pkg/tunnel/tunnel.js";
3
3
 
4
4
 
5
5
  const app = express();
@@ -27,7 +27,7 @@ app.get("/", async (req, res) => {
27
27
  });
28
28
 
29
29
  app.listen(PORT, async () => {
30
- console.log(`listening on http://127.0.0.1:${PORT}`);
30
+ console.log(`listening on http://localhost:${PORT}`);
31
31
  try {
32
32
  const { url, stop } = await startTunnel(String(PORT));
33
33
  process.once("SIGINT", () => {
@@ -0,0 +1,482 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
6
+ <title>Tunnel traffic — dev</title>
7
+ <style>
8
+ /* Theme tokens: dark (default), terminal (green CRT), light */
9
+ body.theme-dark {
10
+ --bg: #0d1117;
11
+ --panel: #161b22;
12
+ --border: #30363d;
13
+ --text: #e6edf3;
14
+ --muted: #8b949e;
15
+ --accent: #58a6ff;
16
+ --green: #3fb950;
17
+ --danger: #f85149;
18
+ --row-alt: #21262d;
19
+ --log-card: #1c2128;
20
+ --kv-td: #1c2128;
21
+ --kv-input-bg: #0d1117;
22
+ --btn-hover: #262c36;
23
+ --btn-primary: #238636;
24
+ --btn-primary-border: #2ea043;
25
+ --btn-primary-hover: #2ea043;
26
+ --selected-ring: #388bfd;
27
+ --err-bg: rgba(248, 81, 73, 0.13);
28
+ --font-ui: ui-sans-serif, system-ui, -apple-system, sans-serif;
29
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
30
+ }
31
+ body.theme-terminal {
32
+ --bg: #070807;
33
+ --panel: #0a0f0a;
34
+ --border: #1e4a28;
35
+ --text: #c8f0c8;
36
+ --muted: #5a8a5a;
37
+ --accent: #00ff88;
38
+ --green: #39ff14;
39
+ --danger: #ff6b6b;
40
+ --row-alt: #0f1810;
41
+ --log-card: #0c120d;
42
+ --kv-td: #080d09;
43
+ --kv-input-bg: #050805;
44
+ --btn-hover: #142818;
45
+ --btn-primary: #1a6b2e;
46
+ --btn-primary-border: #39ff14;
47
+ --btn-primary-hover: #228b3a;
48
+ --selected-ring: #39ff14;
49
+ --err-bg: rgba(255, 107, 107, 0.12);
50
+ --font-ui: "JetBrains Mono", "SF Mono", "Cascadia Mono", "Cascadia Code", Consolas, monospace;
51
+ --font-mono: "JetBrains Mono", "SF Mono", "Cascadia Mono", Menlo, monospace;
52
+ }
53
+ body.theme-light {
54
+ --bg: #f6f8fa;
55
+ --panel: #ffffff;
56
+ --border: #d0d7de;
57
+ --text: #1f2328;
58
+ --muted: #656d76;
59
+ --accent: #0969da;
60
+ --green: #1a7f37;
61
+ --danger: #cf222e;
62
+ --row-alt: #f3f4f6;
63
+ --log-card: #ffffff;
64
+ --kv-td: #f6f8fa;
65
+ --kv-input-bg: #ffffff;
66
+ --btn-hover: #eaeef2;
67
+ --btn-primary: #2da44e;
68
+ --btn-primary-border: #2da44e;
69
+ --btn-primary-hover: #2c974b;
70
+ --selected-ring: #0969da;
71
+ --err-bg: rgba(207, 34, 46, 0.08);
72
+ --font-ui: ui-sans-serif, system-ui, -apple-system, sans-serif;
73
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
74
+ }
75
+ * { box-sizing: border-box; }
76
+ body {
77
+ font-family: var(--font-ui);
78
+ margin: 0; padding: 1rem 1.25rem 2rem;
79
+ background: var(--bg); color: var(--text); min-height: 100vh;
80
+ }
81
+ body.theme-terminal {
82
+ text-shadow: 0 0 1px rgba(57, 255, 20, 0.15);
83
+ }
84
+ header { max-width: 1200px; margin: 0 auto 1rem; }
85
+ header h1 { font-size: 1.25rem; font-weight: 600; margin: 0 0 0.35rem 0; }
86
+ header p { margin: 0; color: var(--muted); font-size: 0.875rem; }
87
+ .shell {
88
+ display: grid; grid-template-columns: minmax(300px, 38%) minmax(0, 1fr);
89
+ gap: 1rem; align-items: start; max-width: 1200px; margin: 0 auto;
90
+ }
91
+ @media (max-width: 880px) { .shell { grid-template-columns: 1fr; } }
92
+ .col-left {
93
+ background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
94
+ padding: 0.65rem 0.75rem; min-height: 200px;
95
+ }
96
+ .col-left .left-topbar {
97
+ display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: space-between; align-items: center;
98
+ margin-bottom: 0.65rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border);
99
+ }
100
+ .col-right {
101
+ background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
102
+ padding: 0.85rem 1rem 1rem; min-height: 280px;
103
+ }
104
+ .detail-toolbar {
105
+ display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-start;
106
+ gap: 0.5rem; margin-bottom: 0.75rem;
107
+ }
108
+ .detail-toolbar .toolbar-actions { display: flex; gap: 0.45rem; flex-shrink: 0; }
109
+ #detail-badge { font-size: 12px; color: var(--muted); max-width: 55%; line-height: 1.35; }
110
+ .btn {
111
+ padding: 0.4rem 0.85rem; font-size: 13px; cursor: pointer; border-radius: 6px;
112
+ border: 1px solid var(--border); background: var(--row-alt); color: var(--text); font-weight: 500;
113
+ }
114
+ .btn:hover:not(:disabled) { background: var(--btn-hover); }
115
+ .btn-primary { background: var(--btn-primary); border-color: var(--btn-primary-border); color: #fff; }
116
+ .btn-primary:hover:not(:disabled) { background: var(--btn-primary-hover); }
117
+ .btn-sm { padding: 0.25rem 0.55rem; font-size: 12px; }
118
+ #log-list { display: flex; flex-direction: column; gap: 0.5rem; }
119
+ .log-card {
120
+ background: var(--log-card); border: 1px solid var(--border); border-radius: 8px;
121
+ overflow: hidden;
122
+ }
123
+ .log-card.selected { box-shadow: 0 0 0 2px var(--accent); border-color: var(--selected-ring); }
124
+ .log-card-head {
125
+ display: grid;
126
+ grid-template-columns: auto 1fr auto auto auto;
127
+ gap: 0.45rem 0.6rem; align-items: center;
128
+ padding: 0.5rem 0.6rem; font-size: 13px;
129
+ }
130
+ .log-card-head .method { font-weight: 600; color: var(--accent); }
131
+ .log-card-head .path { font-family: var(--font-mono); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
132
+ .log-card-head .status { color: var(--green); font-weight: 600; font-size: 12px; }
133
+ .log-card-head .ms { color: var(--muted); font-size: 11px; }
134
+ .btn-toggle {
135
+ border: 1px solid var(--border); background: var(--row-alt); color: var(--text);
136
+ border-radius: 6px; padding: 0.25rem 0.5rem; font-size: 11px; cursor: pointer;
137
+ }
138
+ .btn-toggle:hover { border-color: var(--accent); color: var(--accent); }
139
+ .section-title {
140
+ font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em;
141
+ color: var(--muted); margin: 0.85rem 0 0.45rem 0; font-weight: 600;
142
+ }
143
+ .section-title:first-of-type { margin-top: 0; }
144
+ #req-slot { min-height: 1rem; }
145
+ table.kv { width: 100%; border-collapse: collapse; font-size: 12px; margin: 0; }
146
+ table.kv th {
147
+ text-align: left; vertical-align: top; width: 6.5rem;
148
+ padding: 0.4rem 0.55rem; border: 1px solid var(--border);
149
+ background: var(--row-alt); color: var(--muted); font-weight: 600;
150
+ }
151
+ table.kv td {
152
+ padding: 0.4rem 0.55rem; border: 1px solid var(--border);
153
+ word-break: break-word; background: var(--kv-td);
154
+ }
155
+ table.kv td pre, table.kv .mono {
156
+ margin: 0; font-family: var(--font-mono); font-size: 11px;
157
+ white-space: pre-wrap; max-height: 180px; overflow: auto;
158
+ }
159
+ table.kv-edit input[type="text"], table.kv-edit textarea {
160
+ width: 100%; margin: 0; padding: 0.35rem 0.45rem; font-size: 12px;
161
+ font-family: var(--font-mono);
162
+ border: 1px solid var(--border); border-radius: 4px; background: var(--kv-input-bg); color: var(--text);
163
+ }
164
+ table.kv-edit textarea { resize: vertical; min-height: 3.5rem; display: block; }
165
+ table.kv-edit input:focus, table.kv-edit textarea:focus {
166
+ outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent);
167
+ }
168
+ .err-box {
169
+ color: var(--danger); font-family: var(--font-mono); font-size: 12px;
170
+ padding: 0.5rem; border: 1px solid var(--danger); border-radius: 6px; background: var(--err-bg);
171
+ }
172
+ </style>
173
+ </head>
174
+ <body class="__THEME_CLASS__">
175
+ <header>
176
+ <h1>Tunnel traffic</h1>
177
+ <p>Left: request list. Right: request/response for the <strong>latest</strong> capture until you click <strong>Show</strong> on a row. Replay updates the Response panel.</p>
178
+ </header>
179
+ <div class="shell">
180
+ <aside class="col-left">
181
+ <div class="left-topbar">
182
+ <button type="button" class="btn btn-sm" id="btn-latest" title="Show most recent in the right panel">Latest</button>
183
+ <button type="button" class="btn btn-sm" id="clear-all">Clear all</button>
184
+ </div>
185
+ <div id="log-list"></div>
186
+ </aside>
187
+ <section class="col-right">
188
+ <div class="detail-toolbar">
189
+ <span id="detail-badge">No captures yet.</span>
190
+ <div class="toolbar-actions">
191
+ <button type="button" class="btn btn-sm btn-primary" id="btn-mod-replay" disabled>Modify</button>
192
+ <button type="button" class="btn btn-sm" id="btn-reset-req" disabled>Reset</button>
193
+ </div>
194
+ </div>
195
+ <div class="section-title">Request</div>
196
+ <div id="req-slot"><p style="color:var(--muted);font-size:13px;margin:0">Waiting for traffic…</p></div>
197
+ <div class="section-title"><span id="resp-label">Response</span></div>
198
+ <div id="resp-slot"></div>
199
+ </section>
200
+ </div>
201
+ <script>
202
+ (function () {
203
+ var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
204
+ var ws = new WebSocket(proto + '//' + location.host + '/ws');
205
+ var logList = document.getElementById('log-list');
206
+ var clearAllBtn = document.getElementById('clear-all');
207
+ var btnLatest = document.getElementById('btn-latest');
208
+ var detailBadge = document.getElementById('detail-badge');
209
+ var reqSlot = document.getElementById('req-slot');
210
+ var respSlot = document.getElementById('resp-slot');
211
+ var respLabel = document.getElementById('resp-label');
212
+ var btnModReplay = document.getElementById('btn-mod-replay');
213
+ var btnResetReq = document.getElementById('btn-reset-req');
214
+ var originals = {};
215
+ var latestId = null;
216
+ var focusedId = null;
217
+ var editing = false;
218
+ var respIsReplay = false;
219
+
220
+ function effectiveId() {
221
+ return focusedId || latestId;
222
+ }
223
+ function updateCardSelection() {
224
+ var nodes = logList.querySelectorAll('.log-card');
225
+ var i;
226
+ for (i = 0; i < nodes.length; i++) {
227
+ nodes[i].classList.toggle('selected', focusedId && nodes[i].getAttribute('data-id') === focusedId);
228
+ }
229
+ }
230
+ function updateBadge() {
231
+ var id = effectiveId();
232
+ if (!id || !originals[id]) {
233
+ detailBadge.textContent = 'No captures yet.';
234
+ return;
235
+ }
236
+ var log = originals[id];
237
+ var verb = focusedId ? 'Selected' : 'Latest';
238
+ detailBadge.textContent = verb + ' · ' + log.method + ' ' + log.path + ' · ' + String(log.status);
239
+ }
240
+
241
+ function escapeHtml(s) {
242
+ if (s == null) return '';
243
+ var d = document.createElement('div');
244
+ d.textContent = s;
245
+ return d.innerHTML;
246
+ }
247
+ function escapeAttr(s) {
248
+ return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;');
249
+ }
250
+ function headerRows(h) {
251
+ if (!h || typeof h !== 'object') return '';
252
+ var keys = Object.keys(h).sort();
253
+ var i, k, parts = [];
254
+ for (i = 0; i < keys.length; i++) {
255
+ k = keys[i];
256
+ parts.push('<tr><th>' + escapeHtml(k) + '</th><td class="mono">' + escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : String(h[k])) + '</td></tr>');
257
+ }
258
+ return parts.join('');
259
+ }
260
+ function renderReqView(log) {
261
+ return '<table class="kv"><tbody>' +
262
+ '<tr><th>Method</th><td class="mono">' + escapeHtml(log.method) + '</td></tr>' +
263
+ '<tr><th>Path</th><td class="mono">' + escapeHtml(log.path) + '</td></tr>' +
264
+ headerRows(log.headers) +
265
+ '<tr><th>Body</th><td><pre>' + escapeHtml(log.body != null ? String(log.body) : '') + '</pre></td></tr>' +
266
+ '</tbody></table>';
267
+ }
268
+ function renderReqEdit(log) {
269
+ var h = log.headers || {};
270
+ return '<table class="kv kv-edit"><tbody>' +
271
+ '<tr><th>Method</th><td><input type="text" class="f-method" value="' + escapeAttr(log.method) + '"/></td></tr>' +
272
+ '<tr><th>Path</th><td><input type="text" class="f-path" value="' + escapeAttr(log.path) + '"/></td></tr>' +
273
+ '<tr><th>Headers</th><td><textarea class="f-headers" rows="5">' + escapeHtml(JSON.stringify(h, null, 2)) + '</textarea></td></tr>' +
274
+ '<tr><th>Body</th><td><textarea class="f-body" rows="6">' + escapeHtml(log.body != null ? String(log.body) : '') + '</textarea></td></tr>' +
275
+ '</tbody></table>';
276
+ }
277
+ function renderRespTable(log) {
278
+ return '<table class="kv"><tbody>' +
279
+ '<tr><th>Status</th><td class="mono">' + escapeHtml(String(log.status)) + '</td></tr>' +
280
+ headerRows(log.resp_headers) +
281
+ '<tr><th>Body</th><td><pre>' + escapeHtml(log.resp_body != null ? String(log.resp_body) : '') + '</pre></td></tr>' +
282
+ '</tbody></table>';
283
+ }
284
+ function renderReplayKv(data) {
285
+ if (data.error) {
286
+ return '<div class="err-box">' + escapeHtml(String(data.error)) + '</div>';
287
+ }
288
+ return '<table class="kv"><tbody>' +
289
+ '<tr><th>Status</th><td class="mono">' + escapeHtml(String(data.status)) + '</td></tr>' +
290
+ headerRows(data.headers) +
291
+ '<tr><th>Body</th><td><pre>' + escapeHtml(data.body != null ? String(data.body) : '') + '</pre></td></tr>' +
292
+ '</tbody></table>';
293
+ }
294
+ function snapshotLog(log) {
295
+ try {
296
+ return JSON.parse(JSON.stringify(log));
297
+ } catch (e) {
298
+ return log;
299
+ }
300
+ }
301
+ function syncModReplayBtn() {
302
+ var ed = !!reqSlot.querySelector('.kv-edit');
303
+ btnModReplay.textContent = ed ? 'Replay' : 'Modify';
304
+ }
305
+ function getPayloadFromPanel() {
306
+ var id = effectiveId();
307
+ if (!id || !originals[id]) throw new Error('no selection');
308
+ var edit = reqSlot.querySelector('.kv-edit');
309
+ if (edit) {
310
+ var method = reqSlot.querySelector('.f-method').value;
311
+ var path = reqSlot.querySelector('.f-path').value;
312
+ var body = reqSlot.querySelector('.f-body').value;
313
+ var headersRaw = reqSlot.querySelector('.f-headers').value.trim();
314
+ var headers = {};
315
+ if (headersRaw) headers = JSON.parse(headersRaw);
316
+ return { method: method, path: path, headers: headers, body: body };
317
+ }
318
+ var o = originals[id];
319
+ return {
320
+ method: o.method,
321
+ path: o.path,
322
+ headers: o.headers || {},
323
+ body: o.body != null ? String(o.body) : ''
324
+ };
325
+ }
326
+ function paint() {
327
+ var id = effectiveId();
328
+ updateBadge();
329
+ updateCardSelection();
330
+ if (!id || !originals[id]) {
331
+ reqSlot.innerHTML = '<p style="color:var(--muted);font-size:13px;margin:0">Waiting for traffic…</p>';
332
+ respSlot.innerHTML = '';
333
+ respLabel.textContent = 'Response';
334
+ btnModReplay.disabled = true;
335
+ btnResetReq.disabled = true;
336
+ return;
337
+ }
338
+ btnModReplay.disabled = false;
339
+ btnResetReq.disabled = false;
340
+ var log = originals[id];
341
+ if (!editing) {
342
+ reqSlot.innerHTML = renderReqView(log);
343
+ } else {
344
+ reqSlot.innerHTML = renderReqEdit(log);
345
+ }
346
+ if (!respIsReplay) {
347
+ respSlot.innerHTML = renderRespTable(log);
348
+ respLabel.textContent = 'Response';
349
+ }
350
+ syncModReplayBtn();
351
+ }
352
+ function wireCardHead(card, id) {
353
+ card.querySelector('.btn-toggle').addEventListener('click', function (e) {
354
+ e.stopPropagation();
355
+ focusedId = id;
356
+ editing = false;
357
+ respIsReplay = false;
358
+ paint();
359
+ });
360
+ }
361
+ function appendLogCard(log) {
362
+ var id = log.id || ('tmp-' + Date.now() + '-' + Math.random());
363
+ log.id = id;
364
+ originals[id] = snapshotLog(log);
365
+ latestId = id;
366
+ var card = document.createElement('article');
367
+ card.className = 'log-card';
368
+ card.setAttribute('data-id', id);
369
+ card.innerHTML =
370
+ '<div class="log-card-head">' +
371
+ '<span class="method">' + escapeHtml(log.method) + '</span>' +
372
+ '<span class="path" title="' + escapeAttr(log.path) + '">' + escapeHtml(log.path) + '</span>' +
373
+ '<span class="status">' + escapeHtml(String(log.status)) + '</span>' +
374
+ '<span class="ms">' + escapeHtml(String(log.duration_ms != null ? log.duration_ms : '')) + ' ms</span>' +
375
+ '<button type="button" class="btn-toggle">Show</button>' +
376
+ '</div>';
377
+ wireCardHead(card, id);
378
+ logList.insertBefore(card, logList.firstChild);
379
+ if (focusedId === null) {
380
+ editing = false;
381
+ respIsReplay = false;
382
+ paint();
383
+ }
384
+ return card;
385
+ }
386
+ function clearAll() {
387
+ logList.innerHTML = '';
388
+ originals = {};
389
+ latestId = null;
390
+ focusedId = null;
391
+ editing = false;
392
+ respIsReplay = false;
393
+ paint();
394
+ }
395
+ function loadHistory() {
396
+ fetch('/logs').then(function (res) { return res.json(); }).then(function (logs) {
397
+ if (!Array.isArray(logs) || !logs.length) return;
398
+ var i;
399
+ for (i = 0; i < logs.length; i++) appendLogCard(logs[i]);
400
+ }).catch(function () {});
401
+ }
402
+
403
+ btnLatest.onclick = function () {
404
+ focusedId = null;
405
+ editing = false;
406
+ respIsReplay = false;
407
+ paint();
408
+ };
409
+ btnModReplay.onclick = function () {
410
+ var id = effectiveId();
411
+ if (!id || !originals[id]) return;
412
+ if (btnModReplay.textContent === 'Modify') {
413
+ editing = true;
414
+ paint();
415
+ return;
416
+ }
417
+ var payload;
418
+ try {
419
+ payload = getPayloadFromPanel();
420
+ } catch (err) {
421
+ respIsReplay = true;
422
+ respLabel.textContent = 'Response';
423
+ respSlot.innerHTML = '<div class="err-box">' + escapeHtml(err.message) + '</div>';
424
+ return;
425
+ }
426
+ if (typeof payload.method !== 'string' || !payload.method.trim()) {
427
+ respIsReplay = true;
428
+ respSlot.innerHTML = '<div class="err-box">method required</div>';
429
+ return;
430
+ }
431
+ if (typeof payload.path !== 'string') {
432
+ respIsReplay = true;
433
+ respSlot.innerHTML = '<div class="err-box">path required</div>';
434
+ return;
435
+ }
436
+ btnModReplay.disabled = true;
437
+ respIsReplay = true;
438
+ respLabel.textContent = 'Response (replay)';
439
+ respSlot.innerHTML = '<p style="color:var(--muted);margin:0">…</p>';
440
+ fetch('/replay', {
441
+ method: 'POST',
442
+ headers: { 'Content-Type': 'application/json' },
443
+ body: JSON.stringify({
444
+ method: payload.method,
445
+ path: payload.path,
446
+ headers: payload.headers || {},
447
+ body: payload.body != null ? payload.body : ''
448
+ })
449
+ }).then(function (res) { return res.json(); })
450
+ .then(function (data) {
451
+ respSlot.innerHTML = renderReplayKv(data);
452
+ })
453
+ .catch(function (err) {
454
+ respSlot.innerHTML = '<div class="err-box">' + escapeHtml(String(err)) + '</div>';
455
+ })
456
+ .finally(function () {
457
+ btnModReplay.disabled = false;
458
+ });
459
+ };
460
+ btnResetReq.onclick = function () {
461
+ var id = effectiveId();
462
+ if (!id || !originals[id]) return;
463
+ editing = false;
464
+ respIsReplay = false;
465
+ paint();
466
+ };
467
+ reqSlot.addEventListener('input', function () {
468
+ syncModReplayBtn();
469
+ });
470
+
471
+ loadHistory();
472
+ paint();
473
+
474
+ ws.onmessage = function (event) {
475
+ appendLogCard(JSON.parse(event.data));
476
+ };
477
+
478
+ clearAllBtn.onclick = clearAll;
479
+ })();
480
+ </script>
481
+ </body>
482
+ </html>
@@ -0,0 +1,266 @@
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
+ }
@@ -0,0 +1,65 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ const defaultMaxRequestLogs = 100;
4
+
5
+ /** @type {number} */
6
+ let maxRequestLogs = defaultMaxRequestLogs;
7
+ /** @type {Array<Record<string, unknown>>} */
8
+ let requestLogs = [];
9
+
10
+ /** @type {((entry: Record<string, unknown>) => void) | null} */
11
+ let inspectorSubscriber = null;
12
+
13
+ /**
14
+ * Wire live inspector WebSocket broadcast (optional).
15
+ * @param {((entry: Record<string, unknown>) => void) | null} fn
16
+ */
17
+ export function setInspectorSubscriber(fn) {
18
+ inspectorSubscriber = fn;
19
+ }
20
+
21
+ /**
22
+ * @param {number} n
23
+ */
24
+ export function setMaxRequestLogs(n) {
25
+ if (n < 1) n = defaultMaxRequestLogs;
26
+ maxRequestLogs = n;
27
+ if (requestLogs.length > maxRequestLogs) {
28
+ requestLogs = requestLogs.slice(requestLogs.length - maxRequestLogs);
29
+ }
30
+ }
31
+
32
+ /**
33
+ * @param {Record<string, unknown>} entry
34
+ */
35
+ export function addLog(entry) {
36
+ requestLogs.push(entry);
37
+ if (requestLogs.length > maxRequestLogs) {
38
+ requestLogs = requestLogs.slice(requestLogs.length - maxRequestLogs);
39
+ }
40
+ const sub = inspectorSubscriber;
41
+ if (sub) {
42
+ setImmediate(() => {
43
+ try {
44
+ sub(entry);
45
+ } catch {
46
+ /* ignore */
47
+ }
48
+ });
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Newest entries are last (matches gotunnel GetLogs).
54
+ * @returns {Record<string, unknown>[]}
55
+ */
56
+ export function getLogs() {
57
+ return requestLogs.slice();
58
+ }
59
+
60
+ /**
61
+ * @returns {string}
62
+ */
63
+ export function newLogId() {
64
+ return randomUUID();
65
+ }
@@ -1,6 +1,8 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import net from 'node:net';
3
3
  import { Session } from 'yamux-js/lib/session.js';
4
+ import { addLog, newLogId, setMaxRequestLogs } from './logstore.js';
5
+ import { startInspector } from './inspector.js';
4
6
  // import { version } from '../../../package.json' with { type: 'json' };
5
7
 
6
8
  const defaultMuxConfig = {
@@ -98,6 +100,7 @@ function headersToObject(h) {
98
100
  * @param {string} port
99
101
  */
100
102
  async function handleStream(stream, port) {
103
+ const started = Date.now();
101
104
  try {
102
105
  const line = await readJsonLine(stream);
103
106
  let req;
@@ -135,6 +138,19 @@ async function handleStream(stream, port) {
135
138
  };
136
139
 
137
140
  stream.end(Buffer.from(`${JSON.stringify(payload)}\n`, 'utf8'));
141
+
142
+ addLog({
143
+ id: newLogId(),
144
+ method,
145
+ path,
146
+ headers: raw,
147
+ body: body.toString('utf8'),
148
+ status: resp.status,
149
+ resp_body: respBody.toString('utf8'),
150
+ resp_headers: headersToObject(resp.headers),
151
+ timestamp: new Date().toISOString(),
152
+ duration_ms: Date.now() - started,
153
+ });
138
154
  } catch (e) {
139
155
  try {
140
156
  stream.destroy();
@@ -146,17 +162,52 @@ async function handleStream(stream, port) {
146
162
 
147
163
  /**
148
164
  * @typedef {Object} TunnelOptions
149
- * @property {string} [host] tunnel server host (default localhost)
165
+ * @property {string} [host] tunnel server host (default clickly.cv)
150
166
  * @property {number} [serverPort] tunnel server TCP port (default 9000)
167
+ * @property {boolean} [inspector] traffic inspector UI (default true)
168
+ * @property {string} [themes] inspector palette: "dark" | "terminal" | "light"
169
+ * @property {number} [logs] max request logs in memory (default 100)
170
+ * @property {string} [inspectorAddr] inspector listen address (default ":4040")
171
+ */
172
+
173
+ function defaultTunnelOptions() {
174
+ return {
175
+ host: 'clickly.cv',
176
+ serverPort: 9000,
177
+ inspector: true,
178
+ themes: 'dark',
179
+ logs: 100,
180
+ inspectorAddr: '',
181
+ };
182
+ }
183
+
184
+ /**
185
+ * @param {TunnelOptions | undefined} options
186
+ * @returns {TunnelOptions & ReturnType<typeof defaultTunnelOptions>}
151
187
  */
188
+ function applyTunnelOptions(options) {
189
+ const d = defaultTunnelOptions();
190
+ if (!options || typeof options !== 'object') {
191
+ return /** @type {TunnelOptions & typeof d} */ ({ ...d });
192
+ }
193
+ return {
194
+ host: options.host ?? d.host,
195
+ serverPort: options.serverPort ?? d.serverPort,
196
+ inspector: options.inspector !== undefined ? !!options.inspector : d.inspector,
197
+ themes: options.themes ?? d.themes,
198
+ logs: options.logs > 0 ? options.logs : d.logs,
199
+ inspectorAddr: options.inspectorAddr ?? d.inspectorAddr,
200
+ };
201
+ }
152
202
 
153
203
  class Tunnel {
154
204
  /**
155
205
  * @param {string} localPort local HTTP port to forward to
156
- * @param {TunnelOptions} [options]
206
+ * @param {TunnelOptions} [options] merged options
157
207
  */
158
208
  constructor(localPort, options = {}) {
159
209
  this.localPort = localPort;
210
+ this.options = options;
160
211
  this.serverHost = options.host ?? 'clickly.cv';
161
212
  this.serverPort = options.serverPort ?? 9000;
162
213
  /** @type {import('net').Socket | null} */
@@ -165,6 +216,8 @@ class Tunnel {
165
216
  this.session = null;
166
217
  this.publicUrl = '';
167
218
  this._stopped = false;
219
+ /** @type {(() => void) | null} */
220
+ this._stopInspector = null;
168
221
  }
169
222
 
170
223
  /**
@@ -217,6 +270,14 @@ class Tunnel {
217
270
  stop() {
218
271
  if (this._stopped) return;
219
272
  this._stopped = true;
273
+ try {
274
+ if (this._stopInspector) {
275
+ this._stopInspector();
276
+ this._stopInspector = null;
277
+ }
278
+ } catch {
279
+ /* ignore */
280
+ }
220
281
  try {
221
282
  if (this.session) {
222
283
  this.session.close();
@@ -241,8 +302,11 @@ class Tunnel {
241
302
  * @param {TunnelOptions} [options]
242
303
  */
243
304
  async function newTunnel(localPort, options) {
244
- const t = new Tunnel(localPort, options);
305
+ const opts = applyTunnelOptions(options);
306
+ setMaxRequestLogs(opts.logs);
307
+ const t = new Tunnel(localPort, opts);
245
308
  await t.connect();
309
+ t._stopInspector = startInspector(opts, localPort);
246
310
  return t;
247
311
  }
248
312
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dpkrn/nodetunnel",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "Expose a local HTTP server through a devtunnel/gotunnel-compatible server (yamux + JSON). Node.js 18+.",
5
5
  "keywords": [
6
6
  "tunnel",
@@ -49,7 +49,7 @@
49
49
  "prepublishOnly": "node -e \"import('./pkg/tunnel/tunnel.js').then(() => console.log('pack ok')).catch(e => { console.error(e); process.exit(1); })\""
50
50
  },
51
51
  "dependencies": {
52
- "@dpkrn/nodetunnel": "^1.0.2",
52
+ "ws": "^8.18.0",
53
53
  "yamux-js": "^0.2.0"
54
54
  },
55
55
  "devDependencies": {
@@ -18,19 +18,24 @@
18
18
  */
19
19
 
20
20
  import { newTunnel } from '../../internal/tunnel/tunnel.js';
21
+ import { inspectorHTTPBaseURL } from '../../internal/tunnel/inspector.js';
21
22
 
22
23
  /**
23
24
  * Print a formatted success message for the tunnel.
24
25
  * @param {string} publicURL
25
26
  * @param {string} localURL
27
+ * @param {string} [inspectorURL] when empty, inspector line is omitted
26
28
  */
27
- function printSuccess(publicURL, localURL) {
29
+ function printSuccess(publicURL, localURL, inspectorURL) {
28
30
  console.log();
29
31
  console.log(' ╔══════════════════════════════════════════════════╗');
30
32
  console.log(' ║ 🚇 nodetunnel — tunnel is live ║');
31
33
  console.log(' ╠══════════════════════════════════════════════════╣');
32
34
  console.log(` ║ 🌍 Public → ${publicURL.padEnd(32)}║`);
33
35
  console.log(` ║ 💻 Local → ${localURL.padEnd(32)}║`);
36
+ if (inspectorURL) {
37
+ console.log(` ║ 🔍 Inspector → ${inspectorURL.padEnd(32)}║`);
38
+ }
34
39
  console.log(` ╠══════════════════════════════════════════════════╣`);
35
40
  console.log(' ║ ⚡ Forwarding requests... ║');
36
41
  console.log(' ║ 🛑 Press Ctrl+C to stop ║');
@@ -42,7 +47,14 @@ function printSuccess(publicURL, localURL) {
42
47
  * Connect to the tunnel server and forward public HTTP traffic to localhost:<port>.
43
48
  *
44
49
  * @param {string} port local port (e.g. "8080")
45
- * @param {{ host?: string, serverPort?: number }} [options] tunnel server (default localhost:9000)
50
+ * @param {{
51
+ * host?: string,
52
+ * serverPort?: number,
53
+ * inspector?: boolean,
54
+ * themes?: 'dark' | 'terminal' | 'light' | string,
55
+ * logs?: number,
56
+ * inspectorAddr?: string,
57
+ * }} [options]
46
58
  * @returns {Promise<{ url: string, stop: () => void }>}
47
59
  */
48
60
  async function startTunnel(port, options) {
@@ -50,7 +62,9 @@ async function startTunnel(port, options) {
50
62
 
51
63
  const publicURL = tunnel.getPublicUrl();
52
64
  const localURL = `http://localhost:${port}`;
53
- printSuccess(publicURL, localURL);
65
+ const insp =
66
+ tunnel.options.inspector === false ? '' : inspectorHTTPBaseURL(tunnel.options);
67
+ printSuccess(publicURL, localURL, insp);
54
68
 
55
69
  return {
56
70
  url: publicURL,