@o-town/local.dev 1.1.0 → 1.2.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/bin/cli.js CHANGED
@@ -1,136 +1,92 @@
1
1
  #!/usr/bin/env node
2
-
3
2
  'use strict';
4
3
 
5
- const yargs = require('yargs/yargs');
6
- const { hideBin } = require('yargs/helpers');
7
- const chalk = require('chalk');
8
- const ora = require('ora');
9
- const qrcode = require('qrcode-terminal');
10
4
  const { createTunnel } = require('../lib/tunnel');
11
- const { printBanner, printSuccess, printInfo, printError } = require('../lib/ui');
12
- const updateNotifier = require('update-notifier');
13
- const pkg = require('../package.json');
14
-
15
- // Check for updates silently
16
- updateNotifier({ pkg }).notify();
17
-
18
- const argv = yargs(hideBin(process.argv))
19
- .usage(`\n${chalk.cyan('local.dev')} — expose your local server to the internet\n\nUsage: $0 [port] [options]`)
20
- .command('$0 [port]', 'Start a tunnel to your local server', (yargs) => {
21
- yargs.positional('port', {
22
- describe: 'Local port to expose',
23
- type: 'number',
24
- default: 3000,
25
- });
26
- })
27
- .option('subdomain', {
28
- alias: 's',
29
- type: 'string',
30
- describe: 'Request a specific subdomain (e.g. myapp)',
31
- })
32
- .option('host', {
33
- alias: 'H',
34
- type: 'string',
35
- describe: 'Local host to tunnel (default: localhost)',
36
- default: 'localhost',
37
- })
38
- .option('qr', {
39
- alias: 'q',
40
- type: 'boolean',
41
- describe: 'Show QR code for the tunnel URL',
42
- default: false,
43
- })
44
- .option('open', {
45
- alias: 'o',
46
- type: 'boolean',
47
- describe: 'Open the tunnel URL in browser',
48
- default: false,
49
- })
50
- .option('print-requests', {
51
- alias: 'r',
52
- type: 'boolean',
53
- describe: 'Print incoming requests to stdout',
54
- default: false,
55
- })
56
- .example('$0 3000', 'Expose localhost:3000')
57
- .example('$0 8080 --subdomain myapp', 'Expose localhost:8080 as myapp.loca.lt')
58
- .example('$0 5000 --qr', 'Show QR code for mobile testing')
59
- .epilog(chalk.gray('Docs: https://localdev.sh • Issues: https://github.com/yourusername/local.dev'))
60
- .argv;
5
+ const { printBanner, printSuccess, printInfo, printError, printRequest } = require('../lib/ui');
6
+ const { version } = require('../package.json');
61
7
 
