@vumotions/vm-tunnel 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 +185 -0
- package/bin/vm-tunnel.js +2 -0
- package/dist/chunk-YXAF7B6G.js +100 -0
- package/dist/cli.js +36 -0
- package/dist/index.js +10 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# @vumotions/vm-tunnel
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@vumotions/vm-tunnel)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
[](https://nodejs.org)
|
|
8
|
+
|
|
9
|
+
Cloudflare Tunnel CLI for local development.<br/>
|
|
10
|
+
Exposes your local server to the internet with a **fixed URL** (Named Tunnel) or a **random URL** (Quick Tunnel) — zero config, reads from `.env`.
|
|
11
|
+
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Features
|
|
17
|
+
|
|
18
|
+
- **Named Tunnel** — fixed, persistent URL via your own domain (`dev-john-api.example.com`)
|
|
19
|
+
- **Quick Tunnel** — zero-config random URL, no Cloudflare account needed
|
|
20
|
+
- **Auto-fallback** — if `TUNNEL_HOST` is not set, falls back to Quick Tunnel automatically
|
|
21
|
+
- **Process manager** — spawn your app alongside the tunnel with `--exec`
|
|
22
|
+
- **Programmatic API** — use as a library in your own scripts
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install @vumotions/vm-tunnel
|
|
30
|
+
# or globally
|
|
31
|
+
npm install -g @vumotions/vm-tunnel
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
Add to your project's `.env`:
|
|
39
|
+
|
|
40
|
+
```env
|
|
41
|
+
PORT=3000
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Run:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
vm-tunnel
|
|
48
|
+
# => Quick Tunnel started at https://random-words.trycloudflare.com
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Named Tunnel (fixed URL)
|
|
54
|
+
|
|
55
|
+
Requires a Cloudflare account with a domain.
|
|
56
|
+
|
|
57
|
+
### Prerequisites
|
|
58
|
+
|
|
59
|
+
1. **Cloudflare account** with a domain added
|
|
60
|
+
2. **API Token** with permissions: `Cloudflare Tunnel:Edit`, `DNS:Edit`
|
|
61
|
+
3. **Account ID** from your Cloudflare dashboard
|
|
62
|
+
4. **cloudflared** authenticated locally:
|
|
63
|
+
```bash
|
|
64
|
+
cloudflared login
|
|
65
|
+
# Opens browser → select your domain → cert.pem saved to ~/.cloudflared/cert.pem
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Setup
|
|
69
|
+
|
|
70
|
+
Add to `.env`:
|
|
71
|
+
|
|
72
|
+
```env
|
|
73
|
+
PORT=3000
|
|
74
|
+
TUNNEL_HOST=dev-john-api.example.com
|
|
75
|
+
CF_API_TOKEN=your_api_token
|
|
76
|
+
CF_ACCOUNT_ID=your_account_id
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Run:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
vm-tunnel
|
|
83
|
+
# => Named Tunnel running at https://dev-john-api.example.com
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The tunnel name is derived from the first segment of your hostname (`dev-john-api`). DNS CNAME is created automatically on first run and reused on subsequent runs.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## CLI Reference
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
Usage: vm-tunnel [options]
|
|
94
|
+
|
|
95
|
+
Options:
|
|
96
|
+
--host <host> Tunnel hostname (overrides TUNNEL_HOST env)
|
|
97
|
+
--port <port> Local app port (overrides PORT env)
|
|
98
|
+
--exec <command> App command to run alongside the tunnel
|
|
99
|
+
-h, --help Display help
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Examples
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
# Quick Tunnel on port 3000
|
|
106
|
+
vm-tunnel --port 3000
|
|
107
|
+
|
|
108
|
+
# Named Tunnel with inline options
|
|
109
|
+
vm-tunnel --host dev-john-api.example.com --port 3000
|
|
110
|
+
|
|
111
|
+
# Start app + tunnel in one command
|
|
112
|
+
vm-tunnel --exec "nest start --watch"
|
|
113
|
+
|
|
114
|
+
# Inline everything
|
|
115
|
+
vm-tunnel --host dev-john-api.example.com --port 3000 --exec "nest start --watch"
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Typical `package.json` integration
|
|
119
|
+
|
|
120
|
+
```json
|
|
121
|
+
{
|
|
122
|
+
"scripts": {
|
|
123
|
+
"dev": "vm-tunnel --exec \"nest start --watch\""
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Environment Variables
|
|
131
|
+
|
|
132
|
+
| Variable | Required | Description |
|
|
133
|
+
| ---------------- | -------- | ---------------------------------------------------- |
|
|
134
|
+
| `PORT` | Yes | Local port your app runs on |
|
|
135
|
+
| `TUNNEL_HOST` | No | Fixed hostname for Named Tunnel (e.g. `dev-john-api.example.com`). If omitted, uses Quick Tunnel |
|
|
136
|
+
| `CF_API_TOKEN` | Named | Cloudflare API token (required when `TUNNEL_HOST` is set) |
|
|
137
|
+
| `CF_ACCOUNT_ID` | Named | Cloudflare account ID (required when `TUNNEL_HOST` is set) |
|
|
138
|
+
|
|
139
|
+
> Variables are loaded from `.env` in the current working directory. CLI flags take precedence over env vars.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Programmatic API
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
import { startNamedTunnel, startQuickTunnel, loadEnv } from '@vumotions/vm-tunnel'
|
|
147
|
+
|
|
148
|
+
// Quick Tunnel
|
|
149
|
+
const url = await startQuickTunnel('3000')
|
|
150
|
+
console.log(url) // https://random-words.trycloudflare.com
|
|
151
|
+
|
|
152
|
+
// Named Tunnel
|
|
153
|
+
const url = await startNamedTunnel(
|
|
154
|
+
'dev-john-api.example.com',
|
|
155
|
+
'3000',
|
|
156
|
+
process.env.CF_API_TOKEN!,
|
|
157
|
+
process.env.CF_ACCOUNT_ID!
|
|
158
|
+
)
|
|
159
|
+
console.log(url) // https://dev-john-api.example.com
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## How It Works
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
Your app (localhost:PORT)
|
|
168
|
+
│
|
|
169
|
+
▼
|
|
170
|
+
cloudflared ──── outbound HTTPS ────▶ Cloudflare Edge
|
|
171
|
+
│
|
|
172
|
+
TUNNEL_HOST DNS CNAME
|
|
173
|
+
│
|
|
174
|
+
◀── internet traffic
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
- **Named Tunnel**: creates a tunnel via Cloudflare API, sets a DNS CNAME pointing to the tunnel ID, and starts `cloudflared` with a token. The URL is always the same.
|
|
178
|
+
- **Quick Tunnel**: starts `cloudflared` with no credentials, gets a temporary random URL from Cloudflare. No account required.
|
|
179
|
+
- All connections are **outbound** from your machine — no inbound ports need to be opened.
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## License
|
|
184
|
+
|
|
185
|
+
MIT
|
package/bin/vm-tunnel.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// src/utils/env.ts
|
|
2
|
+
import dotenv from "dotenv";
|
|
3
|
+
import path from "path";
|
|
4
|
+
dotenv.config({ path: path.resolve(process.cwd(), ".env") });
|
|
5
|
+
function loadEnv(overrides = {}) {
|
|
6
|
+
const port = overrides.port ?? process.env.PORT;
|
|
7
|
+
if (!port) throw new Error("PORT is required. Please set PORT in your .env file.");
|
|
8
|
+
return {
|
|
9
|
+
port,
|
|
10
|
+
tunnelHost: overrides.tunnelHost ?? process.env.TUNNEL_HOST,
|
|
11
|
+
cfApiToken: overrides.cfApiToken ?? process.env.CF_API_TOKEN,
|
|
12
|
+
cfAccountId: overrides.cfAccountId ?? process.env.CF_ACCOUNT_ID
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// src/tunnel/named.ts
|
|
17
|
+
import Cloudflare from "cloudflare";
|
|
18
|
+
import { Tunnel } from "cloudflared";
|
|
19
|
+
import fs from "fs";
|
|
20
|
+
import os from "os";
|
|
21
|
+
import path2 from "path";
|
|
22
|
+
var CERT_PATH = path2.join(os.homedir(), ".cloudflared", "cert.pem");
|
|
23
|
+
function getTunnelName(host) {
|
|
24
|
+
return host.split(".")[0];
|
|
25
|
+
}
|
|
26
|
+
async function getOrCreateTunnel(cf, accountId, name) {
|
|
27
|
+
for await (const t of cf.zeroTrust.tunnels.cloudflared.list({ account_id: accountId, name })) {
|
|
28
|
+
if (t.name === name && !t.deleted_at && t.id) {
|
|
29
|
+
console.log(`[vm-tunnel] Reusing existing tunnel: ${name} (${t.id})`);
|
|
30
|
+
const token2 = await cf.zeroTrust.tunnels.cloudflared.token.get(t.id, { account_id: accountId });
|
|
31
|
+
return { id: t.id, token: token2 };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
console.log(`[vm-tunnel] Creating tunnel: ${name}`);
|
|
35
|
+
const created = await cf.zeroTrust.tunnels.cloudflared.create({ account_id: accountId, name });
|
|
36
|
+
if (!created.id) throw new Error(`[vm-tunnel] Failed to create tunnel: no ID returned`);
|
|
37
|
+
const token = await cf.zeroTrust.tunnels.cloudflared.token.get(created.id, { account_id: accountId });
|
|
38
|
+
return { id: created.id, token };
|
|
39
|
+
}
|
|
40
|
+
async function getZoneId(cf, host) {
|
|
41
|
+
const domain = host.split(".").slice(-2).join(".");
|
|
42
|
+
for await (const zone of cf.zones.list({ name: domain })) {
|
|
43
|
+
return zone.id;
|
|
44
|
+
}
|
|
45
|
+
throw new Error(`[vm-tunnel] Zone not found for domain: ${domain}`);
|
|
46
|
+
}
|
|
47
|
+
async function ensureDnsRecord(cf, zoneId, host, tunnelId) {
|
|
48
|
+
const cname = `${tunnelId}.cfargotunnel.com`;
|
|
49
|
+
for await (const record of cf.dns.records.list({ zone_id: zoneId, name: { exact: host }, type: "CNAME" })) {
|
|
50
|
+
if (record.name === host) {
|
|
51
|
+
console.log(`[vm-tunnel] DNS CNAME already exists: ${host} \u2192 ${cname}`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
console.log(`[vm-tunnel] Creating DNS CNAME: ${host} \u2192 ${cname}`);
|
|
56
|
+
await cf.dns.records.create({ zone_id: zoneId, type: "CNAME", name: host, content: cname, proxied: true, ttl: 1 });
|
|
57
|
+
}
|
|
58
|
+
async function startNamedTunnel(host, port, apiToken, accountId) {
|
|
59
|
+
if (!fs.existsSync(CERT_PATH)) {
|
|
60
|
+
throw new Error(`[vm-tunnel] cert.pem not found at ${CERT_PATH}. Run: cloudflared login`);
|
|
61
|
+
}
|
|
62
|
+
const cf = new Cloudflare({ apiToken });
|
|
63
|
+
const name = getTunnelName(host);
|
|
64
|
+
const { id: tunnelId, token } = await getOrCreateTunnel(cf, accountId, name);
|
|
65
|
+
const zoneId = await getZoneId(cf, host);
|
|
66
|
+
await ensureDnsRecord(cf, zoneId, host, tunnelId);
|
|
67
|
+
console.log(`[vm-tunnel] Starting Named Tunnel: https://${host} \u2192 http://localhost:${port}`);
|
|
68
|
+
const t = Tunnel.withToken(token);
|
|
69
|
+
await new Promise((resolve, reject) => {
|
|
70
|
+
t.once("connected", () => resolve());
|
|
71
|
+
t.once("error", reject);
|
|
72
|
+
t.once("exit", (code) => reject(new Error(`cloudflared exited with code ${code}`)));
|
|
73
|
+
});
|
|
74
|
+
console.log(`[vm-tunnel] Tunnel running at https://${host}`);
|
|
75
|
+
process.on("SIGINT", () => t.stop());
|
|
76
|
+
process.on("SIGTERM", () => t.stop());
|
|
77
|
+
return `https://${host}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/tunnel/quick.ts
|
|
81
|
+
import { Tunnel as Tunnel2 } from "cloudflared";
|
|
82
|
+
async function startQuickTunnel(port) {
|
|
83
|
+
console.log(`[vm-tunnel] TUNNEL_HOST not set \u2014 starting Quick Tunnel on port ${port}...`);
|
|
84
|
+
const t = Tunnel2.quick(`http://localhost:${port}`);
|
|
85
|
+
const url = await new Promise((resolve, reject) => {
|
|
86
|
+
t.once("url", resolve);
|
|
87
|
+
t.once("error", reject);
|
|
88
|
+
t.once("exit", (code) => reject(new Error(`cloudflared exited with code ${code}`)));
|
|
89
|
+
});
|
|
90
|
+
console.log(`[vm-tunnel] Quick Tunnel running at ${url}`);
|
|
91
|
+
process.on("SIGINT", () => t.stop());
|
|
92
|
+
process.on("SIGTERM", () => t.stop());
|
|
93
|
+
return url;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export {
|
|
97
|
+
loadEnv,
|
|
98
|
+
startNamedTunnel,
|
|
99
|
+
startQuickTunnel
|
|
100
|
+
};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {
|
|
2
|
+
loadEnv,
|
|
3
|
+
startNamedTunnel,
|
|
4
|
+
startQuickTunnel
|
|
5
|
+
} from "./chunk-YXAF7B6G.js";
|
|
6
|
+
|
|
7
|
+
// src/cli.ts
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
|
|
10
|
+
// src/utils/process.ts
|
|
11
|
+
import { spawn } from "child_process";
|
|
12
|
+
function spawnApp(exec) {
|
|
13
|
+
const [cmd, ...args] = exec.split(" ");
|
|
14
|
+
const child = spawn(cmd, args, { shell: true, stdio: "inherit" });
|
|
15
|
+
child.on("error", (err) => {
|
|
16
|
+
console.error(`[vm-tunnel] Failed to start app: ${err.message}`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
});
|
|
19
|
+
process.on("SIGINT", () => child.kill("SIGINT"));
|
|
20
|
+
process.on("SIGTERM", () => child.kill("SIGTERM"));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// src/cli.ts
|
|
24
|
+
var program = new Command();
|
|
25
|
+
program.name("vm-tunnel").description("Cloudflare tunnel CLI for local development").option("--host <host>", "Tunnel hostname (overrides TUNNEL_HOST)").option("--port <port>", "Local app port (overrides PORT)").option("--exec <command>", "App command to run alongside tunnel").action(async (options) => {
|
|
26
|
+
const env = loadEnv({ tunnelHost: options.host, port: options.port });
|
|
27
|
+
if (options.exec) spawnApp(options.exec);
|
|
28
|
+
if (env.tunnelHost) {
|
|
29
|
+
if (!env.cfApiToken) throw new Error("[vm-tunnel] CF_API_TOKEN is required for Named Tunnel.");
|
|
30
|
+
if (!env.cfAccountId) throw new Error("[vm-tunnel] CF_ACCOUNT_ID is required for Named Tunnel.");
|
|
31
|
+
await startNamedTunnel(env.tunnelHost, env.port, env.cfApiToken, env.cfAccountId);
|
|
32
|
+
} else {
|
|
33
|
+
await startQuickTunnel(env.port);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
program.parse();
|
package/dist/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vumotions/vm-tunnel",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Cloudflare Named Tunnel and Quick Tunnel CLI for local development",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"vm-tunnel": "./bin/vm-tunnel.js"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsup",
|
|
15
|
+
"dev": "tsup --watch"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"bin"
|
|
20
|
+
],
|
|
21
|
+
"keywords": ["cloudflare", "tunnel", "dev", "shopify"],
|
|
22
|
+
"author": "vumotions",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"cloudflare": "^5.2.0",
|
|
26
|
+
"cloudflared": "^0.7.1",
|
|
27
|
+
"commander": "^14.0.3",
|
|
28
|
+
"dotenv": "^17.4.1"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^25.6.0",
|
|
32
|
+
"tsup": "^8.5.1",
|
|
33
|
+
"typescript": "^6.0.2"
|
|
34
|
+
}
|
|
35
|
+
}
|