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.
- package/lib/tunnel.mjs +64 -98
- 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
|
-
|
|
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
|
-
*
|
|
163
|
-
*
|
|
170
|
+
* Poll for new captures and forward them.
|
|
171
|
+
* More reliable than SSE through reverse proxies (Traefik, nginx).
|
|
164
172
|
*/
|
|
165
|
-
async function
|
|
166
|
-
let backoff = BACKOFF_INITIAL_MS;
|
|
173
|
+
async function pollForCaptures(serverUrl, token, tunnelId, localOrigin) {
|
|
167
174
|
let aborted = false;
|
|
168
|
-
let
|
|
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
|
-
|
|
182
|
-
|
|
187
|
+
console.log(
|
|
188
|
+
`${DIM}[${timestamp()}]${RESET} ${GREEN}Listening for requests...${RESET}`
|
|
189
|
+
);
|
|
183
190
|
|
|
191
|
+
while (!aborted) {
|
|
184
192
|
try {
|
|
185
|
-
const
|
|
193
|
+
const pollUrl = `${serverUrl}/api/tunnel/poll?token=${encodeURIComponent(token)}&tunnelId=${encodeURIComponent(tunnelId)}&since=${encodeURIComponent(lastSeen)}`;
|
|
186
194
|
|
|
187
|
-
const res = await fetch(
|
|
188
|
-
headers: {
|
|
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(`
|
|
200
|
+
throw new Error(`Poll failed (${res.status})`);
|
|
194
201
|
}
|
|
195
202
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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": [
|
|
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",
|