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 +80 -0
- package/bin/reqly.mjs +169 -0
- package/lib/auth.mjs +117 -0
- package/lib/config.mjs +50 -0
- package/lib/tunnel.mjs +336 -0
- package/package.json +25 -0
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
|
+
}
|