reqly-cli 0.1.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 ADDED
@@ -0,0 +1,80 @@
1
+ # reqly-cli
2
+
3
+ Tunnel webhooks from [Reqly](https://req.invenit.dev) to your local dev server. Reqly captures incoming HTTP requests (webhooks, API calls, etc.) and this CLI forwards them to `localhost` in real time so you can develop and debug locally without exposing your machine to the internet.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ # 1. Install globally
9
+ npm install -g reqly-cli
10
+
11
+ # 2. Log in with your API token (create one at Settings > Tokens)
12
+ reqly login
13
+
14
+ # 3. Start the tunnel
15
+ reqly tunnel --port 3000
16
+ ```
17
+
18
+ ## Commands
19
+
20
+ ### `reqly tunnel`
21
+
22
+ Start a tunnel that forwards captured requests to your local server.
23
+
24
+ ```bash
25
+ reqly tunnel --port 3000
26
+ reqly tunnel --port 8080 --bin my-webhook
27
+ reqly tunnel --port 3000 --name "stripe hooks"
28
+ ```
29
+
30
+ | Option | Description | Default |
31
+ |--------|-------------|---------|
32
+ | `--port <n>` | Local port to forward to | `3000` |
33
+ | `--bin <slug>` | Bin slug to listen on | assigned by server |
34
+ | `--name <s>` | Human-readable tunnel name | `cli-<port>` |
35
+
36
+ ### `reqly login`
37
+
38
+ Authenticate by pasting an API token you created in the Reqly web UI.
39
+
40
+ ### `reqly logout`
41
+
42
+ Remove the saved token from your machine.
43
+
44
+ ### `reqly status`
45
+
46
+ Show current configuration and verify your token is valid.
47
+
48
+ ### `reqly --help`
49
+
50
+ Show help.
51
+
52
+ ### `reqly --version`
53
+
54
+ Show version.
55
+
56
+ ## How it works
57
+
58
+ 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.
60
+ 3. Each request is forwarded to your local server (`http://localhost:<port>`).
61
+ 4. The response from your local server is reported back to Reqly so you can inspect it in the dashboard.
62
+
63
+ The connection auto-reconnects with exponential backoff if interrupted.
64
+
65
+ ## Configuration
66
+
67
+ Config is stored at `~/.reqly/config.json`:
68
+
69
+ ```json
70
+ {
71
+ "url": "https://req.invenit.dev",
72
+ "token": "rqly_xxx...",
73
+ "defaultPort": 3000
74
+ }
75
+ ```
76
+
77
+ ## Requirements
78
+
79
+ - Node.js 18 or later
80
+ - No external dependencies
package/bin/reqly.mjs ADDED
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * reqly — CLI entry point
5
+ *
6
+ * Commands:
7
+ * reqly tunnel --port 3000 Start tunnel to forward requests to localhost
8
+ * reqly login Authenticate and save API token
9
+ * reqly logout Remove saved token
10
+ * reqly status Show current connection info
11
+ * reqly --help Show help
12
+ * reqly --version Show version
13
+ */
14
+
15
+ import { readFileSync } from "node:fs";
16
+ import { fileURLToPath } from "node:url";
17
+ import { dirname, join } from "node:path";
18
+
19
+ import { login, logout, verifyToken } from "../lib/auth.mjs";
20
+ import { loadConfig, getConfigPath } from "../lib/config.mjs";
21
+ import { startTunnel } from "../lib/tunnel.mjs";
22
+
23
+ // ── ANSI helpers ────────────────────────────────────────────────────
24
+ const BOLD = "\x1b[1m";
25
+ const DIM = "\x1b[2m";
26
+ const GREEN = "\x1b[32m";
27
+ const RED = "\x1b[31m";
28
+ const CYAN = "\x1b[36m";
29
+ const RESET = "\x1b[0m";
30
+
31
+ // ── Read version from package.json ──────────────────────────────────
32
+ const __dirname = dirname(fileURLToPath(import.meta.url));
33
+ const pkg = JSON.parse(
34
+ readFileSync(join(__dirname, "..", "package.json"), "utf-8")
35
+ );
36
+
37
+ // ── Argument parsing ────────────────────────────────────────────────
38
+ const args = process.argv.slice(2);
39
+ const command = args[0];
40
+
41
+ function getFlag(name) {
42
+ const idx = args.indexOf(name);
43
+ if (idx === -1) return undefined;
44
+ return args[idx + 1];
45
+ }
46
+
47
+ function hasFlag(name) {
48
+ return args.includes(name);
49
+ }
50
+
51
+ // ── Help ────────────────────────────────────────────────────────────
52
+ function printHelp() {
53
+ console.log(`
54
+ ${BOLD}reqly${RESET} — tunnel webhooks to your local dev server
55
+
56
+ ${BOLD}Usage:${RESET}
57
+ reqly <command> [options]
58
+
59
+ ${BOLD}Commands:${RESET}
60
+ tunnel Start a tunnel to forward requests to localhost
61
+ login Authenticate and save your API token
62
+ logout Remove saved token
63
+ status Show connection status and config info
64
+
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)
69
+
70
+ ${BOLD}Global options:${RESET}
71
+ --help Show this help message
72
+ --version Show version
73
+
74
+ ${BOLD}Examples:${RESET}
75
+ ${DIM}# Log in with your API token${RESET}
76
+ reqly login
77
+
78
+ ${DIM}# Start tunnel forwarding to port 3000${RESET}
79
+ reqly tunnel --port 3000
80
+
81
+ ${DIM}# Start tunnel on a specific bin${RESET}
82
+ reqly tunnel --port 8080 --bin my-webhook
83
+
84
+ ${BOLD}Docs:${RESET} https://req.invenit.dev/docs
85
+ `);
86
+ }
87
+
88
+ // ── Status ──────────────────────────────────────────────────────────
89
+ async function showStatus() {
90
+ const config = loadConfig();
91
+
92
+ console.log();
93
+ console.log(`${BOLD}Reqly Status${RESET}`);
94
+ console.log();
95
+ console.log(` Server: ${config.url}`);
96
+ console.log(` Config: ${getConfigPath()}`);
97
+ console.log(` Port: ${config.defaultPort}`);
98
+
99
+ if (!config.token) {
100
+ console.log(` Auth: ${RED}Not logged in${RESET}`);
101
+ console.log();
102
+ console.log(`Run ${BOLD}reqly login${RESET} to authenticate.`);
103
+ return;
104
+ }
105
+
106
+ // Verify token
107
+ process.stdout.write(` Auth: `);
108
+ const result = await verifyToken(config.url, config.token);
109
+
110
+ if (result.valid) {
111
+ console.log(`${GREEN}Authenticated${RESET}`);
112
+ } else {
113
+ console.log(`${RED}Token invalid or server unreachable${RESET}`);
114
+ }
115
+ console.log();
116
+ }
117
+
118
+ // ── Main ────────────────────────────────────────────────────────────
119
+ async function main() {
120
+ // Global flags
121
+ if (hasFlag("--version") || hasFlag("-v")) {
122
+ console.log(`reqly ${pkg.version}`);
123
+ return;
124
+ }
125
+
126
+ if (hasFlag("--help") || hasFlag("-h") || !command) {
127
+ printHelp();
128
+ return;
129
+ }
130
+
131
+ switch (command) {
132
+ case "tunnel": {
133
+ const config = loadConfig();
134
+ const port = parseInt(getFlag("--port") || config.defaultPort, 10);
135
+ const bin = getFlag("--bin");
136
+ const name = getFlag("--name");
137
+
138
+ if (isNaN(port) || port < 1 || port > 65535) {
139
+ console.log(`${RED}Invalid port number.${RESET} Use --port <1-65535>`);
140
+ process.exit(1);
141
+ }
142
+
143
+ await startTunnel({ port, bin, name });
144
+ break;
145
+ }
146
+
147
+ case "login":
148
+ await login();
149
+ break;
150
+
151
+ case "logout":
152
+ await logout();
153
+ break;
154
+
155
+ case "status":
156
+ await showStatus();
157
+ break;
158
+
159
+ default:
160
+ console.log(`${RED}Unknown command: ${command}${RESET}`);
161
+ console.log(`Run ${BOLD}reqly --help${RESET} for usage.`);
162
+ process.exit(1);
163
+ }
164
+ }
165
+
166
+ main().catch((err) => {
167
+ console.error(`${RED}Fatal: ${err.message}${RESET}`);
168
+ process.exit(1);
169
+ });
package/lib/auth.mjs ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Authentication helpers for Reqly CLI.
3
+ *
4
+ * Users create API tokens in the Reqly web UI, then paste them here.
5
+ * The CLI verifies the token against the server before saving.
6
+ */
7
+
8
+ import { createInterface } from "node:readline";
9
+ import { loadConfig, saveConfig } from "./config.mjs";
10
+
11
+ // ── ANSI helpers ────────────────────────────────────────────────────
12
+ const BOLD = "\x1b[1m";
13
+ const GREEN = "\x1b[32m";
14
+ const RED = "\x1b[31m";
15
+ const DIM = "\x1b[2m";
16
+ const RESET = "\x1b[0m";
17
+
18
+ // ── Readline prompt ─────────────────────────────────────────────────
19
+
20
+ function prompt(question) {
21
+ const rl = createInterface({
22
+ input: process.stdin,
23
+ output: process.stdout,
24
+ });
25
+ return new Promise((resolve) => {
26
+ rl.question(question, (answer) => {
27
+ rl.close();
28
+ resolve(answer.trim());
29
+ });
30
+ });
31
+ }
32
+
33
+ // ── Public API ──────────────────────────────────────────────────────
34
+
35
+ /**
36
+ * Verify an API token against the Reqly server.
37
+ * Returns { valid: boolean, userId?: string }.
38
+ */
39
+ export async function verifyToken(url, token) {
40
+ try {
41
+ const res = await fetch(`${url}/api/tokens/verify`, {
42
+ method: "POST",
43
+ headers: {
44
+ "Content-Type": "application/json",
45
+ Authorization: `Bearer ${token}`,
46
+ },
47
+ body: JSON.stringify({ token }),
48
+ });
49
+
50
+ if (!res.ok) {
51
+ return { valid: false };
52
+ }
53
+
54
+ const data = await res.json();
55
+ return { valid: true, userId: data.userId };
56
+ } catch {
57
+ return { valid: false };
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Interactive login flow.
63
+ * Prompts the user for their API token, verifies it, and saves to config.
64
+ */
65
+ export async function login() {
66
+ const config = loadConfig();
67
+
68
+ console.log();
69
+ console.log(`${BOLD}Reqly Login${RESET}`);
70
+ console.log();
71
+ console.log(
72
+ `${DIM}Create an API token in your Reqly dashboard at:${RESET}`
73
+ );
74
+ console.log(` ${config.url}/settings/tokens`);
75
+ console.log();
76
+
77
+ const token = await prompt("Paste your API token: ");
78
+
79
+ if (!token) {
80
+ console.log(`${RED}No token provided. Aborting.${RESET}`);
81
+ process.exit(1);
82
+ }
83
+
84
+ // Verify the token with the server
85
+ process.stdout.write(`${DIM}Verifying token...${RESET}`);
86
+ const result = await verifyToken(config.url, token);
87
+
88
+ if (!result.valid) {
89
+ console.log(` ${RED}invalid${RESET}`);
90
+ console.log();
91
+ console.log("Could not verify the token. Check that:");
92
+ console.log(" - The token is correct");
93
+ console.log(` - The server at ${config.url} is reachable`);
94
+ process.exit(1);
95
+ }
96
+
97
+ console.log(` ${GREEN}verified${RESET}`);
98
+
99
+ // Save token to config
100
+ saveConfig({ ...config, token });
101
+
102
+ console.log();
103
+ console.log(`${GREEN}Logged in successfully.${RESET}`);
104
+ console.log(`${DIM}Token saved to ~/.reqly/config.json${RESET}`);
105
+ }
106
+
107
+ /**
108
+ * Remove the saved token from config.
109
+ */
110
+ export async function logout() {
111
+ const config = loadConfig();
112
+ delete config.token;
113
+ saveConfig(config);
114
+
115
+ console.log();
116
+ console.log(`${GREEN}Logged out.${RESET} Token removed from config.`);
117
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Configuration management for Reqly CLI.
3
+ *
4
+ * Stores config at ~/.reqly/config.json with the following shape:
5
+ * { url: string, token: string, defaultPort: number }
6
+ */
7
+
8
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { homedir } from "node:os";
11
+
12
+ const CONFIG_DIR = join(homedir(), ".reqly");
13
+ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
14
+
15
+ const DEFAULTS = {
16
+ url: "https://req.invenit.dev",
17
+ token: null,
18
+ defaultPort: 3000,
19
+ };
20
+
21
+ /**
22
+ * Returns the absolute path to the config file.
23
+ */
24
+ export function getConfigPath() {
25
+ return CONFIG_FILE;
26
+ }
27
+
28
+ /**
29
+ * Load config from disk, merging with defaults for any missing keys.
30
+ * Returns defaults if the file doesn't exist yet.
31
+ */
32
+ export function loadConfig() {
33
+ try {
34
+ const raw = readFileSync(CONFIG_FILE, "utf-8");
35
+ const stored = JSON.parse(raw);
36
+ return { ...DEFAULTS, ...stored };
37
+ } catch {
38
+ // File doesn't exist or is invalid — return defaults
39
+ return { ...DEFAULTS };
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Persist config to ~/.reqly/config.json.
45
+ * Creates the directory if it doesn't exist.
46
+ */
47
+ export function saveConfig(config) {
48
+ mkdirSync(CONFIG_DIR, { recursive: true });
49
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", "utf-8");
50
+ }
package/lib/tunnel.mjs ADDED
@@ -0,0 +1,336 @@
1
+ /**
2
+ * SSE-based tunnel that receives webhook requests from Reqly and
3
+ * forwards them to a local dev server.
4
+ *
5
+ * Flow:
6
+ * 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
9
+ * POST /api/tunnel/response → report the response back
10
+ *
11
+ * Reconnects automatically with exponential backoff + jitter.
12
+ */
13
+
14
+ import { loadConfig } from "./config.mjs";
15
+
16
+ // ── ANSI helpers ────────────────────────────────────────────────────
17
+ const BOLD = "\x1b[1m";
18
+ const DIM = "\x1b[2m";
19
+ const GREEN = "\x1b[32m";
20
+ const YELLOW = "\x1b[33m";
21
+ const RED = "\x1b[31m";
22
+ const CYAN = "\x1b[36m";
23
+ const RESET = "\x1b[0m";
24
+
25
+ // ── Backoff settings ────────────────────────────────────────────────
26
+ const BACKOFF_INITIAL_MS = 1000;
27
+ const BACKOFF_MAX_MS = 60_000;
28
+ const BACKOFF_FACTOR = 2;
29
+
30
+ // ── Helpers ─────────────────────────────────────────────────────────
31
+
32
+ function timestamp() {
33
+ return new Date().toLocaleTimeString("en-GB", { hour12: false });
34
+ }
35
+
36
+ function statusDot(color) {
37
+ return `${color}\u25CF${RESET}`;
38
+ }
39
+
40
+ function printBanner(publicUrl, localUrl, connected) {
41
+ const status = connected
42
+ ? `${statusDot(GREEN)} Connected`
43
+ : `${statusDot(YELLOW)} Connecting...`;
44
+
45
+ const lines = [
46
+ "",
47
+ `${DIM}\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510${RESET}`,
48
+ `${DIM}\u2502${RESET} ${BOLD}Reqly Tunnel${RESET} ${DIM}\u2502${RESET}`,
49
+ `${DIM}\u2502${RESET} ${DIM}\u2502${RESET}`,
50
+ `${DIM}\u2502${RESET} Public URL: ${CYAN}${publicUrl}${RESET}`,
51
+ `${DIM}\u2502${RESET} Forwarding: \u2192 ${localUrl}`,
52
+ `${DIM}\u2502${RESET} Status: ${status}`,
53
+ `${DIM}\u2502${RESET} ${DIM}\u2502${RESET}`,
54
+ `${DIM}\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518${RESET}`,
55
+ "",
56
+ ];
57
+
58
+ console.log(lines.join("\n"));
59
+ }
60
+
61
+ /**
62
+ * Forward a captured request to the local dev server.
63
+ * Returns { status, headers, body, durationMs }.
64
+ */
65
+ async function forwardRequest(localOrigin, capture) {
66
+ const { method, path, headers, body } = capture;
67
+ const url = `${localOrigin}${path}`;
68
+
69
+ const start = Date.now();
70
+
71
+ try {
72
+ const res = await fetch(url, {
73
+ method: method || "POST",
74
+ headers: headers || {},
75
+ body: body != null ? (typeof body === "string" ? body : JSON.stringify(body)) : undefined,
76
+ });
77
+
78
+ const resBody = await res.text();
79
+ const durationMs = Date.now() - start;
80
+
81
+ return {
82
+ status: res.status,
83
+ statusText: res.statusText,
84
+ headers: Object.fromEntries(res.headers.entries()),
85
+ body: resBody,
86
+ durationMs,
87
+ };
88
+ } catch (err) {
89
+ const durationMs = Date.now() - start;
90
+ return {
91
+ status: 502,
92
+ statusText: "Bad Gateway",
93
+ headers: {},
94
+ body: `Reqly CLI: could not reach local server — ${err.message}`,
95
+ durationMs,
96
+ };
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Report the forwarded response back to the Reqly server.
102
+ */
103
+ async function reportResponse(serverUrl, token, captureId, result) {
104
+ try {
105
+ await fetch(`${serverUrl}/api/tunnel/response`, {
106
+ method: "POST",
107
+ headers: {
108
+ "Content-Type": "application/json",
109
+ Authorization: `Bearer ${token}`,
110
+ },
111
+ body: JSON.stringify({
112
+ captureId,
113
+ status: result.status,
114
+ statusText: result.statusText,
115
+ headers: result.headers,
116
+ body: result.body,
117
+ }),
118
+ });
119
+ } catch {
120
+ // Non-fatal — the tunnel keeps working even if reporting fails
121
+ console.log(
122
+ `${DIM}[${timestamp()}]${RESET} ${YELLOW}Could not report response for ${captureId}${RESET}`
123
+ );
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Parse a stream of SSE text into events.
129
+ * Yields { event, data } objects.
130
+ */
131
+ function parseSSELine(line, current) {
132
+ if (line.startsWith("event:")) {
133
+ current.event = line.slice(6).trim();
134
+ } else if (line.startsWith("data:")) {
135
+ current.data = (current.data || "") + line.slice(5).trim();
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Register this tunnel with the server.
141
+ * Returns { tunnelId, binSlug, binUrl }.
142
+ */
143
+ async function registerTunnel(serverUrl, token, name, port, binSlug) {
144
+ const res = await fetch(`${serverUrl}/api/tunnel/register`, {
145
+ method: "POST",
146
+ headers: {
147
+ "Content-Type": "application/json",
148
+ Authorization: `Bearer ${token}`,
149
+ },
150
+ body: JSON.stringify({ token, name, port, binSlug }),
151
+ });
152
+
153
+ if (!res.ok) {
154
+ const text = await res.text().catch(() => "");
155
+ throw new Error(`Registration failed (${res.status}): ${text}`);
156
+ }
157
+
158
+ return res.json();
159
+ }
160
+
161
+ /**
162
+ * Connect to the SSE stream and process incoming requests.
163
+ * Handles reconnection with exponential backoff.
164
+ */
165
+ async function connectSSE(serverUrl, token, tunnelId, localOrigin) {
166
+ let backoff = BACKOFF_INITIAL_MS;
167
+ let aborted = false;
168
+ let controller;
169
+
170
+ // Clean shutdown
171
+ function shutdown() {
172
+ aborted = true;
173
+ if (controller) controller.abort();
174
+ console.log();
175
+ console.log(`${DIM}[${timestamp()}]${RESET} Tunnel closed.`);
176
+ process.exit(0);
177
+ }
178
+ process.on("SIGINT", shutdown);
179
+ process.on("SIGTERM", shutdown);
180
+
181
+ while (!aborted) {
182
+ controller = new AbortController();
183
+
184
+ try {
185
+ const sseUrl = `${serverUrl}/api/tunnel/connect?token=${encodeURIComponent(token)}&tunnelId=${encodeURIComponent(tunnelId)}`;
186
+
187
+ const res = await fetch(sseUrl, {
188
+ headers: { Accept: "text/event-stream" },
189
+ signal: controller.signal,
190
+ });
191
+
192
+ if (!res.ok) {
193
+ throw new Error(`SSE connection failed (${res.status})`);
194
+ }
195
+
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
+ }
226
+ }
227
+ }
228
+
229
+ // Stream ended normally — server closed the connection
230
+ console.log(
231
+ `${DIM}[${timestamp()}]${RESET} ${YELLOW}Stream ended, reconnecting...${RESET}`
232
+ );
233
+ } catch (err) {
234
+ if (aborted) return;
235
+ console.log(
236
+ `${DIM}[${timestamp()}]${RESET} ${RED}Connection error: ${err.message}${RESET}`
237
+ );
238
+ }
239
+
240
+ 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);
250
+ }
251
+ }
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
+ // ── Public API ──────────────────────────────────────────────────────
288
+
289
+ /**
290
+ * Start the tunnel.
291
+ *
292
+ * @param {object} opts
293
+ * @param {number} opts.port - Local port to forward to
294
+ * @param {string} [opts.bin] - Bin slug to listen on (optional, server can assign)
295
+ * @param {string} [opts.name] - Human-readable tunnel name
296
+ */
297
+ export async function startTunnel({ port, bin, name }) {
298
+ const config = loadConfig();
299
+
300
+ if (!config.token) {
301
+ console.log(`${RED}Not logged in.${RESET} Run ${BOLD}reqly login${RESET} first.`);
302
+ process.exit(1);
303
+ }
304
+
305
+ const serverUrl = config.url;
306
+ const token = config.token;
307
+ const localOrigin = `http://localhost:${port}`;
308
+
309
+ // 1. Register the tunnel
310
+ let registration;
311
+ try {
312
+ process.stdout.write(`${DIM}Registering tunnel...${RESET}`);
313
+ registration = await registerTunnel(
314
+ serverUrl,
315
+ token,
316
+ name || `cli-${port}`,
317
+ port,
318
+ bin
319
+ );
320
+ console.log(` ${GREEN}done${RESET}`);
321
+ } catch (err) {
322
+ console.log(` ${RED}failed${RESET}`);
323
+ console.log();
324
+ console.log(`${RED}${err.message}${RESET}`);
325
+ process.exit(1);
326
+ }
327
+
328
+ const { tunnelId, binSlug, binUrl } = registration;
329
+ const publicUrl = binUrl || `${serverUrl}/api/bin/${binSlug}`;
330
+
331
+ // 2. Print banner
332
+ printBanner(publicUrl, localOrigin, true);
333
+
334
+ // 3. Connect SSE and start forwarding
335
+ await connectSSE(serverUrl, token, tunnelId, localOrigin);
336
+ }
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "reqly-cli",
3
+ "version": "0.1.0",
4
+ "description": "Reqly CLI — tunnel webhooks to your local dev server",
5
+ "type": "module",
6
+ "bin": {
7
+ "reqly": "./bin/reqly.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "lib/",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "keywords": ["api", "tunnel", "webhook", "reqly", "postman", "api-testing", "localhost-tunnel"],
18
+ "author": "Invenit",
19
+ "license": "MIT",
20
+ "homepage": "https://req.invenit.dev",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+ssh://git@ssh.dev.azure.com/v3/INVENIT4/Proto/req"
24
+ }
25
+ }