reqly-cli 0.1.0 → 0.2.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.
Files changed (2) hide show
  1. package/lib/tunnel.mjs +64 -98
  2. package/package.json +10 -2
package/lib/tunnel.mjs CHANGED
@@ -63,8 +63,16 @@ function printBanner(publicUrl, localUrl, connected) {
63
63
  * Returns { status, headers, body, durationMs }.
64
64
  */
65
65
  async function forwardRequest(localOrigin, capture) {
66
- const { method, path, headers, body } = capture;
67
- const url = `${localOrigin}${path}`;
66
+ const { method, path: rawPath, headers, body } = capture;
67
+ // Strip the /api/bin/SLUG prefix — forward the remainder to localhost
68
+ // e.g. /api/bin/my-slug/webhooks/stripe → /webhooks/stripe
69
+ // e.g. /api/bin/my-slug → /
70
+ let localPath = rawPath || "/";
71
+ const binPrefixMatch = localPath.match(/^\/api\/bin\/[^/]+(\/.*)?$/);
72
+ if (binPrefixMatch) {
73
+ localPath = binPrefixMatch[1] || "/";
74
+ }
75
+ const url = `${localOrigin}${localPath}`;
68
76
 
69
77
  const start = Date.now();
70
78
 
@@ -159,18 +167,16 @@ async function registerTunnel(serverUrl, token, name, port, binSlug) {
159
167
  }
160
168
 
161
169
  /**
162
- * Connect to the SSE stream and process incoming requests.
163
- * Handles reconnection with exponential backoff.
170
+ * Poll for new captures and forward them.
171
+ * More reliable than SSE through reverse proxies (Traefik, nginx).
164
172
  */
165
- async function connectSSE(serverUrl, token, tunnelId, localOrigin) {
166
- let backoff = BACKOFF_INITIAL_MS;
173
+ async function pollForCaptures(serverUrl, token, tunnelId, localOrigin) {
167
174
  let aborted = false;
168
- let controller;
175
+ let lastSeen = new Date().toISOString();
176
+ let consecutiveErrors = 0;
169
177
 
170
- // Clean shutdown
171
178
  function shutdown() {
172
179
  aborted = true;
173
- if (controller) controller.abort();
174
180
  console.log();
175
181
  console.log(`${DIM}[${timestamp()}]${RESET} Tunnel closed.`);
176
182
  process.exit(0);
@@ -178,112 +184,72 @@ async function connectSSE(serverUrl, token, tunnelId, localOrigin) {
178
184
  process.on("SIGINT", shutdown);
179
185
  process.on("SIGTERM", shutdown);
180
186
 
181
- while (!aborted) {
182
- controller = new AbortController();
187
+ console.log(
188
+ `${DIM}[${timestamp()}]${RESET} ${GREEN}Listening for requests...${RESET}`
189
+ );
183
190
 
191
+ while (!aborted) {
184
192
  try {
185
- const sseUrl = `${serverUrl}/api/tunnel/connect?token=${encodeURIComponent(token)}&tunnelId=${encodeURIComponent(tunnelId)}`;
193
+ const pollUrl = `${serverUrl}/api/tunnel/poll?token=${encodeURIComponent(token)}&tunnelId=${encodeURIComponent(tunnelId)}&since=${encodeURIComponent(lastSeen)}`;
186
194
 
187
- const res = await fetch(sseUrl, {
188
- headers: { Accept: "text/event-stream" },
189
- signal: controller.signal,
195
+ const res = await fetch(pollUrl, {
196
+ headers: { Authorization: `Bearer ${token}` },
190
197
  });
191
198
 
192
199
  if (!res.ok) {
193
- throw new Error(`SSE connection failed (${res.status})`);
200
+ throw new Error(`Poll failed (${res.status})`);
194
201
  }
195
202
 
196
- // Reset backoff on successful connection
197
- backoff = BACKOFF_INITIAL_MS;
198
- console.log(
199
- `${DIM}[${timestamp()}]${RESET} ${GREEN}Connected to tunnel stream${RESET}`
200
- );
201
-
202
- // Read the SSE stream line by line
203
- const reader = res.body.getReader();
204
- const decoder = new TextDecoder();
205
- let buffer = "";
206
- let current = {};
207
-
208
- while (true) {
209
- const { value, done } = await reader.read();
210
- if (done) break;
211
-
212
- buffer += decoder.decode(value, { stream: true });
213
- const lines = buffer.split("\n");
214
- buffer = lines.pop(); // keep incomplete line in buffer
215
-
216
- for (const line of lines) {
217
- if (line === "") {
218
- // Empty line = end of event
219
- if (current.data) {
220
- handleEvent(current, serverUrl, token, localOrigin);
221
- }
222
- current = {};
223
- } else {
224
- parseSSELine(line, current);
225
- }
203
+ consecutiveErrors = 0;
204
+ const data = await res.json();
205
+
206
+ for (const capture of data.captures || []) {
207
+ const { captureId, method, path } = capture;
208
+ if (!captureId) continue;
209
+
210
+ // Parse headers if they're a string
211
+ let headers = capture.headers;
212
+ if (typeof headers === "string") {
213
+ try { headers = JSON.parse(headers); } catch { headers = {}; }
226
214
  }
227
- }
228
215
 
229
- // Stream ended normally server closed the connection
230
- console.log(
231
- `${DIM}[${timestamp()}]${RESET} ${YELLOW}Stream ended, reconnecting...${RESET}`
232
- );
216
+ const methodStr = (method || "POST").toUpperCase().padEnd(6);
217
+ console.log(
218
+ `${DIM}[${timestamp()}]${RESET} ${CYAN}\u2192 ${methodStr}${RESET} ${path || "/"}`
219
+ );
220
+
221
+ // Forward and report
222
+ const result = await forwardRequest(localOrigin, { ...capture, headers });
223
+ const color = result.status < 400 ? GREEN : RED;
224
+ console.log(
225
+ `${DIM}[${timestamp()}]${RESET} ${color}\u2190 ${result.status} ${result.statusText}${RESET} ${DIM}(${result.durationMs}ms)${RESET}`
226
+ );
227
+
228
+ reportResponse(serverUrl, token, captureId, result).catch(() => {});
229
+
230
+ // Update cursor
231
+ if (capture.createdAt && capture.createdAt > lastSeen) {
232
+ lastSeen = capture.createdAt;
233
+ }
234
+ }
233
235
  } catch (err) {
234
236
  if (aborted) return;
235
- console.log(
236
- `${DIM}[${timestamp()}]${RESET} ${RED}Connection error: ${err.message}${RESET}`
237
- );
237
+ consecutiveErrors++;
238
+ if (consecutiveErrors <= 3) {
239
+ // Silent for first few errors
240
+ } else if (consecutiveErrors % 10 === 0) {
241
+ console.log(
242
+ `${DIM}[${timestamp()}]${RESET} ${RED}Poll error: ${err.message}${RESET}`
243
+ );
244
+ }
238
245
  }
239
246
 
240
247
  if (aborted) return;
241
-
242
- // Exponential backoff with jitter
243
- const jitter = Math.random() * backoff * 0.3;
244
- const delay = Math.min(backoff + jitter, BACKOFF_MAX_MS);
245
- console.log(
246
- `${DIM}[${timestamp()}]${RESET} ${YELLOW}Reconnecting in ${(delay / 1000).toFixed(1)}s...${RESET}`
247
- );
248
- await new Promise((r) => setTimeout(r, delay));
249
- backoff = Math.min(backoff * BACKOFF_FACTOR, BACKOFF_MAX_MS);
248
+ // Poll every 1 second
249
+ await new Promise((r) => setTimeout(r, 1000));
250
250
  }
251
251
  }
252
252
 
253
- /**
254
- * Handle a single SSE event: forward the request and report back.
255
- */
256
- function handleEvent(event, serverUrl, token, localOrigin) {
257
- // We only care about "request" events
258
- if (event.event && event.event !== "request" && event.event !== "message") {
259
- return;
260
- }
261
-
262
- let payload;
263
- try {
264
- payload = JSON.parse(event.data);
265
- } catch {
266
- return; // ignore malformed events (e.g. heartbeat pings)
267
- }
268
-
269
- const { captureId, method, path } = payload;
270
- if (!captureId) return;
271
-
272
- const methodStr = (method || "POST").toUpperCase().padEnd(6);
273
- console.log(
274
- `${DIM}[${timestamp()}]${RESET} ${CYAN}\u2192 ${methodStr}${RESET} ${path || "/"}`
275
- );
276
-
277
- // Fire-and-forget: forward + report (don't block the event loop)
278
- forwardRequest(localOrigin, payload).then((result) => {
279
- const color = result.status < 400 ? GREEN : RED;
280
- console.log(
281
- `${DIM}[${timestamp()}]${RESET} ${color}\u2190 ${result.status} ${result.statusText}${RESET} ${DIM}(${result.durationMs}ms)${RESET}`
282
- );
283
- return reportResponse(serverUrl, token, captureId, result);
284
- });
285
- }
286
-
287
253
  // ── Public API ──────────────────────────────────────────────────────
288
254
 
289
255
  /**
@@ -332,5 +298,5 @@ export async function startTunnel({ port, bin, name }) {
332
298
  printBanner(publicUrl, localOrigin, true);
333
299
 
334
300
  // 3. Connect SSE and start forwarding
335
- await connectSSE(serverUrl, token, tunnelId, localOrigin);
301
+ await pollForCaptures(serverUrl, token, tunnelId, localOrigin);
336
302
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reqly-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Reqly CLI — tunnel webhooks to your local dev server",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,15 @@
14
14
  "engines": {
15
15
  "node": ">=18"
16
16
  },
17
- "keywords": ["api", "tunnel", "webhook", "reqly", "postman", "api-testing", "localhost-tunnel"],
17
+ "keywords": [
18
+ "api",
19
+ "tunnel",
20
+ "webhook",
21
+ "reqly",
22
+ "postman",
23
+ "api-testing",
24
+ "localhost-tunnel"
25
+ ],
18
26
  "author": "Invenit",
19
27
  "license": "MIT",
20
28
  "homepage": "https://req.invenit.dev",