62
- async function main() {
63
- printBanner();
8
+ // ─── Minimal arg parser (no yargs) ───────────────────────────────────────────
9
+ const args = process.argv.slice(2);
10
+
11
+ function getFlag(short, long) {
12
+ const i = args.findIndex(a => a === short || a === long);
13
+ if (i === -1) return null;
14
+ return args[i + 1] || null;
15
+ }
16
+
17
+ function hasFlag(short, long) {
18
+ return args.includes(short) || (long ? args.includes(long) : false);
19
+ }
64
20
 
65
- const port = argv.port || 3000;
66
- const host = argv.host || 'localhost';
21
+ if (hasFlag('-v', '--version')) {
22
+ process.stdout.write(version + '\n');
23
+ process.exit(0);
24
+ }
67
25
 
68
- printInfo(`Tunneling ${chalk.yellow(`${host}:${port}`)} to the internet...`);
26
+ if (hasFlag('-h', '--help')) {
27
+ process.stdout.write(`
28
+ Usage: local.dev [port] [options]
69
29
 
70
- const spinner = ora({
71
- text: 'Establishing secure tunnel...',
72
- color: 'cyan',
73
- }).start();
30
+ Arguments:
31
+ port Local port to expose (default: 3000)
74
32
 
75
- try {
76
- const tunnel = await createTunnel({
77
- port,
78
- host,
79
- subdomain: argv.subdomain,
80
- printRequests: argv['print-requests'],
81
- });
33
+ Options:
34
+ -s, --subdomain Request a specific subdomain
35
+ -H, --host Local host to tunnel (default: localhost)
36
+ -r, --requests Print incoming requests
37
+ -v, --version Show version
38
+ -h, --help Show this help
82
39
 
83
- spinner.stop();
84
-
85
- printSuccess(tunnel.url);
86
-
87
- if (argv.qr) {
88
- console.log('\n' + chalk.gray(' Scan to open on mobile:'));
89
- qrcode.generate(tunnel.url, { small: true }, (qr) => {
90
- qr.split('\n').forEach(line => console.log(' ' + line));
91
- });
92
- }
93
-
94
- if (argv.open) {
95
- const { exec } = require('child_process');
96
- const openCmd = process.platform === 'darwin' ? 'open' :
97
- process.platform === 'win32' ? 'start' : 'xdg-open';
98
- exec(`${openCmd} ${tunnel.url}`);
99
- }
100
-
101
- console.log('');
102
- console.log(chalk.gray(' Press Ctrl+C to stop the tunnel'));
103
- console.log('');
104
-
105
- // Handle incoming request logging
106
- if (argv['print-requests']) {
107
- tunnel.on('request', (info) => {
108
- const time = new Date().toLocaleTimeString();
109
- const method = chalk.cyan(info.method.padEnd(6));
110
- const status = info.statusCode >= 400
111
- ? chalk.red(info.statusCode)
112
- : chalk.green(info.statusCode);
113
- console.log(` ${chalk.gray(time)} ${method} ${status} ${chalk.white(info.path)}`);
114
- });
115
- }
116
-
117
- // Graceful shutdown
118
- process.on('SIGINT', async () => {
119
- console.log('\n' + chalk.gray(' Closing tunnel...'));
120
- tunnel.close();
121
- process.exit(0);
122
- });
40
+ Examples:
41
+ local.dev 3000
42
+ local.dev 8080 --subdomain myapp
43
+ local.dev 5000 --requests
123
44
 
124
- process.on('SIGTERM', async () => {
125
- tunnel.close();
126
- process.exit(0);
127
- });
45
+ `);
46
+ process.exit(0);
47
+ }
48
+
49
+ const port = parseInt(args.find(a => /^\d+$/.test(a)), 10) || 3000;
50
+ const subdomain = getFlag('-s', '--subdomain');
51
+ const host = getFlag('-H', '--host') || 'localhost';
52
+ const printRequests = hasFlag('-r', '--requests');
53
+
54
+ // ─── Main ─────────────────────────────────────────────────────────────────────
55
+ async function main() {
56
+ printBanner(version);
57
+ printInfo(`Tunneling ${host}:${port} to the internet...`);
128
58
 
59
+ let tunnel;
60
+ try {
61
+ tunnel = await createTunnel({ port, host, subdomain });
129
62
  } catch (err) {
130
- spinner.fail('Failed to create tunnel');
131
63
  printError(err.message);
132
64
  process.exit(1);
133
65
  }
66
+
67
+ printSuccess(tunnel.url);
68
+ process.stdout.write(' Press Ctrl+C to stop\n\n');
69
+
70
+ if (printRequests) {
71
+ tunnel.on('request', (info) => {
72
+ printRequest(info.method || 'GET', info.path || '/', info.statusCode || 200);
73
+ });
74
+ }
75
+
76
+ tunnel.on('error', (err) => {
77
+ // Surface non-fatal errors quietly
78
+ process.stderr.write(` warn: ${err.message}\n`);
79
+ });
80
+
81
+ // Graceful shutdown
82
+ const shutdown = () => {
83
+ process.stdout.write('\n Closing tunnel...\n');
84
+ tunnel.close();
85
+ process.exit(0);
86
+ };
87
+
88
+ process.on('SIGINT', shutdown);
89
+ process.on('SIGTERM', shutdown);
134
90
  }
135
91
 
136
- main();
92
+ main();
package/lib/index.js CHANGED
@@ -1,54 +1,27 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * local.dev — Expose your local server to the internet.
5
- *
6
- * Programmatic API:
7
- *
8
- * const { tunnel } = require('local.dev');
9
- *
10
- * const t = await tunnel(3000);
11
- * console.log('Public URL:', t.url);
12
- *
13
- * // Later:
14
- * t.close();
15
- */
16
-
17
- const { createTunnel, TunnelHandle } = require('./tunnel');
18
-
19
- /**
20
- * Open a tunnel to a local port.
21
- *
22
- * @param {number|object} portOrOptions - Port number, or options object
23
- * @param {object} [options] - Options (if first arg is a port)
24
- * @returns {Promise<TunnelHandle>}
4
+ * @o-town/local.dev — programmatic API
25
5
  *
26
6
  * @example
27
- * // Simple usage
7
+ * const { tunnel } = require('@o-town/local.dev');
28
8
  * const t = await tunnel(3000);
29
- * console.log(t.url); // https://xyz.loca.lt
9
+ * console.log(t.url);
30
10
  * t.close();
31
- *
32
- * @example
33
- * // With options
34
- * const t = await tunnel({ port: 8080, subdomain: 'myapp' });
35
11
  */
12
+
13
+ const { createTunnel, Tunnel } = require('./tunnel');
14
+
36
15
  async function tunnel(portOrOptions, options = {}) {
37
16
  let opts;
38
-
39
17
  if (typeof portOrOptions === 'number') {
40
18
  opts = { port: portOrOptions, ...options };
41
- } else if (typeof portOrOptions === 'object' && portOrOptions !== null) {
19
+ } else if (portOrOptions && typeof portOrOptions === 'object') {
42
20
  opts = portOrOptions;
43
21
  } else {
44
22
  throw new TypeError('First argument must be a port number or options object');
45
23
  }
46
-
47
24
  return createTunnel(opts);
48
25
  }
49
26
 
50
- module.exports = {
51
- tunnel,
52
- createTunnel,
53
- TunnelHandle,
54
- };
27
+ module.exports = { tunnel, createTunnel, Tunnel };
package/lib/tunnel.js CHANGED
@@ -1,72 +1,149 @@
1
1
  'use strict';
2
2
 
3
- const localtunnel = require('localtunnel');
4
- const EventEmitter = require('events');
5
-
6
3
  /**
7
- * Creates a tunnel to expose a local port to the internet.
4
+ * Core tunnel logic zero deps beyond Node built-ins + ws.
8
5
  *
9
- * @param {object} options
10
- * @param {number} options.port - Local port to expose
11
- * @param {string} [options.host] - Local host (default: localhost)
12
- * @param {string} [options.subdomain] - Requested subdomain
13
- * @param {boolean} [options.printRequests] - Log incoming requests
14
- * @returns {Promise<TunnelHandle>}
6
+ * How it works:
7
+ * 1. POST to loca.lt API to get an assigned subdomain + WebSocket URL
8
+ * 2. Open a WebSocket to the tunnel server
9
+ * 3. For each incoming tunnel request, open a TCP socket to localhost:port
10
+ * and pipe data bidirectionally through the WebSocket message channel
15
11
  */
16
- async function createTunnel(options = {}) {
17
- const {
18
- port,
19
- host = 'localhost',
20
- subdomain,
21
- printRequests = false,
22
- } = options;
23
-
24
- if (!port || typeof port !== 'number' || port < 1 || port > 65535) {
25
- throw new Error(`Invalid port: ${port}. Must be a number between 1 and 65535.`);
26
- }
27
12
 
28
- const tunnelOptions = {
29
- port,
30
- local_host: host,
31
- allow_invalid_cert: false,
32
- };
13
+ const https = require('https');
14
+ const http = require('http');
15
+ const net = require('net');
16
+ const { EventEmitter } = require('events');
17
+ const WebSocket = require('ws');
33
18
 
34
- if (subdomain) {
35
- tunnelOptions.subdomain = subdomain;
36
- }
19
+ const TUNNEL_HOST = 'localtunnel.me';
20
+ const API_URL = `https://${TUNNEL_HOST}`;
37
21
 
38
- const tunnel = await localtunnel(tunnelOptions);
22
+ /**
23
+ * Request a tunnel assignment from the loca.lt API.
24
+ * Returns { id, url, cached_url, ws_connections, port }
25
+ */
26
+ function assignTunnel(subdomain) {
27
+ return new Promise((resolve, reject) => {
28
+ const path = subdomain ? `/api/tunnels/${subdomain}` : '/api/tunnels';
29
+ const options = {
30
+ hostname: TUNNEL_HOST,
31
+ path,
32
+ method: 'GET',
33
+ headers: { 'bypass-tunnel-reminder': '1' },
34
+ };
35
+
36
+ const req = https.request(options, (res) => {
37
+ let body = '';
38
+ res.on('data', (chunk) => (body += chunk));
39
+ res.on('end', () => {
40
+ try {
41
+ const data = JSON.parse(body);
42
+ if (data.error) return reject(new Error(data.error));
43
+ resolve(data);
44
+ } catch {
45
+ reject(new Error('Invalid response from tunnel server'));
46
+ }
47
+ });
48
+ });
39
49
 
40
- const handle = new TunnelHandle(tunnel, { printRequests });
41
- return handle;
50
+ req.on('error', reject);
51
+ req.end();
52
+ });
42
53
  }
43
54
 
44
- class TunnelHandle extends EventEmitter {
45
- constructor(tunnel, options = {}) {
55
+ class Tunnel extends EventEmitter {
56
+ constructor(opts = {}) {
46
57
  super();
47
- this._tunnel = tunnel;
48
- this.url = tunnel.url;
58
+ this.port = opts.port;
59
+ this.host = opts.host || 'localhost';
60
+ this.subdomain = opts.subdomain || null;
61
+ this.url = null;
62
+ this._ws = null;
63
+ this._closed = false;
64
+ }
65
+
66
+ async open() {
67
+ const info = await assignTunnel(this.subdomain);
68
+ this.url = info.url;
69
+ this._connect(info);
70
+ return this;
71
+ }
49
72
 
50
- // Proxy tunnel events
51
- tunnel.on('close', () => {
73
+ _connect(info) {
74
+ // loca.lt uses a WebSocket-based multiplexed tunnel
75
+ const wsUrl = this.url.replace('https://', 'wss://').replace('http://', 'ws://');
76
+ const ws = new WebSocket(wsUrl + '/?new', {
77
+ headers: { 'bypass-tunnel-reminder': '1' },
78
+ });
79
+
80
+ this._ws = ws;
81
+
82
+ ws.on('open', () => {
83
+ this.emit('open', this.url);
84
+ });
85
+
86
+ ws.on('message', (data) => {
87
+ // Parse incoming tunnel request metadata, then proxy it
88
+ try {
89
+ const meta = JSON.parse(data);
90
+ if (meta.type === 'request') {
91
+ this._handleRequest(meta, ws);
92
+ this.emit('request', meta);
93
+ }
94
+ } catch {
95
+ // binary / passthrough frame — ignore at this level
96
+ }
97
+ });
98
+
99
+ ws.on('close', () => {
100
+ if (!this._closed) {
101
+ // Auto-reconnect after a short delay
102
+ setTimeout(() => this._connect(info), 1000);
103
+ }
52
104
  this.emit('close');
53
105
  });
54
106
 
55
- tunnel.on('error', (err) => {
107
+ ws.on('error', (err) => {
56
108
  this.emit('error', err);
57
109
  });
110
+ }
58
111
 
59
- tunnel.on('request', (info) => {
60
- this.emit('request', info);
112
+ _handleRequest(meta, ws) {
113
+ // Open a raw TCP connection to the local server and pipe via WebSocket
114
+ const socket = net.createConnection({ host: this.host, port: this.port });
115
+
116
+ socket.on('error', (err) => {
117
+ this.emit('error', err);
118
+ });
119
+
120
+ // Forward local response back through the WebSocket
121
+ socket.on('data', (chunk) => {
122
+ if (ws.readyState === WebSocket.OPEN) {
123
+ ws.send(JSON.stringify({ id: meta.id, data: chunk.toString('base64') }));
124
+ }
125
+ });
126
+
127
+ // Send the original request down to localhost
128
+ socket.write(Buffer.from(meta.data || '', 'base64'));
129
+
130
+ socket.on('end', () => {
131
+ if (ws.readyState === WebSocket.OPEN) {
132
+ ws.send(JSON.stringify({ id: meta.id, end: true }));
133
+ }
61
134
  });
62
135
  }
63
136
 
64
- /**
65
- * Close the tunnel and free resources.
66
- */
67
137
  close() {
68
- this._tunnel.close();
138
+ this._closed = true;
139
+ if (this._ws) this._ws.terminate();
69
140
  }
70
141
  }
71
142
 
72
- module.exports = { createTunnel, TunnelHandle };
143
+ async function createTunnel(opts = {}) {
144
+ const tunnel = new Tunnel(opts);
145
+ await tunnel.open();
146
+ return tunnel;
147
+ }
148
+
149
+ module.exports = { createTunnel, Tunnel };
package/lib/ui.js CHANGED
@@ -1,40 +1,63 @@
1
1
  'use strict';
2
2
 
3
- const chalk = require('chalk');
3
+ // ANSI color codes — no chalk needed
4
+ const c = {
5
+ reset: '\x1b[0m',
6
+ bold: '\x1b[1m',
7
+ dim: '\x1b[2m',
8
+ cyan: '\x1b[36m',
9
+ green: '\x1b[32m',
10
+ red: '\x1b[31m',
11
+ white: '\x1b[37m',
12
+ yellow: '\x1b[33m',
13
+ };
4
14
 
5
- const BRAND = chalk.bold.cyan('local') + chalk.bold.white('.dev');
15
+ const fmt = (codes, text) => `${codes}${text}${c.reset}`;
6
16
 
7
- function printBanner() {
8
- console.log('');
9
- console.log(` ${BRAND} ${chalk.gray('v' + require('../package.json').version)}`);
10
- console.log(` ${chalk.gray('Expose your local server to the internet')}`);
11
- console.log('');
17
+ function printBanner(version) {
18
+ const brand = fmt(c.bold + c.cyan, 'local') + fmt(c.bold + c.white, '.dev');
19
+ process.stdout.write(`
20
+ ${brand} ${fmt(c.dim, 'v' + version)}
21
+ ${fmt(c.dim, 'Expose your local server to the internet')}
22
+
23
+ `);
12
24
  }
13
25
 
14
26
  function printSuccess(url) {
15
- const line = chalk.gray('─'.repeat(50));
16
- console.log(` ${line}`);
17
- console.log('');
18
- console.log(` ${chalk.green('✔')} Tunnel is ${chalk.green('live')}`);
19
- console.log('');
20
- console.log(` ${chalk.gray('URL')} ${chalk.bold.white(url)}`);
21
- console.log('');
22
- console.log(` ${line}`);
27
+ const line = fmt(c.dim, '─'.repeat(48));
28
+ process.stdout.write(`
29
+ ${line}
30
+
31
+ ${fmt(c.green, '✔')} Tunnel is ${fmt(c.green, 'live')}
32
+
33
+ ${fmt(c.dim, 'URL')} ${fmt(c.bold + c.white, url)}
34
+
35
+ ${line}
36
+
37
+ `);
23
38
  }
24
39
 
25
40
  function printInfo(msg) {
26
- console.log(` ${chalk.cyan('→')} ${chalk.gray(msg)}`);
41
+ process.stdout.write(` ${fmt(c.cyan, '→')} ${fmt(c.dim, msg)}\n`);
27
42
  }
28
43
 
29
44
  function printError(msg) {
30
- console.log('');
31
- console.log(` ${chalk.red('✖')} ${chalk.red('Error:')} ${msg}`);
32
- console.log('');
33
- console.log(` ${chalk.gray('Troubleshooting:')}`);
34
- console.log(` ${chalk.gray(' • Is your local server running?')}`);
35
- console.log(` ${chalk.gray(' • Check the port number is correct')}`);
36
- console.log(` ${chalk.gray(' • Try: local.dev --help')}`);
37
- console.log('');
45
+ process.stderr.write(`
46
+ ${fmt(c.red, '✖ Error:')} ${msg}
47
+
48
+ ${fmt(c.dim, 'Troubleshooting:')}
49
+ ${fmt(c.dim, ' • Is your local server running?')}
50
+ ${fmt(c.dim, ' • Check the port number is correct')}
51
+ ${fmt(c.dim, ' • Run: local.dev --help')}
52
+
53
+ `);
54
+ }
55
+
56
+ function printRequest(method, path, status) {
57
+ const time = new Date().toLocaleTimeString();
58
+ const statusFmt = status >= 400 ? fmt(c.red, status) : fmt(c.green, status);
59
+ const methodFmt = fmt(c.cyan, method.padEnd(6));
60
+ process.stdout.write(` ${fmt(c.dim, time)} ${methodFmt} ${statusFmt} ${path}\n`);
38
61
  }
39
62
 
40
- module.exports = { printBanner, printSuccess, printInfo, printError };
63
+ module.exports = { printBanner, printSuccess, printInfo, printError, printRequest };
package/package.json CHANGED
@@ -1,15 +1,12 @@
1
1
  {
2
2
  "name": "@o-town/local.dev",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Expose your local website to the internet instantly — no config, no signup.",
5
- "main": "./lib/index.js",
5
+ "main": "lib/index.js",
6
6
  "bin": {
7
7
  "local.dev": "./bin/cli.js",
8
8
  "localdev": "./bin/cli.js"
9
9
  },
10
- "scripts": {
11
- "test": "node test.js"
12
- },
13
10
  "keywords": [
14
11
  "tunnel",
15
12
  "localhost",
@@ -23,15 +20,7 @@
23
20
  "author": "local.dev contributors",
24
21
  "license": "MIT",
25
22
  "dependencies": {
26
- "axios": "^1.6.0",
27
- "chalk": "^4.1.2",
28
- "ws": "^8.16.0",
29
- "http-proxy": "^1.18.1",
30
- "localtunnel": "^2.0.2",
31
- "ora": "^5.4.1",
32
- "qrcode-terminal": "^0.12.0",
33
- "update-notifier": "^5.1.0",
34
- "yargs": "^17.7.2"
23
+ "ws": "^8.16.0"
35
24
  },
36
25
  "engines": {
37
26
  "node": ">=14.0.0"