@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 +79 -1
- package/cmd/test-lib/main.js +2 -2
- package/internal/tunnel/inspector-page.html +482 -0
- package/internal/tunnel/inspector.js +266 -0
- package/internal/tunnel/logstore.js +65 -0
- package/internal/tunnel/tunnel.js +67 -3
- package/package.json +2 -2
- package/pkg/tunnel/tunnel.js +17 -3
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.
|
|
187
|
+
| `options` | Optional. Object — see **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
|
package/cmd/test-lib/main.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import express from "express";
|
|
2
|
-
import { startTunnel } from "
|
|
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://
|
|
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,'&').replace(/"/g,'"').replace(/</g,'<');
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
52
|
+
"ws": "^8.18.0",
|
|
53
53
|
"yamux-js": "^0.2.0"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
package/pkg/tunnel/tunnel.js
CHANGED
|
@@ -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 {{
|
|
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
|
-
|
|
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,
|