@dpkrn/nodetunnel 1.0.6 → 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,11 +27,9 @@ 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}`);
31
- console.log("Leave this terminal open; press Ctrl+C to stop.");
30
+ console.log(`listening on http://localhost:${PORT}`);
32
31
  try {
33
32
  const { url, stop } = await startTunnel(String(PORT));
34
- console.log("🌍 Public URL:", url);
35
33
  process.once("SIGINT", () => {
36
34
  stop();
37
35
  process.exit(0);
@@ -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,5 +1,9 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import net from 'node:net';
2
3
  import { Session } from 'yamux-js/lib/session.js';
4
+ import { addLog, newLogId, setMaxRequestLogs } from './logstore.js';
5
+ import { startInspector } from './inspector.js';
6
+ // import { version } from '../../../package.json' with { type: 'json' };
3
7
 
4
8
  const defaultMuxConfig = {
5
9
  enableKeepAlive: false,
@@ -96,6 +100,7 @@ function headersToObject(h) {
96
100
  * @param {string} port
97
101
  */
98
102
  async function handleStream(stream, port) {
103
+ const started = Date.now();
99
104
  try {
100
105
  const line = await readJsonLine(stream);
101
106
  let req;
@@ -133,6 +138,19 @@ async function handleStream(stream, port) {
133
138
  };
134
139
 
135
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
+ });
136
154
  } catch (e) {
137
155
  try {
138
156
  stream.destroy();
@@ -144,17 +162,52 @@ async function handleStream(stream, port) {
144
162
 
145
163
  /**
146
164
  * @typedef {Object} TunnelOptions
147
- * @property {string} [host] tunnel server host (default localhost)
165
+ * @property {string} [host] tunnel server host (default clickly.cv)
148
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")
149
171
  */
150
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>}
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
+ }
202
+
151
203
  class Tunnel {
152
204
  /**
153
205
  * @param {string} localPort local HTTP port to forward to
154
- * @param {TunnelOptions} [options]
206
+ * @param {TunnelOptions} [options] merged options
155
207
  */
156
208
  constructor(localPort, options = {}) {
157
209
  this.localPort = localPort;
210
+ this.options = options;
158
211
  this.serverHost = options.host ?? 'clickly.cv';
159
212
  this.serverPort = options.serverPort ?? 9000;
160
213
  /** @type {import('net').Socket | null} */
@@ -163,6 +216,8 @@ class Tunnel {
163
216
  this.session = null;
164
217
  this.publicUrl = '';
165
218
  this._stopped = false;
219
+ /** @type {(() => void) | null} */
220
+ this._stopInspector = null;
166
221
  }
167
222
 
168
223
  /**
@@ -180,6 +235,14 @@ class Tunnel {
180
235
  socket.once('error', reject);
181
236
  });
182
237
 
238
+ // Client hello (JSON line). Match Go json.Marshal(ClientHello) with exported fields (PascalCase).
239
+ socket.write(JSON.stringify({
240
+ tunnel_type: 'nodetunnel',
241
+ Version: "1.0.7",
242
+ tunnel_id: 'fixed_id',
243
+ connection_id: "conn_"+randomUUID(),
244
+ }) + '\n');
245
+
183
246
  const { line, remainder } = await readPublicUrlLine(socket);
184
247
  this.publicUrl = `${line}`;
185
248
 
@@ -207,6 +270,14 @@ class Tunnel {
207
270
  stop() {
208
271
  if (this._stopped) return;
209
272
  this._stopped = true;
273
+ try {
274
+ if (this._stopInspector) {
275
+ this._stopInspector();
276
+ this._stopInspector = null;
277
+ }
278
+ } catch {
279
+ /* ignore */
280
+ }
210
281
  try {
211
282
  if (this.session) {
212
283
  this.session.close();
@@ -231,8 +302,11 @@ class Tunnel {
231
302
  * @param {TunnelOptions} [options]
232
303
  */
233
304
  async function newTunnel(localPort, options) {
234
- const t = new Tunnel(localPort, options);
305
+ const opts = applyTunnelOptions(options);
306
+ setMaxRequestLogs(opts.logs);
307
+ const t = new Tunnel(localPort, opts);
235
308
  await t.connect();
309
+ t._stopInspector = startInspector(opts, localPort);
236
310
  return t;
237
311
  }
238
312
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dpkrn/nodetunnel",
3
- "version": "1.0.6",
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,21 +18,56 @@
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
- * Connect to the tunnel server and forward public HTTP traffic to localhost:&lt;port&gt;.
24
+ * Print a formatted success message for the tunnel.
25
+ * @param {string} publicURL
26
+ * @param {string} localURL
27
+ * @param {string} [inspectorURL] when empty, inspector line is omitted
28
+ */
29
+ function printSuccess(publicURL, localURL, inspectorURL) {
30
+ console.log();
31
+ console.log(' ╔══════════════════════════════════════════════════╗');
32
+ console.log(' ║ 🚇 nodetunnel — tunnel is live ║');
33
+ console.log(' ╠══════════════════════════════════════════════════╣');
34
+ console.log(` ║ 🌍 Public → ${publicURL.padEnd(32)}║`);
35
+ console.log(` ║ 💻 Local → ${localURL.padEnd(32)}║`);
36
+ if (inspectorURL) {
37
+ console.log(` ║ 🔍 Inspector → ${inspectorURL.padEnd(32)}║`);
38
+ }
39
+ console.log(` ╠══════════════════════════════════════════════════╣`);
40
+ console.log(' ║ ⚡ Forwarding requests... ║');
41
+ console.log(' ║ 🛑 Press Ctrl+C to stop ║');
42
+ console.log(' ╚══════════════════════════════════════════════════╝');
43
+ console.log();
44
+ }
45
+
46
+ /**
47
+ * Connect to the tunnel server and forward public HTTP traffic to localhost:<port>.
24
48
  *
25
49
  * @param {string} port local port (e.g. "8080")
26
- * @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]
27
58
  * @returns {Promise<{ url: string, stop: () => void }>}
28
59
  */
29
60
  async function startTunnel(port, options) {
30
61
  const tunnel = await newTunnel(String(port), options);
31
62
 
32
- console.log('✅Public url:', tunnel.getPublicUrl());
63
+ const publicURL = tunnel.getPublicUrl();
64
+ const localURL = `http://localhost:${port}`;
65
+ const insp =
66
+ tunnel.options.inspector === false ? '' : inspectorHTTPBaseURL(tunnel.options);
67
+ printSuccess(publicURL, localURL, insp);
33
68
 
34
69
  return {
35
- url: tunnel.getPublicUrl(),
70
+ url: publicURL,
36
71
  stop: () => tunnel.stop(),
37
72
  };
38
73
  }