reqly-cli 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/bin/reqly.mjs +9 -4
- package/lib/tunnel.mjs +60 -29
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -25,6 +25,7 @@ Start a tunnel that forwards captured requests to your local server.
|
|
|
25
25
|
reqly tunnel --port 3000
|
|
26
26
|
reqly tunnel --port 8080 --bin my-webhook
|
|
27
27
|
reqly tunnel --port 3000 --name "stripe hooks"
|
|
28
|
+
reqly tunnel --port 3000 --passthrough
|
|
28
29
|
```
|
|
29
30
|
|
|
30
31
|
| Option | Description | Default |
|
|
@@ -32,6 +33,7 @@ reqly tunnel --port 3000 --name "stripe hooks"
|
|
|
32
33
|
| `--port <n>` | Local port to forward to | `3000` |
|
|
33
34
|
| `--bin <slug>` | Bin slug to listen on | assigned by server |
|
|
34
35
|
| `--name <s>` | Human-readable tunnel name | `cli-<port>` |
|
|
36
|
+
| `--passthrough` | Reverse-proxy mode — relay your local server's real response back to the caller (serves HTML, redirects, etc., not just webhooks) | off |
|
|
35
37
|
|
|
36
38
|
### `reqly login`
|
|
37
39
|
|
|
@@ -56,11 +58,13 @@ Show version.
|
|
|
56
58
|
## How it works
|
|
57
59
|
|
|
58
60
|
1. The CLI registers a tunnel with the Reqly server.
|
|
59
|
-
2. It
|
|
61
|
+
2. It polls the server for incoming requests (`GET /api/tunnel/poll`). Polling is used instead of streaming because it survives reverse proxies (Traefik, nginx) that buffer or drop long-lived connections.
|
|
60
62
|
3. Each request is forwarded to your local server (`http://localhost:<port>`).
|
|
61
63
|
4. The response from your local server is reported back to Reqly so you can inspect it in the dashboard.
|
|
62
64
|
|
|
63
|
-
|
|
65
|
+
In `--passthrough` mode, the caller is held open until your local server responds, and that real response is relayed back to them — so the public URL behaves like a reverse proxy (HTML pages, redirects, status codes), not just a webhook collector.
|
|
66
|
+
|
|
67
|
+
The poll loop keeps running through transient network errors and resumes automatically once the server is reachable again.
|
|
64
68
|
|
|
65
69
|
## Configuration
|
|
66
70
|
|
package/bin/reqly.mjs
CHANGED
|
@@ -63,9 +63,11 @@ ${BOLD}Commands:${RESET}
|
|
|
63
63
|
status Show connection status and config info
|
|
64
64
|
|
|
65
65
|
${BOLD}Tunnel options:${RESET}
|
|
66
|
-
--port <n>
|
|
67
|
-
--bin <slug>
|
|
68
|
-
--name <s>
|
|
66
|
+
--port <n> Local port to forward to (default: 3000)
|
|
67
|
+
--bin <slug> Bin slug to listen on (optional)
|
|
68
|
+
--name <s> Human-readable tunnel name (optional)
|
|
69
|
+
--passthrough Reverse-proxy mode: relay the local server's real response
|
|
70
|
+
back to the caller (serves HTML/redirects, not just webhooks)
|
|
69
71
|
|
|
70
72
|
${BOLD}Global options:${RESET}
|
|
71
73
|
--help Show this help message
|
|
@@ -134,13 +136,16 @@ async function main() {
|
|
|
134
136
|
const port = parseInt(getFlag("--port") || config.defaultPort, 10);
|
|
135
137
|
const bin = getFlag("--bin");
|
|
136
138
|
const name = getFlag("--name");
|
|
139
|
+
const url = getFlag("--url");
|
|
140
|
+
const token = getFlag("--token");
|
|
141
|
+
const passthrough = hasFlag("--passthrough");
|
|
137
142
|
|
|
138
143
|
if (isNaN(port) || port < 1 || port > 65535) {
|
|
139
144
|
console.log(`${RED}Invalid port number.${RESET} Use --port <1-65535>`);
|
|
140
145
|
process.exit(1);
|
|
141
146
|
}
|
|
142
147
|
|
|
143
|
-
await startTunnel({ port, bin, name });
|
|
148
|
+
await startTunnel({ port, bin, name, url, token, passthrough });
|
|
144
149
|
break;
|
|
145
150
|
}
|
|
146
151
|
|
package/lib/tunnel.mjs
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Polling-based tunnel that receives webhook requests from Reqly and
|
|
3
3
|
* forwards them to a local dev server.
|
|
4
4
|
*
|
|
5
|
+
* Polling is used instead of SSE because it survives reverse proxies
|
|
6
|
+
* (Traefik, nginx) that buffer or drop long-lived streaming connections.
|
|
7
|
+
*
|
|
5
8
|
* Flow:
|
|
6
9
|
* 1. POST /api/tunnel/register → register this tunnel
|
|
7
|
-
* 2. GET /api/tunnel/
|
|
8
|
-
* 3. For each
|
|
10
|
+
* 2. GET /api/tunnel/poll → fetch captures newer than the cursor
|
|
11
|
+
* 3. For each capture: forward to localhost, then
|
|
9
12
|
* POST /api/tunnel/response → report the response back
|
|
10
13
|
*
|
|
11
|
-
*
|
|
14
|
+
* The poll loop keeps running through transient errors; it logs after
|
|
15
|
+
* repeated failures rather than giving up.
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
18
|
import { loadConfig } from "./config.mjs";
|
|
@@ -22,11 +26,6 @@ const RED = "\x1b[31m";
|
|
|
22
26
|
const CYAN = "\x1b[36m";
|
|
23
27
|
const RESET = "\x1b[0m";
|
|
24
28
|
|
|
25
|
-
// ── Backoff settings ────────────────────────────────────────────────
|
|
26
|
-
const BACKOFF_INITIAL_MS = 1000;
|
|
27
|
-
const BACKOFF_MAX_MS = 60_000;
|
|
28
|
-
const BACKOFF_FACTOR = 2;
|
|
29
|
-
|
|
30
29
|
// ── Helpers ─────────────────────────────────────────────────────────
|
|
31
30
|
|
|
32
31
|
function timestamp() {
|
|
@@ -76,11 +75,25 @@ async function forwardRequest(localOrigin, capture) {
|
|
|
76
75
|
|
|
77
76
|
const start = Date.now();
|
|
78
77
|
|
|
78
|
+
// Clean headers — remove ones that break local forwarding
|
|
79
|
+
const cleanHeaders = {};
|
|
80
|
+
if (headers && typeof headers === "object") {
|
|
81
|
+
const skipHeaders = new Set(["host", "content-length", "transfer-encoding", "connection", "keep-alive", "upgrade", "http2-settings"]);
|
|
82
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
83
|
+
if (!skipHeaders.has(k.toLowerCase())) {
|
|
84
|
+
cleanHeaders[k] = v;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Don't send body for GET/HEAD
|
|
90
|
+
const hasBody = method && !["GET", "HEAD"].includes(method.toUpperCase());
|
|
91
|
+
|
|
79
92
|
try {
|
|
80
93
|
const res = await fetch(url, {
|
|
81
|
-
method: method || "
|
|
82
|
-
headers:
|
|
83
|
-
body:
|
|
94
|
+
method: method || "GET",
|
|
95
|
+
headers: cleanHeaders,
|
|
96
|
+
body: hasBody && body ? (typeof body === "string" ? body : JSON.stringify(body)) : undefined,
|
|
84
97
|
});
|
|
85
98
|
|
|
86
99
|
const resBody = await res.text();
|
|
@@ -148,14 +161,14 @@ function parseSSELine(line, current) {
|
|
|
148
161
|
* Register this tunnel with the server.
|
|
149
162
|
* Returns { tunnelId, binSlug, binUrl }.
|
|
150
163
|
*/
|
|
151
|
-
async function registerTunnel(serverUrl, token, name, port, binSlug) {
|
|
164
|
+
async function registerTunnel(serverUrl, token, name, port, binSlug, passthrough) {
|
|
152
165
|
const res = await fetch(`${serverUrl}/api/tunnel/register`, {
|
|
153
166
|
method: "POST",
|
|
154
167
|
headers: {
|
|
155
168
|
"Content-Type": "application/json",
|
|
156
169
|
Authorization: `Bearer ${token}`,
|
|
157
170
|
},
|
|
158
|
-
body: JSON.stringify({ token, name, port, binSlug }),
|
|
171
|
+
body: JSON.stringify({ token, name, port, binSlug, passthrough: !!passthrough }),
|
|
159
172
|
});
|
|
160
173
|
|
|
161
174
|
if (!res.ok) {
|
|
@@ -170,7 +183,7 @@ async function registerTunnel(serverUrl, token, name, port, binSlug) {
|
|
|
170
183
|
* Poll for new captures and forward them.
|
|
171
184
|
* More reliable than SSE through reverse proxies (Traefik, nginx).
|
|
172
185
|
*/
|
|
173
|
-
async function pollForCaptures(serverUrl, token, tunnelId, localOrigin) {
|
|
186
|
+
async function pollForCaptures(serverUrl, token, tunnelId, localOrigin, pollIntervalMs = 1000) {
|
|
174
187
|
let aborted = false;
|
|
175
188
|
let lastSeen = new Date().toISOString();
|
|
176
189
|
let consecutiveErrors = 0;
|
|
@@ -213,9 +226,14 @@ async function pollForCaptures(serverUrl, token, tunnelId, localOrigin) {
|
|
|
213
226
|
try { headers = JSON.parse(headers); } catch { headers = {}; }
|
|
214
227
|
}
|
|
215
228
|
|
|
216
|
-
|
|
229
|
+
// Compute local path for display
|
|
230
|
+
let displayPath = capture.path || "/";
|
|
231
|
+
const binMatch = displayPath.match(/^\/api\/bin\/[^/]+(\/.*)?$/);
|
|
232
|
+
if (binMatch) displayPath = binMatch[1] || "/";
|
|
233
|
+
|
|
234
|
+
const methodStr = (method || "GET").toUpperCase().padEnd(6);
|
|
217
235
|
console.log(
|
|
218
|
-
`${DIM}[${timestamp()}]${RESET} ${CYAN}\u2192 ${methodStr}${RESET} ${
|
|
236
|
+
`${DIM}[${timestamp()}]${RESET} ${CYAN}\u2192 ${methodStr}${RESET} ${displayPath}`
|
|
219
237
|
);
|
|
220
238
|
|
|
221
239
|
// Forward and report
|
|
@@ -225,7 +243,9 @@ async function pollForCaptures(serverUrl, token, tunnelId, localOrigin) {
|
|
|
225
243
|
`${DIM}[${timestamp()}]${RESET} ${color}\u2190 ${result.status} ${result.statusText}${RESET} ${DIM}(${result.durationMs}ms)${RESET}`
|
|
226
244
|
);
|
|
227
245
|
|
|
228
|
-
|
|
246
|
+
// Await the report so a passthrough request held open on the server
|
|
247
|
+
// is released as soon as possible (non-fatal if it fails).
|
|
248
|
+
await reportResponse(serverUrl, token, captureId, result).catch(() => {});
|
|
229
249
|
|
|
230
250
|
// Update cursor
|
|
231
251
|
if (capture.createdAt && capture.createdAt > lastSeen) {
|
|
@@ -245,8 +265,7 @@ async function pollForCaptures(serverUrl, token, tunnelId, localOrigin) {
|
|
|
245
265
|
}
|
|
246
266
|
|
|
247
267
|
if (aborted) return;
|
|
248
|
-
|
|
249
|
-
await new Promise((r) => setTimeout(r, 1000));
|
|
268
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
250
269
|
}
|
|
251
270
|
}
|
|
252
271
|
|
|
@@ -260,16 +279,16 @@ async function pollForCaptures(serverUrl, token, tunnelId, localOrigin) {
|
|
|
260
279
|
* @param {string} [opts.bin] - Bin slug to listen on (optional, server can assign)
|
|
261
280
|
* @param {string} [opts.name] - Human-readable tunnel name
|
|
262
281
|
*/
|
|
263
|
-
export async function startTunnel({ port, bin, name }) {
|
|
282
|
+
export async function startTunnel({ port, bin, name, url, token: tokenOverride, passthrough }) {
|
|
264
283
|
const config = loadConfig();
|
|
265
284
|
|
|
266
|
-
|
|
267
|
-
|
|
285
|
+
const serverUrl = url || config.url;
|
|
286
|
+
const token = tokenOverride || config.token;
|
|
287
|
+
|
|
288
|
+
if (!token) {
|
|
289
|
+
console.log(`${RED}Not logged in.${RESET} Run ${BOLD}reqly login${RESET} first, or pass ${BOLD}--token${RESET}.`);
|
|
268
290
|
process.exit(1);
|
|
269
291
|
}
|
|
270
|
-
|
|
271
|
-
const serverUrl = config.url;
|
|
272
|
-
const token = config.token;
|
|
273
292
|
const localOrigin = `http://localhost:${port}`;
|
|
274
293
|
|
|
275
294
|
// 1. Register the tunnel
|
|
@@ -281,7 +300,8 @@ export async function startTunnel({ port, bin, name }) {
|
|
|
281
300
|
token,
|
|
282
301
|
name || `cli-${port}`,
|
|
283
302
|
port,
|
|
284
|
-
bin
|
|
303
|
+
bin,
|
|
304
|
+
passthrough
|
|
285
305
|
);
|
|
286
306
|
console.log(` ${GREEN}done${RESET}`);
|
|
287
307
|
} catch (err) {
|
|
@@ -296,7 +316,18 @@ export async function startTunnel({ port, bin, name }) {
|
|
|
296
316
|
|
|
297
317
|
// 2. Print banner
|
|
298
318
|
printBanner(publicUrl, localOrigin, true);
|
|
319
|
+
if (passthrough) {
|
|
320
|
+
console.log(
|
|
321
|
+
`${DIM}[${timestamp()}]${RESET} ${CYAN}Passthrough mode${RESET} ${DIM}— relaying real responses (serves HTML/redirects)${RESET}`
|
|
322
|
+
);
|
|
323
|
+
}
|
|
299
324
|
|
|
300
|
-
// 3.
|
|
301
|
-
await pollForCaptures(
|
|
325
|
+
// 3. Start forwarding. Passthrough holds the caller open, so poll faster.
|
|
326
|
+
await pollForCaptures(
|
|
327
|
+
serverUrl,
|
|
328
|
+
token,
|
|
329
|
+
tunnelId,
|
|
330
|
+
localOrigin,
|
|
331
|
+
passthrough ? 250 : 1000
|
|
332
|
+
);
|
|
302
333
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reqly-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Reqly CLI — tunnel webhooks to your local dev server",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"reqly": "
|
|
7
|
+
"reqly": "bin/reqly.mjs"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bin/",
|