reqly-cli 0.1.0 → 0.2.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.
Files changed (2) hide show
  1. package/lib/tunnel.mjs +54 -96
  2. package/package.json +1 -1
package/lib/tunnel.mjs CHANGED
@@ -159,18 +159,16 @@ async function registerTunnel(serverUrl, token, name, port, binSlug) {
159
159
  }
160
160
 
161
161
  /**
162
- * Connect to the SSE stream and process incoming requests.
163
- * Handles reconnection with exponential backoff.
162
+ * Poll for new captures and forward them.
163
+ * More reliable than SSE through reverse proxies (Traefik, nginx).
164
164
  */
165
- async function connectSSE(serverUrl, token, tunnelId, localOrigin) {
166
- let backoff = BACKOFF_INITIAL_MS;
165
+ async function pollForCaptures(serverUrl, token, tunnelId, localOrigin) {
167
166
  let aborted = false;
168
- let controller;
167
+ let lastSeen = new Date().toISOString();
168
+ let consecutiveErrors = 0;
169
169
 
170
- // Clean shutdown
171
170
  function shutdown() {
172
171
  aborted = true;
173
- if (controller) controller.abort();
174
172
  console.log();
175
173
  console.log(`${DIM}[${timestamp()}]${RESET} Tunnel closed.`);
176
174
  process.exit(0);
@@ -178,112 +176,72 @@ async function connectSSE(serverUrl, token, tunnelId, localOrigin) {
178
176
  process.on("SIGINT", shutdown);
179
177
  process.on("SIGTERM", shutdown);
180
178
 
181
- while (!aborted) {
182
- controller = new AbortController();
179
+ console.log(
180
+ `${DIM}[${timestamp()}]${RESET} ${GREEN}Listening for requests...${RESET}`
181
+ );
183
182
 
183
+ while (!aborted) {
184
184
  try {
185
- const sseUrl = `${serverUrl}/api/tunnel/connect?token=${encodeURIComponent(token)}&tunnelId=${encodeURIComponent(tunnelId)}`;
185
+ const pollUrl = `${serverUrl}/api/tunnel/poll?token=${encodeURIComponent(token)}&tunnelId=${encodeURIComponent(tunnelId)}&since=${encodeURIComponent(lastSeen)}`;
186
186
 
187
- const res = await fetch(sseUrl, {
188
- headers: { Accept: "text/event-stream" },
189
- signal: controller.signal,
187
+ const res = await fetch(pollUrl, {
188
+ headers: { Authorization: `Bearer ${token}` },
190
189
  });
191
190
 
192
191
  if (!res.ok) {
193
- throw new Error(`SSE connection failed (${res.status})`);
192
+ throw new Error(`Poll failed (${res.status})`);
194
193
  }
195
194
 
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
- }
195
+ consecutiveErrors = 0;
196
+ const data = await res.json();
197
+
198
+ for (const capture of data.captures || []) {
199
+ const { captureId, method, path } = capture;
200
+ if (!captureId) continue;
201
+
202
+ // Parse headers if they're a string
203
+ let headers = capture.headers;
204
+ if (typeof headers === "string") {
205
+ try { headers = JSON.parse(headers); } catch { headers = {}; }
226
206
  }
227
- }
228
207
 
229
- // Stream ended normally server closed the connection
230
- console.log(
231
- `${DIM}[${timestamp()}]${RESET} ${YELLOW}Stream ended, reconnecting...${RESET}`
232
- );
208
+ const methodStr = (method || "POST").toUpperCase().padEnd(6);
209
+ console.log(
210
+ `${DIM}[${timestamp()}]${RESET} ${CYAN}\u2192 ${methodStr}${RESET} ${path || "/"}`
211
+ );
212
+
213
+ // Forward and report
214
+ const result = await forwardRequest(localOrigin, { ...capture, headers });
215
+ const color = result.status < 400 ? GREEN : RED;
216
+ console.log(
217
+ `${DIM}[${timestamp()}]${RESET} ${color}\u2190 ${result.status} ${result.statusText}${RESET} ${DIM}(${result.durationMs}ms)${RESET}`
218
+ );
219
+
220
+ reportResponse(serverUrl, token, captureId, result).catch(() => {});
221
+
222
+ // Update cursor
223
+ if (capture.createdAt && capture.createdAt > lastSeen) {
224
+ lastSeen = capture.createdAt;
225
+ }
226
+ }
233
227
  } catch (err) {
234
228
  if (aborted) return;
235
- console.log(
236
- `${DIM}[${timestamp()}]${RESET} ${RED}Connection error: ${err.message}${RESET}`
237
- );
229
+ consecutiveErrors++;
230
+ if (consecutiveErrors <= 3) {
231
+ // Silent for first few errors
232
+ } else if (consecutiveErrors % 10 === 0) {
233
+ console.log(
234
+ `${DIM}[${timestamp()}]${RESET} ${RED}Poll error: ${err.message}${RESET}`
235
+ );
236
+ }
238
237
  }
239
238
 
240
239
  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);
240
+ // Poll every 1 second
241
+ await new Promise((r) => setTimeout(r, 1000));
250
242
  }
251
243
  }
252
244
 
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
245
  // ── Public API ──────────────────────────────────────────────────────
288
246
 
289
247
  /**
@@ -332,5 +290,5 @@ export async function startTunnel({ port, bin, name }) {
332
290
  printBanner(publicUrl, localOrigin, true);
333
291
 
334
292
  // 3. Connect SSE and start forwarding
335
- await connectSSE(serverUrl, token, tunnelId, localOrigin);
293
+ await pollForCaptures(serverUrl, token, tunnelId, localOrigin);
336
294
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reqly-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Reqly CLI — tunnel webhooks to your local dev server",
5
5
  "type": "module",
6
6
  "bin": {