@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 +21 -0
- package/README.md +197 -0
- package/cmd/test-lib/main.js +41 -0
- package/internal/models/protocol/request.js +12 -0
- package/internal/models/protocol/response.js +11 -0
- package/internal/tunnel/tunnel.js +239 -0
- package/package.json +60 -0
- package/pkg/tunnel/tunnel.js +40 -0
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:<port>.
|
|
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 };
|