@dpkrn/nodetunnel 1.0.8 → 1.0.10

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
@@ -1,6 +1,37 @@
1
1
  # @dpkrn/nodetunnel
2
2
 
3
- Give your **local** HTTP server a **public URL** from Node.js useful for webhooks, demos, sharing a dev server, or testing from another device.
3
+ Package **nodetunnel** exposes a local HTTP server on a public URL by establishing a persistent outbound TCP connection to a tunnel server.
4
+
5
+ It creates an outbound connection to a tunnel server and forwards incoming requests to your local application (e.g., `localhost:8080`).
6
+
7
+ From **Node.js**, you get a **public URL** for webhooks, demos, sharing a dev server, or testing from another device — without a separate tunnel daemon.
8
+
9
+ ## Introduction
10
+
11
+ ### Benefits
12
+
13
+ - Sharing your local server with others
14
+ - Testing webhooks (Stripe, GitHub, etc.)
15
+ - Remote debugging without deployment
16
+ - No port forwarding or firewall configuration needed
17
+ - Works behind NAT or private networks
18
+ - Simple integration with existing Node.js HTTP servers (Express, Fastify, plain `http`, etc.)
19
+ - Traffic inspector: capture traffic, replay, and modify requests as many times as you need
20
+
21
+ Incoming traffic reaches the public URL, is forwarded through the tunnel, and is proxied to your local HTTP server (e.g., `localhost:8080`).
22
+
23
+ This enables exposing local development servers without port forwarding, firewall changes, or public hosting.
24
+
25
+ ### Requirements
26
+
27
+ - **Node.js 18+**
28
+ - A **tunnel server** must be running and reachable (configure `host` / `serverPort` — see **Options** under [API](#api) below).
29
+ - The port passed to `startTunnel` must match your local HTTP server port.
30
+ - Your local server must be running **before** or **concurrently** with `startTunnel`.
31
+
32
+ ## Overview
33
+
34
+ **nodetunnel** exposes a local HTTP server on a public URL by connecting to a tunnel server you run separately. Traffic hits the tunnel first, then your app on `localhost`.
4
35
 
5
36
  ---
6
37
 
@@ -9,14 +40,7 @@ Give your **local** HTTP server a **public URL** from Node.js — useful for web
9
40
  - **No separate tunnel process** — call one function from your app.
10
41
  - **Works with your existing server** — Express, Fastify, or plain `http`.
11
42
  - **Simple API** — you get a public `url` and a `stop()` when you are done.
12
-
13
- ---
14
-
15
- ## Requirements
16
-
17
- - **Node.js 18+**
18
- - Your app listening on a port (e.g. `8080`)
19
- - A **tunnel server** reachable from your machine (default: `localhost:9000`)
43
+ - **Optional traffic inspector** — local dashboard on loopback to browse captures, replay requests, modify and pick a theme (see below).
20
44
 
21
45
  ---
22
46
 
@@ -83,6 +107,72 @@ Run with `node app.js`. Open the printed URL in a browser or share it for webhoo
83
107
 
84
108
  ---
85
109
 
110
+ ## Traffic inspector (local dashboard)
111
+
112
+ When enabled (default), nodetunnel starts a small **HTTP server on your machine** (default `http://localhost:4040`) with:
113
+
114
+ - **Live traffic** — requests proxied through the tunnel appear in the UI (WebSocket updates).
115
+ - **History** — recent captures kept in memory (configurable count); reload the page to fetch `/logs`.
116
+ - **Inspect** — request/response headers and bodies for each capture.
117
+ - **Modify** — modify request header/path and can replay.
118
+ - **Replay** — send a capture again to your local app, or edit method/path/headers/body and replay (aligned with the **gotunnel** inspector behavior).
119
+
120
+ The startup banner prints **Inspector →** with that URL. Set `inspector: false` if you do not want the UI or an extra listen port.
121
+
122
+ ### Themes
123
+
124
+ The UI supports three built-in palettes via `themes` in `startTunnel` options:
125
+
126
+ | Value | Appearance |
127
+ |--------|----------------|
128
+ | **`"dark"`** (default) | Dark panels, blue accents — similar to GitHub-dark style. |
129
+ | **`"terminal"`** | Green-on-black “CRT” / terminal aesthetic, monospace UI font. |
130
+ | **`"light"`** | Light gray/white background, high-contrast text for bright environments. |
131
+
132
+ ### Example: themes and inspector options
133
+
134
+ ```js
135
+ import http from "node:http";
136
+ import { startTunnel } from "@dpkrn/nodetunnel";
137
+
138
+ const PORT = 3000;
139
+
140
+ const server = http.createServer((req, res) => {
141
+ res.setHeader("Content-Type", "text/plain");
142
+ res.end("hello\n");
143
+ });
144
+
145
+ server.listen(PORT, async () => {
146
+ const { url, stop } = await startTunnel(String(PORT), {
147
+ // Inspector (defaults: enabled, :4040, dark theme, 100 logs)
148
+ inspector: true,
149
+ inspectorAddr: ":4040",
150
+ themes: "terminal", // try: "dark" | "terminal" | "light"
151
+ logs: 100,
152
+ });
153
+
154
+ // console.log("Public:", url);
155
+ // Open the Inspector URL from stderr in a browser (e.g. http://localhost:4040)
156
+
157
+ process.once("SIGINT", () => {
158
+ stop();
159
+ server.close(() => process.exit(0));
160
+ });
161
+ });
162
+ ```
163
+
164
+ ### Example: tunnel only (no inspector)
165
+
166
+ ```js
167
+ import { startTunnel } from "@dpkrn/nodetunnel";
168
+
169
+ const { url, stop } = await startTunnel("8080", {
170
+ inspector: false,
171
+ });
172
+ ```
173
+
174
+ ---
175
+
86
176
  ## Express (same idea)
87
177
 
88
178
  ```js
@@ -117,7 +207,7 @@ app.listen(PORT, async () => {
117
207
  | Argument | Description |
118
208
  |----------|-------------|
119
209
  | `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`). |
210
+ | `options` | Optional. Objectsee **Options** below (tunnel server address, inspector, themes, etc.). |
121
211
 
122
212
  **Returns:** `{ url, stop }`
123
213
 
@@ -128,6 +218,17 @@ app.listen(PORT, async () => {
128
218
 
129
219
  Errors **reject** the promise — use `try/catch`.
130
220
 
221
+ ### Options (`startTunnel` second argument)
222
+
223
+ | Field | Type | Default | Description |
224
+ |-------|------|---------|-------------|
225
+ | `host` | `string` | `'clickly.cv'` | Hostname of the tunnel **control** server. |
226
+ | `serverPort` | `number` | `9000` | TCP port of the tunnel server. |
227
+ | `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. |
228
+ | `themes` | `string` | `'dark'` | Inspector palette: `'dark'`, `'terminal'`, or `'light'`. |
229
+ | `logs` | `number` | `100` | Maximum number of request/response captures kept in memory for the inspector. |
230
+ | `inspectorAddr` | `string` | `':4040'` | Listen address for the inspector (e.g. `':4040'`, `'localhost:9090'`). Display URL follows the same rules as the public banner. |
231
+
131
232
  ---
132
233
 
133
234
  ## 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.10",
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,