@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 ADDED
@@ -0,0 +1,185 @@
1
+ <div align="center">
2
+
3
+ # @vumotions/vm-tunnel
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@vumotions/vm-tunnel)](https://www.npmjs.com/package/@vumotions/vm-tunnel)
6
+ [![license](https://img.shields.io/npm/l/@vumotions/vm-tunnel)](./LICENSE)
7
+ [![node](https://img.shields.io/node/v/@vumotions/vm-tunnel)](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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../dist/cli.js'
@@ -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
@@ -0,0 +1,10 @@
1
+ import {
2
+ loadEnv,
3
+ startNamedTunnel,
4
+ startQuickTunnel
5
+ } from "./chunk-YXAF7B6G.js";
6
+ export {
7
+ loadEnv,
8
+ startNamedTunnel,
9
+ startQuickTunnel
10
+ };
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
+ }