shopify-tunnel-dev 0.1.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 +118 -0
- package/bin/shopify-tunnel-dev.js +82 -0
- package/package.json +30 -0
- package/src/cloudflared-binary.js +54 -0
- package/src/load-config.js +74 -0
- package/src/run-cloudflared.js +50 -0
- package/src/start-tunnel.js +90 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Huy Nguyen The
|
|
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,118 @@
|
|
|
1
|
+
# shopify-tunnel-dev
|
|
2
|
+
|
|
3
|
+
Chạy `shopify app dev` sau một Cloudflare named tunnel có **hostname cố định**. Cả team dùng chung
|
|
4
|
+
một bộ credential, dev mới chỉ cần đặt `VITE_HOST` rồi chạy, không ai phải cấp tunnel hay cert.
|
|
5
|
+
|
|
6
|
+
Cùng cơ chế và cùng tên biến với `@bss-sbc/shopify-dev`, nên repo đang dùng tool đó đổi sang được
|
|
7
|
+
mà không phải sửa `.env` hay `account.json`. Không có dependency, cần Node >= 20.12.
|
|
8
|
+
|
|
9
|
+
## Cách chạy
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
pnpm dev
|
|
13
|
+
├─ đọc web/.env hoặc .env, sau đó .cloudflared/account.json (account.json đè lên)
|
|
14
|
+
├─ tìm tunnel tên = phần đầu của VITE_HOST, chưa có thì tạo bằng secret chung
|
|
15
|
+
├─ trỏ DNS VITE_HOST về tunnel (không ghi đè record của tunnel khác)
|
|
16
|
+
├─ chạy connector về http://localhost:$VITE_PORT, chờ edge nhận kết nối
|
|
17
|
+
└─ npx shopify app dev --tunnel-url https://$VITE_HOST:$VITE_PORT
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
HTTPS do Cloudflare xử lý, máy dev không cần cert TLS. Nếu không có `cloudflared` trên máy, lần
|
|
21
|
+
chạy đầu tool tự tải bản mới nhất về `~/.cache/shopify-tunnel-dev/`. Muốn dùng binary khác thì đặt
|
|
22
|
+
`CLOUDFLARED_BIN`.
|
|
23
|
+
|
|
24
|
+
## Setup một lần cho cả team
|
|
25
|
+
|
|
26
|
+
1. Dùng một Cloudflare account chỉ dành cho dev (lý do ở mục Bảo mật), thêm domain vào account đó.
|
|
27
|
+
2. Chạy `cloudflared tunnel login`, chọn zone. Lệnh này sinh `~/.cloudflared/cert.pem`.
|
|
28
|
+
3. Tạo secret: `openssl rand -base64 32`.
|
|
29
|
+
4. Lưu cả hai lên password manager (Passbolt...), kèm `account.json`:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"CF_ACCOUNT_TAG": "<account id>",
|
|
34
|
+
"CF_SECRET_KEY": "<base64 32 byte>",
|
|
35
|
+
"CF_HOST_PATTERN": "^dev-banner-[a-zA-Z0-9-]+\\.yourdomain\\.com$"
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`CF_HOST_PATTERN` là tuỳ chọn. Nên đặt để mỗi app có tiền tố riêng và không đè DNS của nhau.
|
|
40
|
+
|
|
41
|
+
## Setup cho repo app
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pnpm add -D shopify-tunnel-dev # hoặc git URL / pnpm link khi chưa publish
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
"scripts": { "dev": "shopify-tunnel-dev" }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`.gitignore`:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
.cloudflared/*
|
|
55
|
+
!.cloudflared/*.example
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`shopify.app.toml` của từng dev nên đặt `automatically_update_urls_on_dev = true` (mặc định), để
|
|
59
|
+
Shopify CLI tự cập nhật URL app theo `VITE_HOST`. Mỗi dev nên có app dev riêng trên Partner, vì URL
|
|
60
|
+
app nằm trên Shopify chứ không nằm ở tunnel.
|
|
61
|
+
|
|
62
|
+
## Dev mới
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# lấy từ password manager
|
|
66
|
+
.cloudflared/cert.pem
|
|
67
|
+
.cloudflared/account.json
|
|
68
|
+
chmod 600 .cloudflared/*
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`.env`:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
VITE_HOST=dev-banner-huy.yourdomain.com
|
|
75
|
+
VITE_PORT=9000
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Chạy `pnpm dev`.
|
|
79
|
+
|
|
80
|
+
## Tuỳ chọn
|
|
81
|
+
|
|
82
|
+
| Flag | Tác dụng |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `-q`, `--quick` | URL `*.trycloudflare.com` ngẫu nhiên, chỉ cần `VITE_PORT`, không cần credential |
|
|
85
|
+
| `-f`, `--force` | Chiếm `VITE_HOST` dù DNS đang trỏ tunnel khác, hoặc tunnel đang có kết nối |
|
|
86
|
+
| còn lại | Chuyển tiếp cho `shopify app dev` (`--reset`, `--store=...`) |
|
|
87
|
+
|
|
88
|
+
## Chống trùng hostname
|
|
89
|
+
|
|
90
|
+
`@bss-sbc/shopify-dev` luôn ghi đè DNS và không kiểm tra connector đang chạy, nên hai người đặt
|
|
91
|
+
cùng `VITE_HOST` sẽ bị chia request ngẫu nhiên cho nhau. Tool này dừng với lỗi khi:
|
|
92
|
+
|
|
93
|
+
- tunnel của `VITE_HOST` đang có kết nối sống từ máy khác;
|
|
94
|
+
- DNS của `VITE_HOST` đang trỏ về tunnel khác.
|
|
95
|
+
|
|
96
|
+
Nếu chính process của bạn vừa chết và tunnel còn giữ kết nối cũ, chờ vài giây hoặc chạy với `--force`.
|
|
97
|
+
|
|
98
|
+
## Khác với `@bss-sbc/shopify-dev`
|
|
99
|
+
|
|
100
|
+
- Đọc `.env` bằng `util.parseEnv` của Node: dòng comment và giá trị có dấu `=` không còn làm hỏng.
|
|
101
|
+
- `account.json` và `cert.pem` chỉ bắt buộc ở named mode, `--quick` không cần.
|
|
102
|
+
- Credential của tunnel truyền qua env (`TUNNEL_CRED_CONTENTS`), không ghi file `<id>.json` ra đĩa.
|
|
103
|
+
- Không có lệnh `start` (prod), vì prod không nên chạy qua tunnel dev.
|
|
104
|
+
|
|
105
|
+
## Bảo mật
|
|
106
|
+
|
|
107
|
+
- `cert.pem` cho quyền tạo, xoá và trỏ DNS **mọi** tunnel trong account và zone đã chọn.
|
|
108
|
+
- Ai có `CF_SECRET_KEY` thì chạy được connector cho mọi tunnel mà tool đã tạo.
|
|
109
|
+
|
|
110
|
+
Vì vậy dùng account Cloudflare riêng cho dev, và không commit hai giá trị này. Rotate: đổi secret
|
|
111
|
+
trên password manager, xoá các tunnel cũ (`cloudflared tunnel delete`), lần `pnpm dev` sau sẽ tự
|
|
112
|
+
tạo lại.
|
|
113
|
+
|
|
114
|
+
## Test
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
node --test
|
|
118
|
+
```
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { resolveCloudflared } from '../src/cloudflared-binary.js';
|
|
4
|
+
import { ConfigError, loadConfig } from '../src/load-config.js';
|
|
5
|
+
import { stopCloudflared } from '../src/run-cloudflared.js';
|
|
6
|
+
import { startNamedTunnel, startQuickTunnel } from '../src/start-tunnel.js';
|
|
7
|
+
|
|
8
|
+
const USAGE = `Usage: shopify-tunnel-dev [dev] [options] [-- shopify app dev args]
|
|
9
|
+
|
|
10
|
+
Options:
|
|
11
|
+
-q, --quick Random *.trycloudflare.com URL, no Cloudflare credentials needed
|
|
12
|
+
-f, --force Take over VITE_HOST even if its DNS record or tunnel is in use
|
|
13
|
+
-h, --help Show this help
|
|
14
|
+
|
|
15
|
+
Any other argument (e.g. --reset, --store=foo) is passed to \`shopify app dev\`.`;
|
|
16
|
+
|
|
17
|
+
function parseArgs(argv) {
|
|
18
|
+
const args = argv[0] === 'dev' ? argv.slice(1) : argv;
|
|
19
|
+
const opts = { quick: false, force: false, help: false, passthrough: [] };
|
|
20
|
+
for (const arg of args) {
|
|
21
|
+
if (arg === '-q' || arg === '--quick') opts.quick = true;
|
|
22
|
+
else if (arg === '-f' || arg === '--force') opts.force = true;
|
|
23
|
+
else if (arg === '-h' || arg === '--help') opts.help = true;
|
|
24
|
+
else if (arg !== '--') opts.passthrough.push(arg);
|
|
25
|
+
}
|
|
26
|
+
return opts;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function main() {
|
|
30
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
31
|
+
if (opts.help) return console.log(USAGE);
|
|
32
|
+
|
|
33
|
+
const cfg = loadConfig({ quick: opts.quick });
|
|
34
|
+
const bin = await resolveCloudflared();
|
|
35
|
+
const tunnel = opts.quick ? await startQuickTunnel(bin, cfg) : await startNamedTunnel(bin, cfg, opts);
|
|
36
|
+
console.log(`[tunnel] https://${tunnel.host} -> http://localhost:${cfg.port}`);
|
|
37
|
+
|
|
38
|
+
const shopify = spawn(
|
|
39
|
+
'npx',
|
|
40
|
+
['shopify', 'app', 'dev', '--tunnel-url', `https://${tunnel.host}:${cfg.port}`, ...opts.passthrough],
|
|
41
|
+
{ stdio: 'inherit', shell: process.platform === 'win32' },
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Ctrl+C already reaches shopify through the process group; stay alive until it exits, and mark the
|
|
45
|
+
// tunnel as intentionally stopped so its exit is not reported as a crash. A second Ctrl+C forces out.
|
|
46
|
+
let interrupts = 0;
|
|
47
|
+
process.on('SIGINT', () => {
|
|
48
|
+
stopCloudflared(tunnel.child);
|
|
49
|
+
if (++interrupts > 1) {
|
|
50
|
+
killTree(shopify);
|
|
51
|
+
process.exit(130);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
process.on('SIGTERM', () => {
|
|
55
|
+
stopCloudflared(tunnel.child);
|
|
56
|
+
killTree(shopify);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
tunnel.child.on('exit', () => {
|
|
60
|
+
if (tunnel.child.killedByUs) return;
|
|
61
|
+
console.error('[tunnel] tunnel is down, stopping shopify app dev');
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
killTree(shopify);
|
|
64
|
+
});
|
|
65
|
+
shopify.on('exit', (code) => {
|
|
66
|
+
stopCloudflared(tunnel.child);
|
|
67
|
+
process.exitCode ||= code ?? 1;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// On Windows shopify runs under a cmd.exe shell and kill() would only end the wrapper, orphaning the
|
|
72
|
+
// real node process that holds the dev port; taskkill /T takes down the whole tree.
|
|
73
|
+
function killTree(child) {
|
|
74
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
75
|
+
if (process.platform === 'win32') spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
|
|
76
|
+
else child.kill('SIGINT');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
main().catch((err) => {
|
|
80
|
+
console.error(err instanceof ConfigError ? `[config] ${err.message}` : err);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "shopify-tunnel-dev",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Run `shopify app dev` behind a fixed-hostname Cloudflare named tunnel that the whole team shares one credential set for",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"shopify-tunnel-dev": "bin/shopify-tunnel-dev.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20.12"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node --test"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"shopify",
|
|
23
|
+
"shopify-app",
|
|
24
|
+
"cloudflare",
|
|
25
|
+
"tunnel",
|
|
26
|
+
"cloudflared"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"author": "Huy Nguyen The"
|
|
30
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const RELEASE_URL = 'https://github.com/cloudflare/cloudflared/releases/latest/download';
|
|
7
|
+
|
|
8
|
+
// Resolution order: CLOUDFLARED_BIN > `cloudflared` on PATH > binary cached in ~/.cache.
|
|
9
|
+
// ponytail: no checksum check on the download (HTTPS from GitHub only); pin a version + sha256 if supply chain matters.
|
|
10
|
+
export async function resolveCloudflared() {
|
|
11
|
+
if (process.env.CLOUDFLARED_BIN) return process.env.CLOUDFLARED_BIN;
|
|
12
|
+
if (isRunnable('cloudflared')) return 'cloudflared';
|
|
13
|
+
|
|
14
|
+
const cacheDir = path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), 'shopify-tunnel-dev');
|
|
15
|
+
const bin = path.join(cacheDir, process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared');
|
|
16
|
+
if (!existsSync(bin)) await download(cacheDir, bin);
|
|
17
|
+
return bin;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isRunnable(cmd) {
|
|
21
|
+
try {
|
|
22
|
+
execFileSync(cmd, ['--version'], { stdio: 'ignore' });
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function assetName() {
|
|
30
|
+
const arch = { x64: 'amd64', arm64: 'arm64', arm: 'arm', ia32: '386' }[process.arch];
|
|
31
|
+
if (arch && process.platform === 'linux') return `cloudflared-linux-${arch}`;
|
|
32
|
+
if (arch && process.platform === 'darwin') return `cloudflared-darwin-${arch}.tgz`;
|
|
33
|
+
if (arch && process.platform === 'win32') return `cloudflared-windows-${arch}.exe`;
|
|
34
|
+
throw new Error(`No cloudflared build for ${process.platform}/${process.arch}; install it and set CLOUDFLARED_BIN`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function download(cacheDir, bin) {
|
|
38
|
+
const asset = assetName();
|
|
39
|
+
console.log(`[tunnel] downloading ${asset} to ${cacheDir}`);
|
|
40
|
+
const res = await fetch(`${RELEASE_URL}/${asset}`);
|
|
41
|
+
if (!res.ok) throw new Error(`Downloading ${asset} failed: HTTP ${res.status}`);
|
|
42
|
+
|
|
43
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
44
|
+
// Write to a temp name first so an interrupted download never leaves a broken binary in the cache.
|
|
45
|
+
const tmp = `${bin}.${process.pid}.tmp`;
|
|
46
|
+
writeFileSync(tmp, Buffer.from(await res.arrayBuffer()));
|
|
47
|
+
if (asset.endsWith('.tgz')) {
|
|
48
|
+
execFileSync('tar', ['-xzf', tmp, '-C', cacheDir]); // the macOS archive holds a single `cloudflared`
|
|
49
|
+
rmSync(tmp);
|
|
50
|
+
} else {
|
|
51
|
+
renameSync(tmp, bin);
|
|
52
|
+
}
|
|
53
|
+
chmodSync(bin, 0o755);
|
|
54
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseEnv } from 'node:util';
|
|
4
|
+
|
|
5
|
+
export class ConfigError extends Error {}
|
|
6
|
+
|
|
7
|
+
// Same file layout and variable names as @bss-sbc/shopify-dev, so an existing repo can switch
|
|
8
|
+
// without renaming anything: web/.env wins over ./.env, and .cloudflared/account.json overrides
|
|
9
|
+
// keys from the env file.
|
|
10
|
+
export function loadConfig({ cwd = process.cwd(), quick = false } = {}) {
|
|
11
|
+
const envPath = [path.join(cwd, 'web/.env'), path.join(cwd, '.env')].find((p) => existsSync(p));
|
|
12
|
+
const cloudflaredDir = path.join(cwd, '.cloudflared');
|
|
13
|
+
const accountPath = path.join(cloudflaredDir, 'account.json');
|
|
14
|
+
const certPath = path.join(cloudflaredDir, 'cert.pem');
|
|
15
|
+
|
|
16
|
+
const env = envPath ? parseEnv(readFileSync(envPath, 'utf8')) : {};
|
|
17
|
+
const account = existsSync(accountPath) ? readJson(accountPath) : {};
|
|
18
|
+
const vars = { ...env, ...account };
|
|
19
|
+
|
|
20
|
+
const port = Number(vars.VITE_PORT);
|
|
21
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
22
|
+
throw new ConfigError(`VITE_PORT must be a port number, got "${vars.VITE_PORT ?? ''}"`);
|
|
23
|
+
}
|
|
24
|
+
if (quick) return { quick, port };
|
|
25
|
+
|
|
26
|
+
const missing = ['VITE_HOST', 'CF_ACCOUNT_TAG', 'CF_SECRET_KEY'].filter((key) => !vars[key]);
|
|
27
|
+
if (missing.length) {
|
|
28
|
+
throw new ConfigError(`Missing ${missing.join(', ')}. Put VITE_HOST in .env and CF_* in ${accountPath}`);
|
|
29
|
+
}
|
|
30
|
+
if (!existsSync(certPath)) {
|
|
31
|
+
throw new ConfigError(`Missing ${certPath} (origin cert from \`cloudflared tunnel login\`)`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const host = vars.VITE_HOST.trim();
|
|
35
|
+
if (vars.CF_HOST_PATTERN && !hostPattern(vars.CF_HOST_PATTERN).test(host)) {
|
|
36
|
+
throw new ConfigError(`VITE_HOST "${host}" does not match CF_HOST_PATTERN ${vars.CF_HOST_PATTERN}`);
|
|
37
|
+
}
|
|
38
|
+
// Tunnel name = first DNS label, same rule as @bss-sbc/shopify-dev, so both tools reuse one tunnel.
|
|
39
|
+
const tunnelName = host.split('.')[0];
|
|
40
|
+
if (!host.includes('.') || !/^[a-zA-Z0-9-]{3,63}$/.test(tunnelName)) {
|
|
41
|
+
throw new ConfigError(`VITE_HOST "${host}" must be a full hostname whose first label is 3-63 chars of [a-zA-Z0-9-]`);
|
|
42
|
+
}
|
|
43
|
+
// cloudflared rejects tunnel secrets that decode to fewer than 32 bytes.
|
|
44
|
+
if (Buffer.from(vars.CF_SECRET_KEY, 'base64').length < 32) {
|
|
45
|
+
throw new ConfigError('CF_SECRET_KEY must be base64 of at least 32 bytes (openssl rand -base64 32)');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
quick,
|
|
50
|
+
port,
|
|
51
|
+
host,
|
|
52
|
+
tunnelName,
|
|
53
|
+
accountTag: vars.CF_ACCOUNT_TAG,
|
|
54
|
+
secret: vars.CF_SECRET_KEY,
|
|
55
|
+
certPath,
|
|
56
|
+
cloudflaredDir,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function hostPattern(source) {
|
|
61
|
+
try {
|
|
62
|
+
return new RegExp(source);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
throw new ConfigError(`CF_HOST_PATTERN is not a valid regex: ${err.message}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readJson(file) {
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
71
|
+
} catch (err) {
|
|
72
|
+
throw new ConfigError(`Cannot parse ${file}: ${err.message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
const READY_TIMEOUT_MS = 30_000;
|
|
4
|
+
const TAIL_LINES = 30;
|
|
5
|
+
|
|
6
|
+
// Spawns a long-running cloudflared and resolves once the edge has registered a connection.
|
|
7
|
+
// Logs stay silent after that; if the process dies, the last lines are printed so the cause is visible.
|
|
8
|
+
export function runCloudflared(bin, args, env) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const child = spawn(bin, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
11
|
+
let output = '';
|
|
12
|
+
let ready = false;
|
|
13
|
+
|
|
14
|
+
const timer = setTimeout(() => {
|
|
15
|
+
child.kill();
|
|
16
|
+
reject(new Error(`cloudflared not connected after ${READY_TIMEOUT_MS / 1000}s:\n${tail(output)}`));
|
|
17
|
+
}, READY_TIMEOUT_MS);
|
|
18
|
+
|
|
19
|
+
const onData = (chunk) => {
|
|
20
|
+
output = (output + chunk).slice(-20_000);
|
|
21
|
+
if (!ready && output.includes('Registered tunnel connection')) {
|
|
22
|
+
ready = true;
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
resolve({ child, output });
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
child.stdout.on('data', onData);
|
|
28
|
+
child.stderr.on('data', onData);
|
|
29
|
+
|
|
30
|
+
child.on('error', (err) => {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
reject(err);
|
|
33
|
+
});
|
|
34
|
+
child.on('exit', (code, signal) => {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
const message = `cloudflared exited (${signal ?? code}):\n${tail(output)}`;
|
|
37
|
+
if (!ready) reject(new Error(message));
|
|
38
|
+
else if (!child.killedByUs) console.error(`[tunnel] ${message}`);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function stopCloudflared(child) {
|
|
44
|
+
child.killedByUs = true;
|
|
45
|
+
child.kill();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function tail(text) {
|
|
49
|
+
return text.trimEnd().split('\n').slice(-TAIL_LINES).join('\n');
|
|
50
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { rmSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { runCloudflared } from './run-cloudflared.js';
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
// Default grace period is 30s, which would hang every Ctrl+C; dev traffic does not need draining.
|
|
9
|
+
const baseEnv = () => ({ ...process.env, NO_AUTOUPDATE: 'true', TUNNEL_GRACE_PERIOD: '1s' });
|
|
10
|
+
|
|
11
|
+
// Throwaway *.trycloudflare.com URL, no Cloudflare account needed. Hostname changes every run.
|
|
12
|
+
export async function startQuickTunnel(bin, { port }) {
|
|
13
|
+
const { child, output } = await runCloudflared(
|
|
14
|
+
bin,
|
|
15
|
+
['tunnel', '--no-autoupdate', '--url', `http://localhost:${port}`],
|
|
16
|
+
baseEnv(),
|
|
17
|
+
);
|
|
18
|
+
const host = output.match(/https:\/\/([a-z0-9-]+\.trycloudflare\.com)/)?.[1];
|
|
19
|
+
if (!host) {
|
|
20
|
+
child.kill();
|
|
21
|
+
throw new Error('Quick tunnel connected but no trycloudflare.com URL was printed');
|
|
22
|
+
}
|
|
23
|
+
return { child, host };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Fixed hostname on the team's Cloudflare zone. Every dev shares cert.pem + one tunnel secret, so a
|
|
27
|
+
// tunnel created on one machine can be run from any other; the tunnel is created on first run only.
|
|
28
|
+
export async function startNamedTunnel(bin, cfg, { force = false } = {}) {
|
|
29
|
+
const env = { ...baseEnv(), TUNNEL_ORIGIN_CERT: cfg.certPath };
|
|
30
|
+
const cf = (args, extraEnv = {}) => execFileAsync(bin, args, { env: { ...env, ...extraEnv } });
|
|
31
|
+
|
|
32
|
+
let tunnel = await findTunnel(cf, cfg.tunnelName);
|
|
33
|
+
if (!tunnel) {
|
|
34
|
+
console.log(`[tunnel] creating tunnel ${cfg.tunnelName}`);
|
|
35
|
+
tunnel = await createTunnel(cf, cfg);
|
|
36
|
+
} else if (tunnel.connections?.length && !force) {
|
|
37
|
+
// Two connectors on one tunnel = Cloudflare load-balances requests between two laptops.
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Tunnel "${cfg.tunnelName}" already has ${tunnel.connections.length} live connection(s): ` +
|
|
40
|
+
`someone else is probably running with VITE_HOST=${cfg.host}. ` +
|
|
41
|
+
'Pick another VITE_HOST, or pass --force if it is your own process that just died.',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await routeDns(cf, tunnel.id, cfg.host, force);
|
|
46
|
+
|
|
47
|
+
// Credentials are rebuilt from the shared secret and handed over via env, never written to disk.
|
|
48
|
+
const credentials = JSON.stringify({ AccountTag: cfg.accountTag, TunnelSecret: cfg.secret, TunnelID: tunnel.id });
|
|
49
|
+
const { child } = await runCloudflared(
|
|
50
|
+
bin,
|
|
51
|
+
['tunnel', '--no-autoupdate', 'run', '--url', `http://localhost:${cfg.port}`, tunnel.id],
|
|
52
|
+
{ ...env, TUNNEL_CRED_CONTENTS: credentials },
|
|
53
|
+
);
|
|
54
|
+
return { child, host: cfg.host };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function findTunnel(cf, name) {
|
|
58
|
+
const { stdout } = await cf(['tunnel', 'list', '--output', 'json', '--name', name]);
|
|
59
|
+
return (JSON.parse(stdout || '[]') ?? []).find((t) => t.name === name);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function createTunnel(cf, cfg) {
|
|
63
|
+
// create insists on writing a credentials file; run uses TUNNEL_CRED_CONTENTS instead, so drop it.
|
|
64
|
+
// It goes next to account.json (already secret and gitignored), not world-readable tmp, in case a
|
|
65
|
+
// hard kill skips the cleanup.
|
|
66
|
+
const credFile = path.join(cfg.cloudflaredDir, `create-${process.pid}.json`);
|
|
67
|
+
try {
|
|
68
|
+
const { stdout } = await cf(
|
|
69
|
+
['tunnel', 'create', '--output', 'json', '--credentials-file', credFile, cfg.tunnelName],
|
|
70
|
+
{ TUNNEL_CREATE_SECRET: cfg.secret },
|
|
71
|
+
);
|
|
72
|
+
const tunnel = JSON.parse(stdout);
|
|
73
|
+
if (!tunnel?.id) throw new Error(`Unexpected \`cloudflared tunnel create\` output: ${stdout}`);
|
|
74
|
+
return tunnel;
|
|
75
|
+
} finally {
|
|
76
|
+
rmSync(credFile, { force: true });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function routeDns(cf, tunnelId, host, force) {
|
|
81
|
+
try {
|
|
82
|
+
await cf(['tunnel', 'route', 'dns', ...(force ? ['--overwrite-dns'] : []), tunnelId, host]);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
const detail = (err.stderr || err.message).trim();
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Cannot point ${host} at tunnel ${tunnelId}: ${detail}\n` +
|
|
87
|
+
'If the record belongs to another tunnel, pick another VITE_HOST or pass --force to take it over.',
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|