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 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() {
@@ -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 || "POST",
82
- headers: headers || {},
83
- body: body != null ? (typeof body === "string" ? body : JSON.stringify(body)) : undefined,
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
- const methodStr = (method || "POST").toUpperCase().padEnd(6);
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} ${path || "/"}`
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
- reportResponse(serverUrl, token, captureId, result).catch(() => {});
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
- // Poll every 1 second
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
- if (!config.token) {
267
- console.log(`${RED}Not logged in.${RESET} Run ${BOLD}reqly login${RESET} first.`);
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. Connect SSE and start forwarding
301
- await pollForCaptures(serverUrl, token, tunnelId, localOrigin);
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.2.2",
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": "./bin/reqly.mjs"
7
+ "reqly": "bin/reqly.mjs"
8
8
  },
9
9
  "files": [
10
10
  "bin/",