@dpkrn/nodetunnel 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DpkRn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,197 @@
1
+ # @dpkrn/nodetunnel
2
+
3
+ **nodetunnel** is a Node.js library for exposing a local HTTP server through a compatible tunnel server (yamux + JSON wire format). It is published on npm as **`@dpkrn/nodetunnel`**.
4
+
5
+ ```
6
+ Internet ──► Tunnel Server ──► yamux stream ──► nodetunnel ──► localhost:<port>
7
+ ```
8
+
9
+ **Requirements**
10
+
11
+ - **Node.js 18+** (uses global `fetch` / `Headers`)
12
+ - A running tunnel **server** reachable at `localhost:9000` (for example the [devtunnel](https://github.com/DpkRn/devtunnel) server)
13
+
14
+ ### Install from npm
15
+
16
+ ```bash
17
+ npm install @dpkrn/nodetunnel
18
+ ```
19
+
20
+ When developing **in this repository**, import with a relative path instead, for example:
21
+
22
+ `import { startTunnel } from "./pkg/tunnel/tunnel.js"`.
23
+
24
+ ---
25
+
26
+ ## Quick start (ESM)
27
+
28
+ This package is **`"type": "module"`**. Use `import`, not `require`.
29
+
30
+ ```js
31
+ import http from "node:http";
32
+ import { startTunnel } from "@dpkrn/nodetunnel";
33
+
34
+ const PORT = 8080;
35
+
36
+ const server = http.createServer((req, res) => {
37
+ res.writeHead(200, { "Content-Type": "text/plain" });
38
+ res.end("Hello from localhost\n");
39
+ });
40
+
41
+ server.listen(PORT, async () => {
42
+ try {
43
+ const { url, stop } = await startTunnel(String(PORT));
44
+ console.log("Public URL:", url);
45
+
46
+ process.on("SIGINT", () => {
47
+ stop();
48
+ server.close(() => process.exit(0));
49
+ });
50
+ } catch (e) {
51
+ console.error("Tunnel failed:", e.message);
52
+ }
53
+ });
54
+ ```
55
+
56
+ 1. Start your tunnel **server** (port **9000**).
57
+ 2. Run your script: `node app.js`
58
+ 3. Open the printed **public URL** in a browser or `curl` it.
59
+
60
+ ---
61
+
62
+ ## API
63
+
64
+ ### `startTunnel(port, options?)`
65
+
66
+ | Argument | Type | Description |
67
+ |----------|------|-------------|
68
+ | `port` | `string` | Local port your HTTP server listens on (e.g. `"8080"`). |
69
+ | `options` | `object` (optional) | `{ host?: string, serverPort?: number }` — tunnel server address (default `localhost:9000`). |
70
+
71
+ **Returns** a `Promise` resolving to:
72
+
73
+ | Field | Type | Description |
74
+ |-------|------|-------------|
75
+ | `url` | `string` | Public URL assigned by the server (e.g. `http://subdomain.localhost:3000`). |
76
+ | `stop` | `() => void` | Stops the tunnel and closes the connection. |
77
+
78
+ On failure (cannot connect, handshake error), the promise **rejects**. Use `try/catch`.
79
+
80
+ ---
81
+
82
+ ## Express example
83
+
84
+ Start the HTTP server first, then call `startTunnel` with the **same port**.
85
+
86
+ ```js
87
+ import express from "express";
88
+ import { startTunnel } from "@dpkrn/nodetunnel";
89
+
90
+ const app = express();
91
+ const PORT = process.env.PORT || 8080;
92
+
93
+ app.get("/", (req, res) => {
94
+ res.send("OK");
95
+ });
96
+
97
+ app.listen(PORT, async () => {
98
+ console.log(`http://127.0.0.1:${PORT}`);
99
+ try {
100
+ const { url, stop } = await startTunnel(String(PORT));
101
+ console.log("Public:", url);
102
+ process.once("SIGINT", () => {
103
+ stop();
104
+ process.exit(0);
105
+ });
106
+ } catch (e) {
107
+ console.error("Tunnel error:", e.message);
108
+ }
109
+ });
110
+ ```
111
+
112
+ ---
113
+
114
+ ## Troubleshooting
115
+
116
+ - **`Tunnel failed` / connection refused** — Start the tunnel server on the host/port you pass in `options` (default `localhost:9000`).
117
+ - **Wrong path when running scripts** — Run from the `nodetunnel` directory, or use `node nodetunnel/cmd/test-lib/main.js` from the repo root.
118
+ - **Port already in use** — Another process may be bound to your app port; stop it or change `PORT`.
119
+
120
+ ---
121
+
122
+ ## CLI — `mytunnel` (release install, ngrok-style)
123
+
124
+ Besides embedding **nodetunnel** in a Node app, you can install the **`mytunnel`** CLI from **[devtunnel](https://github.com/DpkRn/devtunnel) releases** — same idea as **ngrok**: one binary, one command, expose a local port. It speaks the same tunnel server + yamux protocol as this library.
125
+
126
+ ```
127
+ Internet ──► Tunnel Server ──► yamux stream ──► mytunnel ──► localhost:<port>
128
+ ```
129
+
130
+ The **`mytunnel`** binary lets you expose any local port with a single command (no Node required for the client).
131
+
132
+ ### Install
133
+
134
+ ```bash
135
+ curl -fsSL https://raw.githubusercontent.com/DpkRn/devtunnel/master/install.sh | bash
136
+ ```
137
+
138
+ Auto-detects your OS and CPU architecture (macOS Apple Silicon, macOS Intel, Linux x86_64) and installs to `/usr/local/bin`.
139
+
140
+ **Or build the Go client from source** (from the [devtunnel](https://github.com/DpkRn/devtunnel) or [gotunnel](https://github.com/DpkRn/gotunnel) repo):
141
+
142
+ ```bash
143
+ go build -o mytunnel ./cmd/client
144
+ sudo mv mytunnel /usr/local/bin/
145
+ ```
146
+
147
+ ### Usage
148
+
149
+ ```bash
150
+ mytunnel http <port>
151
+ ```
152
+
153
+ **Example — expose a dev server on port 3000:**
154
+
155
+ ```
156
+ $ mytunnel http 3000
157
+
158
+ ╔══════════════════════════════════════════════════╗
159
+ ║ 🚇 mytunnel — tunnel is live ║
160
+ ╠══════════════════════════════════════════════════╣
161
+ ║ 🌍 Public → http://abc123.example.com ║
162
+ ║ 💻 Local → http://localhost:3000 ║
163
+ ╠══════════════════════════════════════════════════╣
164
+ ║ ⚡ Forwarding requests... ║
165
+ ║ 🛑 Press Ctrl+C to stop ║
166
+ ╚══════════════════════════════════════════════════╝
167
+ ```
168
+
169
+ Press `Ctrl+C` to stop.
170
+
171
+ ### Commands
172
+
173
+ | Command | Description |
174
+ |---------|-------------|
175
+ | `mytunnel http <port>` | Forward public HTTP traffic to `localhost:<port>` |
176
+ | `mytunnel help` | Show help |
177
+
178
+ You still need the **tunnel server** running (e.g. on `localhost:9000`) for both **nodetunnel** and **mytunnel**.
179
+
180
+ ---
181
+
182
+ ## Publishing to npm (maintainers)
183
+
184
+ This package is published as **`@dpkrn/nodetunnel`** ([scoped package](https://docs.npmjs.com/about-scopes)). **`publishConfig.access`** is set to **`"public"`** so anyone can install it without an npm org.
185
+
186
+ 1. Bump **`version`** in `package.json` (semver).
187
+ 2. Dry run: `npm pack` — inspect the tarball.
188
+ 3. Log in: `npm login` (account must have permission to publish under the **`@dpkrn`** scope).
189
+ 4. From the **`nodetunnel`** directory: `npm publish`
190
+
191
+ Adjust **`repository`**, **`bugs`**, and **`homepage`** in `package.json` if your Git remote or default branch differs.
192
+
193
+ ---
194
+
195
+ ## License
196
+
197
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,41 @@
1
+ import express from "express";
2
+ import { startTunnel } from "../../pkg/tunnel/tunnel.js";
3
+
4
+ const app = express();
5
+ app.set("etag", false);
6
+ const PORT = process.env.PORT || 8080;
7
+ /** Random id per process — if this doesn’t match your terminal on each restart, another process is bound to the port. */
8
+ const INSTANCE = Math.random().toString(36).slice(2, 10);
9
+
10
+ app.get("/health", (req, res) => {
11
+ res.status(200).json({
12
+ status: "OK",
13
+ uptime: process.uptime(),
14
+ timestamp: Date.now(),
15
+ message: "Server is healthy 🚀",
16
+ });
17
+ });
18
+
19
+ const ROOT_DELAY_MS = 10_000;
20
+
21
+ // Return a Promise so Express 5’s router waits (see router Layer.handleRequest).
22
+ app.get("/", async (req, res) => {
23
+
24
+ await new Promise((resolve) => setTimeout(resolve, ROOT_DELAY_MS));
25
+ res.send("Backend is running");
26
+ });
27
+
28
+ app.listen(PORT, async () => {
29
+ console.log(`listening on http://127.0.0.1:${PORT}`);
30
+ console.log("Leave this terminal open; press Ctrl+C to stop.");
31
+ try {
32
+ const { url, stop } = await startTunnel(String(PORT));
33
+ console.log("🌍 Public URL:", url);
34
+ process.once("SIGINT", () => {
35
+ stop();
36
+ process.exit(0);
37
+ });
38
+ } catch (e) {
39
+ console.error("Tunnel failed (is devtunnel server on :9000?):", e.message);
40
+ }
41
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Wire format: JSON line from tunnel server → client (per yamux stream).
3
+ * Matches gotunnel / devtunnel Go `TunnelRequest`.
4
+ *
5
+ * @typedef {Object} TunnelRequest
6
+ * @property {string} Method
7
+ * @property {string} Path
8
+ * @property {Record<string, string[]>} [Headers]
9
+ * @property {string} [Body] base64 (Go encoding/json for []byte)
10
+ */
11
+
12
+ module.exports = {};
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Wire format: JSON line from client → tunnel server (per yamux stream).
3
+ * Matches gotunnel / devtunnel Go `TunnelResponse`.
4
+ *
5
+ * @typedef {Object} TunnelResponse
6
+ * @property {number} Status
7
+ * @property {Record<string, string[]>} Headers
8
+ * @property {string} Body base64 (Go encoding/json for []byte)
9
+ */
10
+
11
+ module.exports = {};
@@ -0,0 +1,239 @@
1
+ import net from 'node:net';
2
+ import { Session } from 'yamux-js/lib/session.js';
3
+
4
+ const defaultMuxConfig = {
5
+ enableKeepAlive: false,
6
+ logger: () => {},
7
+ };
8
+
9
+ /**
10
+ * Read until first LF; returns trimmed line (without \n) and any bytes after it.
11
+ * @param {import('net').Socket} socket
12
+ */
13
+ function readPublicUrlLine(socket) {
14
+ return new Promise((resolve, reject) => {
15
+ let buffer = Buffer.alloc(0);
16
+
17
+ const onError = (err) => {
18
+ socket.off('data', onData);
19
+ reject(err);
20
+ };
21
+
22
+ const onData = (chunk) => {
23
+ buffer = Buffer.concat([buffer, chunk]);
24
+ const nl = buffer.indexOf(0x0a);
25
+ if (nl >= 0) {
26
+ socket.off('data', onData);
27
+ socket.off('error', onError);
28
+ const line = buffer.subarray(0, nl).toString('utf8').trim();
29
+ const remainder = buffer.subarray(nl + 1);
30
+ resolve({ line, remainder });
31
+ }
32
+ };
33
+
34
+ socket.on('data', onData);
35
+ socket.on('error', onError);
36
+ });
37
+ }
38
+
39
+ /**
40
+ * Read one line (LF-terminated) from a yamux duplex stream.
41
+ * @param {import('stream').Duplex} stream
42
+ */
43
+ function readJsonLine(stream) {
44
+ return new Promise((resolve, reject) => {
45
+ let buffer = Buffer.alloc(0);
46
+
47
+ const onError = (err) => {
48
+ stream.off('data', onData);
49
+ reject(err);
50
+ };
51
+
52
+ const onData = (chunk) => {
53
+ buffer = Buffer.concat([buffer, chunk]);
54
+ const nl = buffer.indexOf(0x0a);
55
+ if (nl >= 0) {
56
+ stream.off('data', onData);
57
+ stream.off('error', onError);
58
+ const line = buffer.subarray(0, nl).toString('utf8');
59
+ resolve(line);
60
+ }
61
+ };
62
+
63
+ stream.on('data', onData);
64
+ stream.on('error', onError);
65
+ });
66
+ }
67
+
68
+ /** @param {unknown} body */
69
+ function decodeRequestBody(body) {
70
+ if (body == null || body === '') return Buffer.alloc(0);
71
+ if (typeof body === 'string') return Buffer.from(body, 'base64');
72
+ if (Buffer.isBuffer(body)) return body;
73
+ if (Array.isArray(body)) return Buffer.from(body);
74
+ return Buffer.alloc(0);
75
+ }
76
+
77
+ /** @param {Headers | import('http').IncomingHttpHeaders} h */
78
+ function headersToObject(h) {
79
+ const out = {};
80
+ if (h && typeof h.forEach === 'function') {
81
+ h.forEach((value, key) => {
82
+ if (!out[key]) out[key] = [];
83
+ out[key].push(value);
84
+ });
85
+ return out;
86
+ }
87
+ for (const [key, val] of Object.entries(h || {})) {
88
+ if (val == null) continue;
89
+ out[key] = Array.isArray(val) ? val : [val];
90
+ }
91
+ return out;
92
+ }
93
+
94
+ /**
95
+ * @param {import('stream').Duplex} stream
96
+ * @param {string} port
97
+ */
98
+ async function handleStream(stream, port) {
99
+ try {
100
+ const line = await readJsonLine(stream);
101
+ let req;
102
+ try {
103
+ req = JSON.parse(line);
104
+ } catch {
105
+ return;
106
+ }
107
+
108
+ const method = req.Method || 'GET';
109
+ const path = req.Path || '/';
110
+ const target = new URL(path, `http://127.0.0.1:${port}`).toString();
111
+ const body = decodeRequestBody(req.Body);
112
+
113
+ const headers = new Headers();
114
+ const raw = req.Headers || {};
115
+ for (const [k, vals] of Object.entries(raw)) {
116
+ if (!Array.isArray(vals)) continue;
117
+ for (const v of vals) {
118
+ if (v != null) headers.append(k, String(v));
119
+ }
120
+ }
121
+
122
+ /** @type {{ method: string; headers: Headers; body?: Buffer }} */
123
+ const init = { method, headers };
124
+ if (body.length) init.body = body;
125
+
126
+ const resp = await fetch(target, init);
127
+
128
+ const respBody = Buffer.from(await resp.arrayBuffer());
129
+ const payload = {
130
+ Status: resp.status,
131
+ Headers: headersToObject(resp.headers),
132
+ Body: respBody.toString('base64'),
133
+ };
134
+
135
+ stream.end(Buffer.from(`${JSON.stringify(payload)}\n`, 'utf8'));
136
+ } catch (e) {
137
+ try {
138
+ stream.destroy();
139
+ } catch {
140
+ /* ignore */
141
+ }
142
+ }
143
+ }
144
+
145
+ /**
146
+ * @typedef {Object} TunnelOptions
147
+ * @property {string} [host] tunnel server host (default localhost)
148
+ * @property {number} [serverPort] tunnel server TCP port (default 9000)
149
+ */
150
+
151
+ class Tunnel {
152
+ /**
153
+ * @param {string} localPort local HTTP port to forward to
154
+ * @param {TunnelOptions} [options]
155
+ */
156
+ constructor(localPort, options = {}) {
157
+ this.localPort = localPort;
158
+ this.serverHost = options.host ?? 'localhost';
159
+ this.serverPort = options.serverPort ?? 9000;
160
+ /** @type {import('net').Socket | null} */
161
+ this.socket = null;
162
+ /** @type {InstanceType<typeof Session> | null} */
163
+ this.session = null;
164
+ this.publicUrl = '';
165
+ this._stopped = false;
166
+ }
167
+
168
+ /**
169
+ * Connect, read assigned public URL (plaintext line before yamux), start yamux client.
170
+ * Matches devtunnel server: URL line first, then hashicorp-compatible yamux.
171
+ */
172
+ async connect() {
173
+ const socket = net.createConnection({
174
+ host: this.serverHost,
175
+ port: this.serverPort,
176
+ });
177
+
178
+ await new Promise((resolve, reject) => {
179
+ socket.once('connect', resolve);
180
+ socket.once('error', reject);
181
+ });
182
+
183
+ const { line, remainder } = await readPublicUrlLine(socket);
184
+ this.publicUrl = `http://${line}`;
185
+
186
+ const session = new Session(true, defaultMuxConfig, (stream) => {
187
+ handleStream(stream, this.localPort);
188
+ });
189
+
190
+ this.socket = socket;
191
+ this.session = session;
192
+
193
+ if (remainder.length) {
194
+ session.write(remainder);
195
+ }
196
+ socket.pipe(session);
197
+ session.pipe(socket);
198
+
199
+ session.on('error', () => {});
200
+ socket.on('error', () => {});
201
+ }
202
+
203
+ getPublicUrl() {
204
+ return this.publicUrl;
205
+ }
206
+
207
+ stop() {
208
+ if (this._stopped) return;
209
+ this._stopped = true;
210
+ try {
211
+ if (this.session) {
212
+ this.session.close();
213
+ this.session = null;
214
+ }
215
+ } catch {
216
+ /* ignore */
217
+ }
218
+ try {
219
+ if (this.socket) {
220
+ this.socket.destroy();
221
+ this.socket = null;
222
+ }
223
+ } catch {
224
+ /* ignore */
225
+ }
226
+ }
227
+ }
228
+
229
+ /**
230
+ * @param {string} localPort
231
+ * @param {TunnelOptions} [options]
232
+ */
233
+ async function newTunnel(localPort, options) {
234
+ const t = new Tunnel(localPort, options);
235
+ await t.connect();
236
+ return t;
237
+ }
238
+
239
+ export { Tunnel, newTunnel };
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@dpkrn/nodetunnel",
3
+ "version": "1.0.0",
4
+ "description": "Expose a local HTTP server through a devtunnel/gotunnel-compatible server (yamux + JSON). Node.js 18+.",
5
+ "keywords": [
6
+ "tunnel",
7
+ "ngrok",
8
+ "yamux",
9
+ "devtunnel",
10
+ "gotunnel",
11
+ "localhost",
12
+ "expose",
13
+ "http",
14
+ "multiplex"
15
+ ],
16
+ "homepage": "https://github.com/DpkRn/NGROK/tree/main/nodetunnel#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/DpkRn/NGROK/issues"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/DpkRn/NGROK.git",
23
+ "directory": "nodetunnel"
24
+ },
25
+ "license": "MIT",
26
+ "author": "DpkRn (https://github.com/DpkRn)",
27
+ "type": "module",
28
+ "main": "./pkg/tunnel/tunnel.js",
29
+ "exports": {
30
+ ".": {
31
+ "import": "./pkg/tunnel/tunnel.js",
32
+ "default": "./pkg/tunnel/tunnel.js"
33
+ },
34
+ "./pkg/tunnel": "./pkg/tunnel/tunnel.js",
35
+ "./pkg/tunnel/tunnel.js": "./pkg/tunnel/tunnel.js"
36
+ },
37
+ "files": [
38
+ "pkg",
39
+ "internal",
40
+ "cmd",
41
+ "README.md",
42
+ "LICENSE"
43
+ ],
44
+ "engines": {
45
+ "node": ">=18"
46
+ },
47
+ "scripts": {
48
+ "test-lib": "node cmd/test-lib/main.js",
49
+ "prepublishOnly": "node -e \"import('./pkg/tunnel/tunnel.js').then(() => console.log('pack ok')).catch(e => { console.error(e); process.exit(1); })\""
50
+ },
51
+ "dependencies": {
52
+ "yamux-js": "^0.2.0"
53
+ },
54
+ "devDependencies": {
55
+ "express": "^5.2.1"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public"
59
+ }
60
+ }
@@ -0,0 +1,40 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Public API: expose a local HTTP server through a gotunnel-compatible tunnel server.
5
+ *
6
+ * @example
7
+ * import http from 'node:http';
8
+ * import { startTunnel } from '@dpkrn/nodetunnel';
9
+ *
10
+ * const server = http.createServer((req, res) => {
11
+ * res.end('ok');
12
+ * });
13
+ * server.listen(8080, async () => {
14
+ * const { url, stop } = await startTunnel('8080');
15
+ * console.log('Public URL:', url);
16
+ * process.on('SIGINT', () => { stop(); process.exit(0); });
17
+ * });
18
+ */
19
+
20
+ import { newTunnel } from '../../internal/tunnel/tunnel.js';
21
+
22
+ /**
23
+ * Connect to the tunnel server and forward public HTTP traffic to localhost:&lt;port&gt;.
24
+ *
25
+ * @param {string} port local port (e.g. "8080")
26
+ * @param {{ host?: string, serverPort?: number }} [options] tunnel server (default localhost:9000)
27
+ * @returns {Promise<{ url: string, stop: () => void }>}
28
+ */
29
+ async function startTunnel(port, options) {
30
+ const tunnel = await newTunnel(String(port), options);
31
+
32
+ console.log('✅Public url:', tunnel.getPublicUrl());
33
+
34
+ return {
35
+ url: tunnel.getPublicUrl(),
36
+ stop: () => tunnel.stop(),
37
+ };
38
+ }
39
+
40
+ export { startTunnel };