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 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 opens a Server-Sent Events (SSE) connection to receive incoming requests in real time.
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
- The connection auto-reconnects with exponential backoff if interrupted.
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> Local port to forward to (default: 3000)
67
- --bin <slug> Bin slug to listen on (optional)
68
- --name <s> Human-readable tunnel name (optional)
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
- * SSE-based tunnel that receives webhook requests from Reqly and
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/connect SSE stream of incoming requests
8
- * 3. For each event: forward to localhost, then
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
- * Reconnects automatically with exponential backoff + jitter.
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
- reportResponse(serverUrl, token, captureId, result).catch(() => {});
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
- // Poll every 1 second
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
- if (!config.token) {
286
- console.log(`${RED}Not logged in.${RESET} Run ${BOLD}reqly login${RESET} first.`);
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
- // 1. Register the tunnel
295
- let registration;
296
- try {
297
- process.stdout.write(`${DIM}Registering tunnel...${RESET}`);
298
- registration = await registerTunnel(
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
- bin
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. Connect SSE and start forwarding
320
- await pollForCaptures(serverUrl, token, tunnelId, localOrigin);
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.2.3",
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": "./bin/reqly.mjs"
7
+ "reqly": "bin/reqly.mjs"
8
8
  },
9
9
  "files": [
10
10
  "bin/",