@cvasingh/httpsurl 1.0.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,108 @@
1
+ # @httpsurl/client
2
+
3
+ CLI tool to expose local HTTP servers to the internet via httpsurl.in tunnels.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ # From the monorepo root
9
+ npm install
10
+
11
+ # Or install globally (after publishing)
12
+ npm install -g @httpsurl/client
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ### Login
18
+
19
+ ```bash
20
+ # Opens browser for Clerk authentication
21
+ httpsurl login
22
+
23
+ # Or provide token directly
24
+ httpsurl login --token <clerk-session-token>
25
+ ```
26
+
27
+ Token is stored in `~/.httpsurl/credentials.json` (chmod 600).
28
+
29
+ ### Create a Tunnel
30
+
31
+ ```bash
32
+ # Expose local port 3000
33
+ httpsurl http 3000
34
+
35
+ # Custom server URL
36
+ httpsurl http 8080 --server wss://httpsurl.in/connect
37
+
38
+ # Local development
39
+ httpsurl http 8080 --server ws://localhost:3000/connect
40
+ ```
41
+
42
+ Output:
43
+
44
+ ```
45
+ ℹ Connecting to wss://httpsurl.in/connect...
46
+ ℹ Connected, registering tunnel...
47
+ ✔ Tunnel registered!
48
+
49
+ httpsurl.in
50
+ ─────────────────────────────
51
+ ▸ Forwarding: http://abc123def.httpsurl.in
52
+ ─────────────────────────────
53
+
54
+ → GET / 200 12ms
55
+ → POST /api/data 201 45ms
56
+ → GET /style.css 200 3ms
57
+ ```
58
+
59
+ ## Modules
60
+
61
+ ### `cli.js` — Commander CLI
62
+ Two commands:
63
+ - `httpsurl login` — Opens browser to Clerk sign-in, receives token via localhost callback server
64
+ - `httpsurl http <port>` — Creates tunnel with `--server` option (default: `wss://httpsurl.in/connect`)
65
+
66
+ ### `tunnel-client.js` — WebSocket Client
67
+ - Connects to server, sends `register` message with Clerk token
68
+ - Receives `registered` with tunnel URL
69
+ - Handles `request` messages by proxying to local server via `local-proxy.js`
70
+ - Sends `response` or `response_error` back
71
+ - Auto-reconnects with exponential backoff on disconnect
72
+ - Preserves tunnel ID across reconnections
73
+
74
+ ### `local-proxy.js` — HTTP Proxy
75
+ - Makes HTTP requests to `127.0.0.1:<port>`
76
+ - Strips `host` and `x-tunnel-host` headers
77
+ - Base64 encodes/decodes request and response bodies
78
+ - 30s timeout per request
79
+
80
+ ### `reconnect.js` — Exponential Backoff
81
+ - Initial delay: 1s
82
+ - Doubles each attempt: 1s → 2s → 4s → 8s → 16s → 30s (max)
83
+ - Resets on successful reconnection
84
+ - Cancellable
85
+
86
+ ### `logger.js` — Terminal Output
87
+ Colored output using chalk:
88
+ - `info` — blue
89
+ - `success` — green
90
+ - `warn` — yellow
91
+ - `error` — red
92
+ - `request` — method-colored badges with status code and latency
93
+ - `banner` — formatted tunnel URL display
94
+
95
+ ### `protocol.js` — Message Constants
96
+ Shared message type constants (duplicated from server for simplicity):
97
+ - `MSG_REGISTER`, `MSG_RESPONSE`, `MSG_RESPONSE_ERROR`
98
+ - `MSG_REGISTERED`, `MSG_REQUEST`, `MSG_ERROR`
99
+
100
+ ## Dependencies
101
+
102
+ | Package | Purpose |
103
+ |---------|---------|
104
+ | ws | WebSocket client |
105
+ | commander | CLI argument parsing |
106
+ | chalk (v4, CJS) | Terminal colors |
107
+ | nanoid (v3, CJS) | Request ID generation |
108
+ | open (v8) | Open browser for login |
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { createCli } = require('../src/cli');
4
+
5
+ const program = createCli();
6
+ program.parse(process.argv);
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@cvasingh/httpsurl",
3
+ "version": "1.0.0",
4
+ "description": "Expose local servers to the internet via httpsurl.in tunnels",
5
+ "keywords": [
6
+ "tunnel",
7
+ "localhost",
8
+ "ngrok",
9
+ "proxy",
10
+ "cli",
11
+ "httpsurl"
12
+ ],
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/cvasingh/httpsurl.git",
17
+ "directory": "packages/client"
18
+ },
19
+ "author": "cvasingh",
20
+ "bin": {
21
+ "httpsurl": "bin/httpsurl.js"
22
+ },
23
+ "files": [
24
+ "bin/",
25
+ "src/",
26
+ "README.md"
27
+ ],
28
+ "scripts": {
29
+ "test": "vitest run",
30
+ "test:watch": "vitest"
31
+ },
32
+ "dependencies": {
33
+ "chalk": "^4.1.2",
34
+ "commander": "^12.0.0",
35
+ "nanoid": "^3.3.7",
36
+ "open": "^8.4.2",
37
+ "ws": "^8.16.0"
38
+ },
39
+ "devDependencies": {
40
+ "vitest": "^1.6.0"
41
+ },
42
+ "engines": {
43
+ "node": ">=18"
44
+ }
45
+ }
package/src/cli.js ADDED
@@ -0,0 +1,134 @@
1
+ const { Command } = require('commander');
2
+ const { TunnelClient } = require('./tunnel-client');
3
+ const log = require('./logger');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+
8
+ const CREDENTIALS_DIR = path.join(os.homedir(), '.httpsurl');
9
+ const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'credentials.json');
10
+
11
+ function loadToken() {
12
+ try {
13
+ const data = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, 'utf8'));
14
+ return data.token;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ function saveToken(token) {
21
+ fs.mkdirSync(CREDENTIALS_DIR, { recursive: true });
22
+ fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify({ token }, null, 2));
23
+ fs.chmodSync(CREDENTIALS_FILE, 0o600);
24
+ }
25
+
26
+ function createCli() {
27
+ const program = new Command();
28
+
29
+ program
30
+ .name('httpsurl')
31
+ .description('Expose local servers to the internet via httpsurl.in')
32
+ .version('1.0.0');
33
+
34
+ // Login command
35
+ program
36
+ .command('login')
37
+ .description('Authenticate with httpsurl.in')
38
+ .option('--token <token>', 'Provide token directly')
39
+ .action(async (options) => {
40
+ if (options.token) {
41
+ saveToken(options.token);
42
+ log.success('Token saved!');
43
+ return;
44
+ }
45
+
46
+ // Open browser for Clerk auth
47
+ const http = require('http');
48
+
49
+ const server = http.createServer((req, res) => {
50
+ const url = new URL(req.url, 'http://localhost');
51
+ const token = url.searchParams.get('token');
52
+
53
+ if (token) {
54
+ saveToken(token);
55
+ res.writeHead(200, { 'Content-Type': 'text/html' });
56
+ res.end(`
57
+ <html>
58
+ <body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;">
59
+ <div style="text-align: center;">
60
+ <h1 style="font-weight: 300;">✓ Authenticated</h1>
61
+ <p style="color: #666;">You can close this window and return to the terminal.</p>
62
+ </div>
63
+ </body>
64
+ </html>
65
+ `);
66
+ server.close();
67
+ log.success('Authenticated successfully!');
68
+ process.exit(0);
69
+ } else {
70
+ res.writeHead(400);
71
+ res.end('Missing token');
72
+ }
73
+ });
74
+
75
+ server.listen(0, async () => {
76
+ const port = server.address().port;
77
+ const callbackUrl = `http://localhost:${port}`;
78
+ const loginUrl = `https://httpsurl.in/sign-in?redirect_url=${encodeURIComponent(callbackUrl)}`;
79
+
80
+ log.info(`Opening browser for authentication...`);
81
+ log.info(`If browser doesn't open, visit: ${loginUrl}`);
82
+
83
+ try {
84
+ const open = require('open');
85
+ await open(loginUrl);
86
+ } catch {
87
+ log.warn('Could not open browser automatically');
88
+ }
89
+ });
90
+ });
91
+
92
+ // HTTP tunnel command
93
+ program
94
+ .command('http')
95
+ .description('Create a tunnel to a local HTTP server')
96
+ .argument('<port>', 'Local port to expose', parseInt)
97
+ .option('-s, --server <url>', 'Server WebSocket URL', 'wss://httpsurl.in/connect')
98
+ .action(async (port, options) => {
99
+ if (!port || port < 1 || port > 65535) {
100
+ log.error('Invalid port number');
101
+ process.exit(1);
102
+ }
103
+
104
+ const token = loadToken();
105
+ if (!token) {
106
+ log.error('Not authenticated. Run: httpsurl login');
107
+ process.exit(1);
108
+ }
109
+
110
+ const client = new TunnelClient({
111
+ serverUrl: options.server,
112
+ localPort: port,
113
+ token,
114
+ });
115
+
116
+ // Graceful shutdown
117
+ process.on('SIGINT', () => {
118
+ log.info('\nShutting down...');
119
+ client.close();
120
+ process.exit(0);
121
+ });
122
+
123
+ try {
124
+ await client.connect();
125
+ } catch (err) {
126
+ log.error(`Failed to connect: ${err.message}`);
127
+ process.exit(1);
128
+ }
129
+ });
130
+
131
+ return program;
132
+ }
133
+
134
+ module.exports = { createCli };
@@ -0,0 +1,52 @@
1
+ const http = require('http');
2
+
3
+ function makeLocalRequest(port, requestData) {
4
+ return new Promise((resolve, reject) => {
5
+ const { method, path, headers, body } = requestData;
6
+
7
+ // Remove headers that shouldn't be forwarded to local server
8
+ const localHeaders = { ...headers };
9
+ delete localHeaders.host;
10
+ delete localHeaders['x-tunnel-host'];
11
+
12
+ const options = {
13
+ hostname: '127.0.0.1',
14
+ port,
15
+ path,
16
+ method,
17
+ headers: localHeaders,
18
+ };
19
+
20
+ const req = http.request(options, (res) => {
21
+ const chunks = [];
22
+
23
+ res.on('data', (chunk) => chunks.push(chunk));
24
+
25
+ res.on('end', () => {
26
+ const responseBody = Buffer.concat(chunks);
27
+ resolve({
28
+ statusCode: res.statusCode,
29
+ headers: res.headers,
30
+ body: responseBody.length > 0 ? responseBody.toString('base64') : null,
31
+ });
32
+ });
33
+ });
34
+
35
+ req.on('error', (err) => {
36
+ reject(new Error(`Local server error: ${err.message}`));
37
+ });
38
+
39
+ // Set timeout
40
+ req.setTimeout(30000, () => {
41
+ req.destroy(new Error('Local server timeout'));
42
+ });
43
+
44
+ if (body) {
45
+ req.write(Buffer.from(body, 'base64'));
46
+ }
47
+
48
+ req.end();
49
+ });
50
+ }
51
+
52
+ module.exports = { makeLocalRequest };
package/src/logger.js ADDED
@@ -0,0 +1,45 @@
1
+ const chalk = require('chalk');
2
+
3
+ const log = {
4
+ info(msg, ...args) {
5
+ console.log(chalk.blue('ℹ'), msg, ...args);
6
+ },
7
+ success(msg, ...args) {
8
+ console.log(chalk.green('✔'), msg, ...args);
9
+ },
10
+ warn(msg, ...args) {
11
+ console.log(chalk.yellow('⚠'), msg, ...args);
12
+ },
13
+ error(msg, ...args) {
14
+ console.error(chalk.red('✖'), msg, ...args);
15
+ },
16
+ request(method, path, statusCode, latency) {
17
+ const methodColor = {
18
+ GET: chalk.cyan,
19
+ POST: chalk.green,
20
+ PUT: chalk.yellow,
21
+ DELETE: chalk.red,
22
+ PATCH: chalk.magenta,
23
+ };
24
+ const colorFn = methodColor[method] || chalk.white;
25
+ const statusColor = statusCode < 400 ? chalk.green : statusCode < 500 ? chalk.yellow : chalk.red;
26
+
27
+ console.log(
28
+ chalk.gray('→'),
29
+ colorFn(method.padEnd(7)),
30
+ chalk.white(path),
31
+ statusColor(statusCode),
32
+ chalk.gray(`${latency}ms`)
33
+ );
34
+ },
35
+ banner(url) {
36
+ console.log('');
37
+ console.log(chalk.bold(' httpsurl.in'));
38
+ console.log(chalk.gray(' ─────────────────────────────'));
39
+ console.log(` ${chalk.green('▸')} Forwarding: ${chalk.cyan.underline(url)}`);
40
+ console.log(chalk.gray(' ─────────────────────────────'));
41
+ console.log('');
42
+ },
43
+ };
44
+
45
+ module.exports = log;
@@ -0,0 +1,11 @@
1
+ module.exports = {
2
+ // Client → Server
3
+ MSG_REGISTER: 'register',
4
+ MSG_RESPONSE: 'response',
5
+ MSG_RESPONSE_ERROR: 'response_error',
6
+
7
+ // Server → Client
8
+ MSG_REGISTERED: 'registered',
9
+ MSG_REQUEST: 'request',
10
+ MSG_ERROR: 'error',
11
+ };
@@ -0,0 +1,33 @@
1
+ class ReconnectStrategy {
2
+ constructor(options = {}) {
3
+ this.initialDelay = options.initialDelay || 1000;
4
+ this.maxDelay = options.maxDelay || 30000;
5
+ this.currentDelay = this.initialDelay;
6
+ this._timer = null;
7
+ }
8
+
9
+ schedule(fn) {
10
+ const delay = this.currentDelay;
11
+ this._timer = setTimeout(fn, delay);
12
+ // Exponential backoff
13
+ this.currentDelay = Math.min(this.currentDelay * 2, this.maxDelay);
14
+ return delay;
15
+ }
16
+
17
+ reset() {
18
+ this.currentDelay = this.initialDelay;
19
+ }
20
+
21
+ cancel() {
22
+ if (this._timer) {
23
+ clearTimeout(this._timer);
24
+ this._timer = null;
25
+ }
26
+ }
27
+
28
+ destroy() {
29
+ this.cancel();
30
+ }
31
+ }
32
+
33
+ module.exports = { ReconnectStrategy };
@@ -0,0 +1,151 @@
1
+ const WebSocket = require('ws');
2
+ const { MSG_REGISTER, MSG_REGISTERED, MSG_REQUEST, MSG_RESPONSE, MSG_RESPONSE_ERROR, MSG_ERROR } = require('./protocol');
3
+ const { makeLocalRequest } = require('./local-proxy');
4
+ const { ReconnectStrategy } = require('./reconnect');
5
+ const log = require('./logger');
6
+
7
+ class TunnelClient {
8
+ constructor(options) {
9
+ this.serverUrl = options.serverUrl;
10
+ this.localPort = options.localPort;
11
+ this.token = options.token;
12
+ this.tunnelId = null;
13
+ this.tunnelUrl = null;
14
+ this.ws = null;
15
+ this._reconnect = new ReconnectStrategy();
16
+ this._closing = false;
17
+ this._requestCount = 0;
18
+ }
19
+
20
+ connect() {
21
+ return new Promise((resolve, reject) => {
22
+ this._closing = false;
23
+ const url = this.serverUrl;
24
+
25
+ log.info(`Connecting to ${url}...`);
26
+
27
+ this.ws = new WebSocket(url);
28
+
29
+ this.ws.on('open', () => {
30
+ log.info('Connected, registering tunnel...');
31
+
32
+ // Send register message
33
+ this.ws.send(JSON.stringify({
34
+ type: MSG_REGISTER,
35
+ token: this.token,
36
+ port: this.localPort,
37
+ tunnelId: this.tunnelId, // null on first connect, set on reconnect
38
+ }));
39
+ });
40
+
41
+ this.ws.on('message', (data) => {
42
+ let msg;
43
+ try {
44
+ msg = JSON.parse(data.toString());
45
+ } catch {
46
+ log.error('Invalid message from server');
47
+ return;
48
+ }
49
+
50
+ this._handleMessage(msg, resolve);
51
+ });
52
+
53
+ this.ws.on('close', (code, reason) => {
54
+ if (this._closing) return;
55
+
56
+ log.warn(`Disconnected (code: ${code}). Reconnecting...`);
57
+ const delay = this._reconnect.schedule(() => this.connect());
58
+ log.info(`Reconnecting in ${delay}ms...`);
59
+ });
60
+
61
+ this.ws.on('error', (err) => {
62
+ if (this._closing) return;
63
+
64
+ // Only reject on first connection attempt
65
+ if (!this.tunnelId) {
66
+ reject(err);
67
+ }
68
+ log.error(`WebSocket error: ${err.message}`);
69
+ });
70
+ });
71
+ }
72
+
73
+ _handleMessage(msg, resolveConnect) {
74
+ switch (msg.type) {
75
+ case MSG_REGISTERED: {
76
+ this.tunnelId = msg.tunnelId;
77
+ this.tunnelUrl = msg.url;
78
+ this._reconnect.reset();
79
+
80
+ log.success(`Tunnel registered!`);
81
+ log.banner(`http://${msg.url}`);
82
+
83
+ if (resolveConnect) resolveConnect(this);
84
+ break;
85
+ }
86
+
87
+ case MSG_REQUEST: {
88
+ this._handleRequest(msg);
89
+ break;
90
+ }
91
+
92
+ case MSG_ERROR: {
93
+ log.error(`Server error: ${msg.error}`);
94
+ break;
95
+ }
96
+ }
97
+ }
98
+
99
+ async _handleRequest(msg) {
100
+ const { requestId, method, path, headers, body } = msg;
101
+ const start = Date.now();
102
+ this._requestCount++;
103
+
104
+ try {
105
+ const response = await makeLocalRequest(this.localPort, {
106
+ method,
107
+ path,
108
+ headers,
109
+ body,
110
+ });
111
+
112
+ this.ws.send(JSON.stringify({
113
+ type: MSG_RESPONSE,
114
+ requestId,
115
+ statusCode: response.statusCode,
116
+ headers: response.headers,
117
+ body: response.body,
118
+ }));
119
+
120
+ const latency = Date.now() - start;
121
+ log.request(method, path, response.statusCode, latency);
122
+ } catch (err) {
123
+ this.ws.send(JSON.stringify({
124
+ type: MSG_RESPONSE_ERROR,
125
+ requestId,
126
+ error: err.message,
127
+ }));
128
+
129
+ log.error(`${method} ${path} → Error: ${err.message}`);
130
+ }
131
+ }
132
+
133
+ close() {
134
+ this._closing = true;
135
+ this._reconnect.destroy();
136
+ if (this.ws) {
137
+ this.ws.close(1000, 'Client closing');
138
+ }
139
+ }
140
+
141
+ get stats() {
142
+ return {
143
+ tunnelId: this.tunnelId,
144
+ url: this.tunnelUrl,
145
+ requestCount: this._requestCount,
146
+ connected: this.ws?.readyState === WebSocket.OPEN,
147
+ };
148
+ }
149
+ }
150
+
151
+ module.exports = { TunnelClient };