reqly-cli 0.2.3 → 0.3.1
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 +91 -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() {
|
|
@@ -162,14 +161,14 @@ function parseSSELine(line, current) {
|
|
|
162
161
|
* Register this tunnel with the server.
|
|
163
162
|
* Returns { tunnelId, binSlug, binUrl }.
|
|
164
163
|
*/
|
|
165
|
-
async function registerTunnel(serverUrl, token, name, port, binSlug) {
|
|
164
|
+
async function registerTunnel(serverUrl, token, name, port, binSlug, passthrough) {
|
|
166
165
|
const res = await fetch(`${serverUrl}/api/tunnel/register`, {
|
|
167
166
|
method: "POST",
|
|
168
167
|
headers: {
|
|
169
168
|
"Content-Type": "application/json",
|
|
170
169
|
Authorization: `Bearer ${token}`,
|
|
171
170
|
},
|
|
172
|
-
body: JSON.stringify({ token, name, port, binSlug }),
|
|
171
|
+
body: JSON.stringify({ token, name, port, binSlug, passthrough: !!passthrough }),
|
|
173
172
|
});
|
|
174
173
|
|
|
175
174
|
if (!res.ok) {
|
|
@@ -183,8 +182,14 @@ async function registerTunnel(serverUrl, token, name, port, binSlug) {
|
|
|
183
182
|
/**
|
|
184
183
|
* Poll for new captures and forward them.
|
|
185
184
|
* More reliable than SSE through reverse proxies (Traefik, nginx).
|
|
185
|
+
*
|
|
186
|
+
* The server's tunnel registry is in-memory with a short TTL, so after the
|
|
187
|
+
* laptop sleeps (poll loop frozen) or the server restarts, the tunnelId is
|
|
188
|
+
* forgotten and poll returns 404/401. When that happens we re-register via
|
|
189
|
+
* `reregister()` to get a fresh tunnelId and resume — so the tunnel
|
|
190
|
+
* self-heals instead of looping forever on a dead id.
|
|
186
191
|
*/
|
|
187
|
-
async function pollForCaptures(serverUrl, token, tunnelId, localOrigin) {
|
|
192
|
+
async function pollForCaptures({ serverUrl, token, tunnelId, localOrigin, pollIntervalMs = 1000, reregister }) {
|
|
188
193
|
let aborted = false;
|
|
189
194
|
let lastSeen = new Date().toISOString();
|
|
190
195
|
let consecutiveErrors = 0;
|
|
@@ -210,6 +215,22 @@ async function pollForCaptures(serverUrl, token, tunnelId, localOrigin) {
|
|
|
210
215
|
headers: { Authorization: `Bearer ${token}` },
|
|
211
216
|
});
|
|
212
217
|
|
|
218
|
+
// 404 = server forgot this tunnel (sleep/restart); 401 = token re-auth
|
|
219
|
+
// needed. Either way, re-register to get a fresh tunnelId and resume.
|
|
220
|
+
if (res.status === 404 || res.status === 401) {
|
|
221
|
+
console.log(
|
|
222
|
+
`${DIM}[${timestamp()}]${RESET} ${YELLOW}Tunnel dropped (${res.status}) — reconnecting...${RESET}`
|
|
223
|
+
);
|
|
224
|
+
const fresh = await reregister();
|
|
225
|
+
tunnelId = fresh.tunnelId;
|
|
226
|
+
consecutiveErrors = 0;
|
|
227
|
+
console.log(
|
|
228
|
+
`${DIM}[${timestamp()}]${RESET} ${GREEN}Reconnected.${RESET} ${DIM}Listening for requests...${RESET}`
|
|
229
|
+
);
|
|
230
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
213
234
|
if (!res.ok) {
|
|
214
235
|
throw new Error(`Poll failed (${res.status})`);
|
|
215
236
|
}
|
|
@@ -244,7 +265,9 @@ async function pollForCaptures(serverUrl, token, tunnelId, localOrigin) {
|
|
|
244
265
|
`${DIM}[${timestamp()}]${RESET} ${color}\u2190 ${result.status} ${result.statusText}${RESET} ${DIM}(${result.durationMs}ms)${RESET}`
|
|
245
266
|
);
|
|
246
267
|
|
|
247
|
-
|
|
268
|
+
// Await the report so a passthrough request held open on the server
|
|
269
|
+
// is released as soon as possible (non-fatal if it fails).
|
|
270
|
+
await reportResponse(serverUrl, token, captureId, result).catch(() => {});
|
|
248
271
|
|
|
249
272
|
// Update cursor
|
|
250
273
|
if (capture.createdAt && capture.createdAt > lastSeen) {
|
|
@@ -264,8 +287,7 @@ async function pollForCaptures(serverUrl, token, tunnelId, localOrigin) {
|
|
|
264
287
|
}
|
|
265
288
|
|
|
266
289
|
if (aborted) return;
|
|
267
|
-
|
|
268
|
-
await new Promise((r) => setTimeout(r, 1000));
|
|
290
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
269
291
|
}
|
|
270
292
|
}
|
|
271
293
|
|
|
@@ -279,29 +301,57 @@ async function pollForCaptures(serverUrl, token, tunnelId, localOrigin) {
|
|
|
279
301
|
* @param {string} [opts.bin] - Bin slug to listen on (optional, server can assign)
|
|
280
302
|
* @param {string} [opts.name] - Human-readable tunnel name
|
|
281
303
|
*/
|
|
282
|
-
export async function startTunnel({ port, bin, name }) {
|
|
304
|
+
export async function startTunnel({ port, bin, name, url, token: tokenOverride, passthrough }) {
|
|
283
305
|
const config = loadConfig();
|
|
284
306
|
|
|
285
|
-
|
|
286
|
-
|
|
307
|
+
const serverUrl = url || config.url;
|
|
308
|
+
const token = tokenOverride || config.token;
|
|
309
|
+
|
|
310
|
+
if (!token) {
|
|
311
|
+
console.log(`${RED}Not logged in.${RESET} Run ${BOLD}reqly login${RESET} first, or pass ${BOLD}--token${RESET}.`);
|
|
287
312
|
process.exit(1);
|
|
288
313
|
}
|
|
289
|
-
|
|
290
|
-
const serverUrl = config.url;
|
|
291
|
-
const token = config.token;
|
|
292
314
|
const localOrigin = `http://localhost:${port}`;
|
|
293
315
|
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
316
|
+
// Re-register against the SAME bin slug each time so the public URL stays
|
|
317
|
+
// stable across reconnects. On the first call `bin` may be undefined (the
|
|
318
|
+
// server auto-creates a bin); afterwards we reuse the slug it returned.
|
|
319
|
+
let knownBinSlug = bin;
|
|
320
|
+
async function register() {
|
|
321
|
+
const reg = await registerTunnel(
|
|
299
322
|
serverUrl,
|
|
300
323
|
token,
|
|
301
324
|
name || `cli-${port}`,
|
|
302
325
|
port,
|
|
303
|
-
|
|
326
|
+
knownBinSlug,
|
|
327
|
+
passthrough
|
|
304
328
|
);
|
|
329
|
+
knownBinSlug = reg.binSlug;
|
|
330
|
+
return reg;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Resilient re-register: keep retrying with backoff until the server is
|
|
334
|
+
// reachable again (e.g. it's still booting after a restart).
|
|
335
|
+
async function reregister() {
|
|
336
|
+
let delay = 1000;
|
|
337
|
+
for (;;) {
|
|
338
|
+
try {
|
|
339
|
+
return await register();
|
|
340
|
+
} catch (err) {
|
|
341
|
+
console.log(
|
|
342
|
+
`${DIM}[${timestamp()}]${RESET} ${YELLOW}Reconnect failed: ${err.message} — retrying in ${Math.round(delay / 1000)}s${RESET}`
|
|
343
|
+
);
|
|
344
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
345
|
+
delay = Math.min(delay * 2, 30_000);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// 1. Register the tunnel
|
|
351
|
+
let registration;
|
|
352
|
+
try {
|
|
353
|
+
process.stdout.write(`${DIM}Registering tunnel...${RESET}`);
|
|
354
|
+
registration = await register();
|
|
305
355
|
console.log(` ${GREEN}done${RESET}`);
|
|
306
356
|
} catch (err) {
|
|
307
357
|
console.log(` ${RED}failed${RESET}`);
|
|
@@ -315,7 +365,19 @@ export async function startTunnel({ port, bin, name }) {
|
|
|
315
365
|
|
|
316
366
|
// 2. Print banner
|
|
317
367
|
printBanner(publicUrl, localOrigin, true);
|
|
368
|
+
if (passthrough) {
|
|
369
|
+
console.log(
|
|
370
|
+
`${DIM}[${timestamp()}]${RESET} ${CYAN}Passthrough mode${RESET} ${DIM}— relaying real responses (serves HTML/redirects)${RESET}`
|
|
371
|
+
);
|
|
372
|
+
}
|
|
318
373
|
|
|
319
|
-
// 3.
|
|
320
|
-
await pollForCaptures(
|
|
374
|
+
// 3. Start forwarding. Passthrough holds the caller open, so poll faster.
|
|
375
|
+
await pollForCaptures({
|
|
376
|
+
serverUrl,
|
|
377
|
+
token,
|
|
378
|
+
tunnelId,
|
|
379
|
+
localOrigin,
|
|
380
|
+
pollIntervalMs: passthrough ? 250 : 1000,
|
|
381
|
+
reregister,
|
|
382
|
+
});
|
|
321
383
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reqly-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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/",
|