@o-town/expose 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.
@@ -0,0 +1 @@
1
+ {}
package/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # expose
2
+
3
+ > Zero-dependency tunnel — expose `localhost` to `*.site.com` using only Node's built-in `net` module.
4
+
5
+ ```
6
+ http://localhost:3000 → https://abc1f3.site.com
7
+ ```
8
+
9
+ ---
10
+
11
+ ## Install & run (client)
12
+
13
+ ```bash
14
+ # Run directly with npx (no install needed)
15
+ npx expose 3000
16
+
17
+ # Or install globally
18
+ npm install -g expose
19
+ expose 3000
20
+
21
+ # With a custom subdomain
22
+ expose 3000 myapp
23
+ # → https://myapp.site.com
24
+ ```
25
+
26
+ ### Environment variables
27
+
28
+ | Variable | Default | Description |
29
+ |-----------------|------------------|------------------------------|
30
+ | `EXPOSE_SERVER` | `tunnel.site.com`| Tunnel server hostname |
31
+ | `EXPOSE_PORT` | `2000` | Tunnel server TCP port |
32
+
33
+ ---
34
+
35
+ ## Deploy the server on site.com
36
+
37
+ ```bash
38
+ node server.js --http-port 80 --tunnel-port 2000 --domain site.com
39
+ ```
40
+
41
+ ### Flags / env vars
42
+
43
+ | Flag | Env var | Default |
44
+ |------------------|---------------|-------------|
45
+ | `--http-port` | `HTTP_PORT` | `80` |
46
+ | `--tunnel-port` | `TUNNEL_PORT` | `2000` |
47
+ | `--domain` | `DOMAIN` | `site.com` |
48
+
49
+ ### Keep alive with PM2
50
+
51
+ ```bash
52
+ npm i -g pm2
53
+ pm2 start server.js --name expose-server -- --http-port 80 --domain site.com
54
+ pm2 save && pm2 startup
55
+ ```
56
+
57
+ ### nginx + TLS (recommended)
58
+
59
+ Issue a wildcard cert first:
60
+ ```bash
61
+ certbot certonly --manual --preferred-challenges dns -d "*.site.com" -d "site.com"
62
+ ```
63
+
64
+ Then add a site config:
65
+ ```nginx
66
+ server {
67
+ listen 443 ssl;
68
+ server_name *.site.com;
69
+
70
+ ssl_certificate /etc/letsencrypt/live/site.com/fullchain.pem;
71
+ ssl_certificate_key /etc/letsencrypt/live/site.com/privkey.pem;
72
+
73
+ location / {
74
+ proxy_pass http://127.0.0.1:80;
75
+ proxy_set_header Host $host;
76
+ proxy_set_header X-Real-IP $remote_addr;
77
+ }
78
+ }
79
+ ```
80
+
81
+ ---
82
+
83
+ ## How it works
84
+
85
+ ```
86
+ Browser
87
+ │ HTTPS → nginx → port 80
88
+
89
+ server.js (http.Server — public)
90
+ │ JSON line over TCP socket (port 2000)
91
+
92
+ expose.js (net.createConnection — your machine)
93
+ │ http.request
94
+
95
+ localhost:3000
96
+ ```
97
+
98
+ 1. **Client** opens a raw TCP socket to `site.com:2000` and sends a `register` message.
99
+ 2. **Server** assigns a subdomain and replies `ready` with the public URL.
100
+ 3. **Browser** hits `https://abc1f3.site.com` → nginx → server's HTTP listener.
101
+ 4. **Server** sends a `request` JSON line over the TCP socket.
102
+ 5. **Client** forwards it to `localhost:PORT`, gets the response, sends `response` back.
103
+ 6. **Server** writes the response to the waiting browser connection.
104
+
105
+ Zero dependencies. No WebSocket. No npm installs. Pure Node.
package/expose.js ADDED
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * expose — tunnel client
6
+ * Usage: node expose.js <port> [subdomain]
7
+ * npx expose 3000
8
+ * npx expose 3000 myapp
9
+ */
10
+
11
+ const net = require('net');
12
+ const http = require('http');
13
+
14
+ // ── CLI args ─────────────────────────────────────────────────────────────────
15
+ const [,, rawPort, subdomain] = process.argv;
16
+
17
+ if (!rawPort || isNaN(Number(rawPort))) {
18
+ console.error('\n Usage: expose <port> [subdomain]\n Example: expose 3000\n');
19
+ process.exit(1);
20
+ }
21
+
22
+ const LOCAL_PORT = Number(rawPort);
23
+ const SERVER_HOST = process.env.EXPOSE_SERVER || 'tunnel.site.com';
24
+ const SERVER_PORT = Number(process.env.EXPOSE_PORT || 2000);
25
+
26
+ // ── Colours (no deps) ────────────────────────────────────────────────────────
27
+ const c = {
28
+ reset: '\x1b[0m',
29
+ bold: '\x1b[1m',
30
+ dim: '\x1b[2m',
31
+ green: '\x1b[32m',
32
+ cyan: '\x1b[36m',
33
+ yellow: '\x1b[33m',
34
+ red: '\x1b[31m',
35
+ };
36
+
37
+ function log(msg) { process.stdout.write(msg + '\n'); }
38
+ function dim(msg) { log(c.dim + msg + c.reset); }
39
+ function ok(msg) { log(c.green + ' ✔ ' + c.reset + msg); }
40
+ function warn(msg) { log(c.yellow + ' ⚠ ' + c.reset + msg); }
41
+ function err(msg) { log(c.red + ' ✖ ' + c.reset + msg); }
42
+
43
+ // ── State ────────────────────────────────────────────────────────────────────
44
+ let reconnectDelay = 1000;
45
+ let connected = false;
46
+
47
+ // ── Main connection loop ─────────────────────────────────────────────────────
48
+ function connect() {
49
+ dim(` Connecting to ${SERVER_HOST}:${SERVER_PORT}…`);
50
+
51
+ const sock = net.createConnection({ host: SERVER_HOST, port: SERVER_PORT });
52
+ let lineBuffer = '';
53
+
54
+ // ── Connected ──────────────────────────────────────────────────────────────
55
+ sock.once('connect', () => {
56
+ reconnectDelay = 1000; // reset back-off on success
57
+ send(sock, {
58
+ type: 'register',
59
+ port: LOCAL_PORT,
60
+ subdomain: subdomain || null,
61
+ });
62
+ });
63
+
64
+ // ── Incoming messages ──────────────────────────────────────────────────────
65
+ sock.on('data', (chunk) => {
66
+ lineBuffer += chunk.toString();
67
+ const lines = lineBuffer.split('\n');
68
+ lineBuffer = lines.pop(); // keep incomplete tail
69
+
70
+ for (const line of lines) {
71
+ if (!line.trim()) continue;
72
+ let msg;
73
+ try { msg = JSON.parse(line); } catch { continue; }
74
+ handleMessage(sock, msg);
75
+ }
76
+ });
77
+
78
+ // ── Disconnect / error ─────────────────────────────────────────────────────
79
+ sock.on('close', () => {
80
+ if (connected) {
81
+ warn('Tunnel disconnected.');
82
+ connected = false;
83
+ }
84
+ scheduleReconnect();
85
+ });
86
+
87
+ sock.on('error', (e) => {
88
+ err(`Socket error: ${e.message}`);
89
+ sock.destroy();
90
+ });
91
+ }
92
+
93
+ function scheduleReconnect() {
94
+ warn(`Reconnecting in ${reconnectDelay / 1000}s…\n`);
95
+ setTimeout(() => {
96
+ reconnectDelay = Math.min(reconnectDelay * 2, 30_000);
97
+ connect();
98
+ }, reconnectDelay);
99
+ }
100
+
101
+ // ── Message handler ──────────────────────────────────────────────────────────
102
+ function handleMessage(sock, msg) {
103
+ switch (msg.type) {
104
+ case 'ready':
105
+ connected = true;
106
+ log('');
107
+ ok(`Tunnel open!\n`);
108
+ log(` ${c.bold}Local :${c.reset} ${c.cyan}http://localhost:${LOCAL_PORT}${c.reset}`);
109
+ log(` ${c.bold}Public :${c.reset} ${c.cyan}${msg.url}${c.reset}`);
110
+ log(c.dim + '\n Ctrl+C to stop.\n' + c.reset);
111
+ break;
112
+
113
+ case 'request':
114
+ forwardRequest(msg, (response) => {
115
+ send(sock, { type: 'response', id: msg.id, ...response });
116
+ });
117
+ break;
118
+
119
+ case 'error':
120
+ err(`Server: ${msg.message}`);
121
+ break;
122
+
123
+ default:
124
+ break;
125
+ }
126
+ }
127
+
128
+ // ── Forward HTTP request to local server ─────────────────────────────────────
129
+ function forwardRequest({ method, path, headers, body }, cb) {
130
+ // Strip hop-by-hop headers that confuse Node's http module
131
+ const safeHeaders = Object.fromEntries(
132
+ Object.entries(headers || {}).filter(([k]) =>
133
+ !['connection', 'keep-alive', 'transfer-encoding', 'upgrade'].includes(k.toLowerCase())
134
+ )
135
+ );
136
+
137
+ const options = {
138
+ hostname: 'localhost',
139
+ port: LOCAL_PORT,
140
+ method: method || 'GET',
141
+ path: path || '/',
142
+ headers: { ...safeHeaders, host: `localhost:${LOCAL_PORT}` },
143
+ };
144
+
145
+ const req = http.request(options, (res) => {
146
+ const chunks = [];
147
+ res.on('data', (c) => chunks.push(c));
148
+ res.on('end', () => {
149
+ cb({
150
+ status: res.statusCode,
151
+ headers: res.headers,
152
+ body: Buffer.concat(chunks).toString('base64'),
153
+ });
154
+ });
155
+ });
156
+
157
+ req.setTimeout(15_000, () => {
158
+ req.destroy();
159
+ cb({ status: 504, headers: {}, body: btoa('Gateway Timeout') });
160
+ });
161
+
162
+ req.on('error', (e) => {
163
+ err(`Local server error: ${e.message}`);
164
+ cb({
165
+ status: 502,
166
+ headers: { 'content-type': 'text/plain' },
167
+ body: Buffer.from(`Local error: ${e.message}`).toString('base64'),
168
+ });
169
+ });
170
+
171
+ if (body) req.write(Buffer.from(body, 'base64'));
172
+ req.end();
173
+ }
174
+
175
+ // ── Helpers ───────────────────────────────────────────────────────────────────
176
+ function send(sock, obj) {
177
+ if (sock.writable) sock.write(JSON.stringify(obj) + '\n');
178
+ }
179
+
180
+ function btoa(str) { return Buffer.from(str).toString('base64'); }
181
+
182
+ // ── Graceful shutdown ─────────────────────────────────────────────────────────
183
+ process.on('SIGINT', () => { log('\n Bye!\n'); process.exit(0); });
184
+ process.on('SIGTERM', () => process.exit(0));
185
+
186
+ // ── Start ─────────────────────────────────────────────────────────────────────
187
+ log('');
188
+ log(` ${c.bold}expose${c.reset} v1.0.0 — localhost:${LOCAL_PORT} → *.site.com`);
189
+ log('');
190
+ connect();
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@o-town/expose",
3
+ "version": "1.0.0",
4
+ "description": "Expose localhost to *.site.com — zero dependencies, pure Node net",
5
+ "bin": { "expose": "./expose.js" },
6
+ "scripts": {
7
+ "client": "node expose.js",
8
+ "server": "node server.js"
9
+ },
10
+ "preferGlobal": true,
11
+ "engines": { "node": ">=14" },
12
+ "license": "MIT"
13
+ }
package/server.js ADDED
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * expose — tunnel server
6
+ * Deploy this on your VPS / site.com machine.
7
+ *
8
+ * Usage:
9
+ * node server.js
10
+ * node server.js --http-port 80 --tunnel-port 2000 --domain site.com
11
+ *
12
+ * Environment variables (alternative to flags):
13
+ * HTTP_PORT public HTTP port (default 80)
14
+ * TUNNEL_PORT client TCP port (default 2000)
15
+ * DOMAIN base domain (default site.com)
16
+ */
17
+
18
+ const net = require('net');
19
+ const http = require('http');
20
+ const { randomBytes } = require('crypto');
21
+
22
+ // ── Config ────────────────────────────────────────────────────────────────────
23
+ const args = parseArgs(process.argv.slice(2));
24
+ const HTTP_PORT = Number(args['http-port'] || process.env.HTTP_PORT || 80);
25
+ const TUNNEL_PORT= Number(args['tunnel-port'] || process.env.TUNNEL_PORT || 2000);
26
+ const DOMAIN = args['domain'] || process.env.DOMAIN || 'site.com';
27
+
28
+ // ── State ─────────────────────────────────────────────────────────────────────
29
+ /**
30
+ * clients : Map<subdomain, {
31
+ * sock : net.Socket,
32
+ * pending : Map<id, { res: http.ServerResponse, timer: NodeJS.Timeout }>
33
+ * }>
34
+ */
35
+ const clients = new Map();
36
+
37
+ // ── Tunnel server (TCP — clients connect here) ────────────────────────────────
38
+ const tunnelServer = net.createServer((sock) => {
39
+ let lineBuffer = '';
40
+ let subdomain = null;
41
+
42
+ sock.on('data', (chunk) => {
43
+ lineBuffer += chunk.toString();
44
+ const lines = lineBuffer.split('\n');
45
+ lineBuffer = lines.pop();
46
+
47
+ for (const line of lines) {
48
+ if (!line.trim()) continue;
49
+ let msg;
50
+ try { msg = JSON.parse(line); } catch { continue; }
51
+ handleClientMessage(sock, msg);
52
+ }
53
+ });
54
+
55
+ sock.on('close', () => {
56
+ if (subdomain && clients.get(subdomain)?.sock === sock) {
57
+ clients.delete(subdomain);
58
+ log(`[-] Tunnel closed: ${subdomain}.${DOMAIN}`);
59
+ }
60
+ });
61
+
62
+ sock.on('error', (e) => {
63
+ if (e.code !== 'ECONNRESET') log(`[!] Client socket error: ${e.message}`);
64
+ sock.destroy();
65
+ });
66
+
67
+ // ── Handle messages from client ──────────────────────────────────────────────
68
+ function handleClientMessage(sock, msg) {
69
+ switch (msg.type) {
70
+
71
+ // Client registers a new tunnel
72
+ case 'register': {
73
+ const desired = msg.subdomain;
74
+ subdomain = (desired && !clients.has(desired))
75
+ ? desired
76
+ : randomBytes(4).toString('hex');
77
+
78
+ clients.set(subdomain, { sock, pending: new Map() });
79
+
80
+ const url = `https://${subdomain}.${DOMAIN}`;
81
+ log(`[+] Tunnel opened: ${url} (port=${msg.port})`);
82
+ send(sock, { type: 'ready', url, subdomain });
83
+ break;
84
+ }
85
+
86
+ // Client sends back an HTTP response
87
+ case 'response': {
88
+ const client = clients.get(subdomain);
89
+ if (!client) break;
90
+
91
+ const pending = client.pending.get(msg.id);
92
+ if (!pending) break;
93
+
94
+ clearTimeout(pending.timer);
95
+ client.pending.delete(msg.id);
96
+
97
+ const body = msg.body
98
+ ? Buffer.from(msg.body, 'base64')
99
+ : Buffer.alloc(0);
100
+
101
+ // Strip hop-by-hop headers
102
+ const headers = Object.fromEntries(
103
+ Object.entries(msg.headers || {}).filter(([k]) =>
104
+ !['transfer-encoding', 'connection', 'keep-alive'].includes(k.toLowerCase())
105
+ )
106
+ );
107
+
108
+ pending.res.writeHead(msg.status || 200, headers);
109
+ pending.res.end(body);
110
+ break;
111
+ }
112
+
113
+ default:
114
+ break;
115
+ }
116
+ }
117
+ });
118
+
119
+ tunnelServer.listen(TUNNEL_PORT, () => {
120
+ log(`[expose-server] Tunnel listener on :${TUNNEL_PORT}`);
121
+ });
122
+
123
+ // ── HTTP server (public-facing) ───────────────────────────────────────────────
124
+ const httpServer = http.createServer((req, res) => {
125
+ // Derive subdomain from Host header
126
+ const host = (req.headers.host || '').split(':')[0]; // strip port
127
+ const sub = host.replace(`.${DOMAIN}`, '').split('.').pop();
128
+ const client = clients.get(sub);
129
+
130
+ if (!client) {
131
+ res.writeHead(502, { 'content-type': 'text/html' });
132
+ res.end(`<h2>No tunnel active for <code>${host}</code></h2>`);
133
+ return;
134
+ }
135
+
136
+ const id = randomBytes(8).toString('hex');
137
+ const chunks = [];
138
+
139
+ req.on('data', (c) => chunks.push(c));
140
+ req.on('end', () => {
141
+ const body = Buffer.concat(chunks).toString('base64');
142
+
143
+ // Forward request to client
144
+ send(client.sock, {
145
+ type: 'request',
146
+ id,
147
+ method: req.method,
148
+ path: req.url,
149
+ headers: req.headers,
150
+ body,
151
+ });
152
+
153
+ // Timeout if client doesn't respond
154
+ const timer = setTimeout(() => {
155
+ client.pending.delete(id);
156
+ if (!res.headersSent) {
157
+ res.writeHead(504, { 'content-type': 'text/plain' });
158
+ res.end('Gateway Timeout — local server did not respond.');
159
+ }
160
+ }, 30_000);
161
+
162
+ client.pending.set(id, { res, timer });
163
+ });
164
+
165
+ req.on('error', () => {
166
+ res.writeHead(400); res.end();
167
+ });
168
+ });
169
+
170
+ httpServer.listen(HTTP_PORT, () => {
171
+ log(`[expose-server] HTTP listener on :${HTTP_PORT} (domain: *.${DOMAIN})`);
172
+ });
173
+
174
+ // ── Helpers ───────────────────────────────────────────────────────────────────
175
+ function send(sock, obj) {
176
+ if (sock.writable) sock.write(JSON.stringify(obj) + '\n');
177
+ }
178
+
179
+ function log(msg) { console.log(`[${new Date().toISOString()}] ${msg}`); }
180
+
181
+ function parseArgs(argv) {
182
+ const out = {};
183
+ for (let i = 0; i < argv.length; i++) {
184
+ if (argv[i].startsWith('--')) out[argv[i].slice(2)] = argv[i + 1] ?? true;
185
+ }
186
+ return out;
187
+ }
188
+
189
+ // ── Graceful shutdown ─────────────────────────────────────────────────────────
190
+ function shutdown() {
191
+ log('Shutting down…');
192
+ tunnelServer.close();
193
+ httpServer.close();
194
+ process.exit(0);
195
+ }
196
+ process.on('SIGINT', shutdown);
197
+ process.on('SIGTERM', shutdown);