buncargo 3.2.3 → 3.2.5
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/dist/cli/bin.js +10 -10
- package/dist/cli/index.js +4 -2
- package/dist/core/network.js +1 -1
- package/dist/core/quick-tunnel/cloudflared-process.d.ts +3 -0
- package/dist/core/quick-tunnel/constants.d.ts +2 -0
- package/dist/core/quick-tunnel/index.d.ts +4 -3
- package/dist/core/utils.js +1 -1
- package/dist/docker/index.js +2 -2
- package/dist/environment/index.js +5 -5
- package/dist/index-1fset27q.js +72 -0
- package/dist/index-2bcjw5n0.js +666 -0
- package/dist/index-39s6ez1q.js +250 -0
- package/dist/index-5vg657rh.js +72 -0
- package/dist/index-6att53sd.js +250 -0
- package/dist/index-94kgbw4m.js +72 -0
- package/dist/index-96q4yh56.js +72 -0
- package/dist/index-bgcx898h.js +451 -0
- package/dist/index-c28x1pjb.js +250 -0
- package/dist/index-c2v0t0y2.js +250 -0
- package/dist/index-cm05c27w.js +417 -0
- package/dist/index-emcawhxm.js +250 -0
- package/dist/index-fkgqg6w2.js +125 -0
- package/dist/index-gfjdt37q.js +391 -0
- package/dist/index-gfs10vb8.js +389 -0
- package/dist/index-pbwvaz4v.js +666 -0
- package/dist/index-pmbmwg3x.js +72 -0
- package/dist/index-pt8t9tkg.js +389 -0
- package/dist/index-qnpd5fn5.js +666 -0
- package/dist/index-qtprmjbm.js +399 -0
- package/dist/index-qz66apm2.js +250 -0
- package/dist/index-thsdxz7m.js +250 -0
- package/dist/index-twwcjn9p.js +228 -0
- package/dist/index-tyk17rfn.js +666 -0
- package/dist/index-vj8kaz2d.js +72 -0
- package/dist/index-vr4ygtyj.js +415 -0
- package/dist/index-wmgx8rsm.js +666 -0
- package/dist/index-ymdvr5sn.js +666 -0
- package/dist/index-yw46g4tr.js +666 -0
- package/dist/index-znaek8z2.js +72 -0
- package/dist/index.js +25 -25
- package/dist/loader/index.js +6 -6
- package/package.json +3 -3
- package/src/core/quick-tunnel/cloudflared-process.ts +73 -12
- package/src/core/quick-tunnel/constants.ts +14 -1
- package/src/core/quick-tunnel/index.ts +82 -40
- package/src/core/quick-tunnel/install.ts +1 -1
- package/src/core/tunnel.ts +25 -21
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createDevEnvironment
|
|
3
|
+
} from "./index-ymdvr5sn.js";
|
|
4
|
+
|
|
5
|
+
// src/loader/cache.ts
|
|
6
|
+
var cachedEnv = null;
|
|
7
|
+
function setCachedDevEnv(env) {
|
|
8
|
+
cachedEnv = env;
|
|
9
|
+
}
|
|
10
|
+
function getCachedDevEnv() {
|
|
11
|
+
return cachedEnv;
|
|
12
|
+
}
|
|
13
|
+
function clearDevEnvCache() {
|
|
14
|
+
cachedEnv = null;
|
|
15
|
+
}
|
|
16
|
+
// src/loader/find-config-file.ts
|
|
17
|
+
import { existsSync } from "node:fs";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
var CONFIG_FILES = [
|
|
20
|
+
"dev.config.ts",
|
|
21
|
+
"dev.config.js",
|
|
22
|
+
"dev-tools.config.ts",
|
|
23
|
+
"dev-tools.config.js"
|
|
24
|
+
];
|
|
25
|
+
function findConfigFile(startDir) {
|
|
26
|
+
let currentDir = startDir;
|
|
27
|
+
while (true) {
|
|
28
|
+
for (const file of CONFIG_FILES) {
|
|
29
|
+
const configPath = join(currentDir, file);
|
|
30
|
+
if (existsSync(configPath)) {
|
|
31
|
+
return configPath;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const parentDir = dirname(currentDir);
|
|
35
|
+
if (parentDir === currentDir) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
currentDir = parentDir;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// src/loader/load-dev-env.ts
|
|
42
|
+
async function loadDevEnv(options) {
|
|
43
|
+
if (!options?.reload) {
|
|
44
|
+
const cached = getCachedDevEnv();
|
|
45
|
+
if (cached)
|
|
46
|
+
return cached;
|
|
47
|
+
}
|
|
48
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
49
|
+
const configPath = findConfigFile(cwd);
|
|
50
|
+
if (configPath) {
|
|
51
|
+
const mod = await import(configPath);
|
|
52
|
+
const config = mod.default;
|
|
53
|
+
if (!config?.projectPrefix || !config?.services) {
|
|
54
|
+
throw new Error(`Invalid config in "${configPath}". Use defineDevConfig() and export as default.`);
|
|
55
|
+
}
|
|
56
|
+
const env = createDevEnvironment(config);
|
|
57
|
+
setCachedDevEnv(env);
|
|
58
|
+
return env;
|
|
59
|
+
}
|
|
60
|
+
throw new Error("No config file found. Create dev.config.ts with: export default defineDevConfig({ ... })");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/loader/index.ts
|
|
64
|
+
function getDevEnv() {
|
|
65
|
+
const env = getCachedDevEnv();
|
|
66
|
+
if (!env) {
|
|
67
|
+
throw new Error("Dev environment not loaded. Call loadDevEnv() first.");
|
|
68
|
+
}
|
|
69
|
+
return env;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export { clearDevEnvCache, CONFIG_FILES, findConfigFile, loadDevEnv, getDevEnv };
|
package/dist/index.js
CHANGED
|
@@ -3,15 +3,15 @@ import {
|
|
|
3
3
|
getFlagValue,
|
|
4
4
|
hasFlag,
|
|
5
5
|
runCli
|
|
6
|
-
} from "./index-
|
|
6
|
+
} from "./index-c28x1pjb.js";
|
|
7
7
|
import {
|
|
8
8
|
clearDevEnvCache,
|
|
9
9
|
getDevEnv,
|
|
10
10
|
loadDevEnv
|
|
11
|
-
} from "./index-
|
|
11
|
+
} from "./index-znaek8z2.js";
|
|
12
12
|
import {
|
|
13
13
|
createDevEnvironment
|
|
14
|
-
} from "./index-
|
|
14
|
+
} from "./index-ymdvr5sn.js";
|
|
15
15
|
import {
|
|
16
16
|
DOCKER_NOT_RUNNING_MESSAGE,
|
|
17
17
|
MAX_ATTEMPTS,
|
|
@@ -20,27 +20,7 @@ import {
|
|
|
20
20
|
assertDockerRunning,
|
|
21
21
|
isContainerRunning,
|
|
22
22
|
isDockerRunning
|
|
23
|
-
} from "./index-
|
|
24
|
-
import {
|
|
25
|
-
getEnvVar,
|
|
26
|
-
getLocalIp,
|
|
27
|
-
isCI,
|
|
28
|
-
isPortAvailable,
|
|
29
|
-
logApiUrl,
|
|
30
|
-
logExpoApiUrl,
|
|
31
|
-
logFrontendPort,
|
|
32
|
-
sleep,
|
|
33
|
-
waitForServer
|
|
34
|
-
} from "./index-c0dr6mcv.js";
|
|
35
|
-
import {
|
|
36
|
-
calculatePortOffset,
|
|
37
|
-
computeDevIdentity,
|
|
38
|
-
findMonorepoRoot,
|
|
39
|
-
getProjectName,
|
|
40
|
-
getWorktreeName,
|
|
41
|
-
getWorktreeProjectSuffix,
|
|
42
|
-
isWorktree
|
|
43
|
-
} from "./index-fb29934k.js";
|
|
23
|
+
} from "./index-twwcjn9p.js";
|
|
44
24
|
import {
|
|
45
25
|
DEFAULT_GENERATED_COMPOSE_FILE,
|
|
46
26
|
buildComposeModel,
|
|
@@ -53,7 +33,7 @@ import {
|
|
|
53
33
|
resolveExposeTargets,
|
|
54
34
|
startPublicTunnels,
|
|
55
35
|
stopPublicTunnels
|
|
56
|
-
} from "./index-
|
|
36
|
+
} from "./index-bgcx898h.js";
|
|
57
37
|
import {
|
|
58
38
|
getHeartbeatFile,
|
|
59
39
|
getWatchdogPidFile,
|
|
@@ -71,6 +51,26 @@ import {
|
|
|
71
51
|
killProcessOnPortAndWait,
|
|
72
52
|
killProcessesOnAppPorts
|
|
73
53
|
} from "./index-mm412dkp.js";
|
|
54
|
+
import {
|
|
55
|
+
getEnvVar,
|
|
56
|
+
getLocalIp,
|
|
57
|
+
isCI,
|
|
58
|
+
isPortAvailable,
|
|
59
|
+
logApiUrl,
|
|
60
|
+
logExpoApiUrl,
|
|
61
|
+
logFrontendPort,
|
|
62
|
+
sleep,
|
|
63
|
+
waitForServer
|
|
64
|
+
} from "./index-fkgqg6w2.js";
|
|
65
|
+
import {
|
|
66
|
+
calculatePortOffset,
|
|
67
|
+
computeDevIdentity,
|
|
68
|
+
findMonorepoRoot,
|
|
69
|
+
getProjectName,
|
|
70
|
+
getWorktreeName,
|
|
71
|
+
getWorktreeProjectSuffix,
|
|
72
|
+
isWorktree
|
|
73
|
+
} from "./index-fb29934k.js";
|
|
74
74
|
import {
|
|
75
75
|
assertValidConfig,
|
|
76
76
|
defineDevConfig,
|
package/dist/loader/index.js
CHANGED
|
@@ -4,15 +4,15 @@ import {
|
|
|
4
4
|
findConfigFile,
|
|
5
5
|
getDevEnv,
|
|
6
6
|
loadDevEnv
|
|
7
|
-
} from "../index-
|
|
8
|
-
import"../index-
|
|
9
|
-
import"../index-
|
|
10
|
-
import"../index-c0dr6mcv.js";
|
|
11
|
-
import"../index-fb29934k.js";
|
|
7
|
+
} from "../index-znaek8z2.js";
|
|
8
|
+
import"../index-ymdvr5sn.js";
|
|
9
|
+
import"../index-twwcjn9p.js";
|
|
12
10
|
import"../index-5t9jxqm0.js";
|
|
13
|
-
import"../index-
|
|
11
|
+
import"../index-bgcx898h.js";
|
|
14
12
|
import"../index-mam0bcyz.js";
|
|
15
13
|
import"../index-mm412dkp.js";
|
|
14
|
+
import"../index-fkgqg6w2.js";
|
|
15
|
+
import"../index-fb29934k.js";
|
|
16
16
|
import"../index-t0fj6gg1.js";
|
|
17
17
|
import"../index-qnx9j3qa.js";
|
|
18
18
|
export {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "buncargo",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.5",
|
|
4
4
|
"description": "A Bun-powered development environment CLI for managing Docker Compose services, dev servers, and environment variables",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./dist/index.js",
|
|
@@ -133,8 +133,8 @@
|
|
|
133
133
|
"lint:write": "bun run typecheck && biome check --fix src example && biome format src example",
|
|
134
134
|
"typecheck": "tsgo --incremental",
|
|
135
135
|
"test": "bun test",
|
|
136
|
-
"test:integration-cloudflared": "bun test src/core/quick-tunnel/quick-tunnel.test.ts",
|
|
137
|
-
"test:integration-cloudflared-e2e": "BUNCARGO_TEST_CLOUDFLARED_E2E=1 bun test src/core/quick-tunnel/quick-tunnel.test.ts"
|
|
136
|
+
"test:integration-cloudflared": "BUNCARGO_TEST_CLOUDFLARED_SMOKE=1 bun test src/core/quick-tunnel/quick-tunnel.test.ts",
|
|
137
|
+
"test:integration-cloudflared-e2e": "BUNCARGO_TEST_CLOUDFLARED_SMOKE=1 BUNCARGO_TEST_CLOUDFLARED_E2E=1 bun test src/core/quick-tunnel/quick-tunnel.test.ts"
|
|
138
138
|
},
|
|
139
139
|
"devDependencies": {
|
|
140
140
|
"@types/bun": "1.3.2",
|
|
@@ -3,9 +3,34 @@
|
|
|
3
3
|
* Derived from unjs/untun (MIT), originally forked from node-cloudflared.
|
|
4
4
|
*/
|
|
5
5
|
import { type ChildProcess, spawn } from "node:child_process";
|
|
6
|
-
import {
|
|
6
|
+
import { resolvedCloudflaredBinPath } from "./constants";
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
/** Primary: ASCII box line from cloudflared (`| https://… |`). */
|
|
9
|
+
const urlRegexPipe = /\|\s+(https?:\/\/\S+)/;
|
|
10
|
+
/** Fallback: URL may appear without the leading pipe if logs wrap or format changes. */
|
|
11
|
+
const urlRegexTryCloudflare =
|
|
12
|
+
/(https:\/\/[a-zA-Z0-9][-a-zA-Z0-9.]*\.trycloudflare\.com)\b/;
|
|
13
|
+
|
|
14
|
+
const MAX_CAPTURED_LOG = 24_000;
|
|
15
|
+
|
|
16
|
+
/** Default 30s; set `BUNCARGO_QUICK_TUNNEL_TIMEOUT_MS=0` to disable. */
|
|
17
|
+
export function resolveQuickTunnelUrlTimeoutMs(): number {
|
|
18
|
+
const raw = process.env.BUNCARGO_QUICK_TUNNEL_TIMEOUT_MS;
|
|
19
|
+
if (raw === undefined || raw === "") {
|
|
20
|
+
return 30_000;
|
|
21
|
+
}
|
|
22
|
+
const n = Number.parseInt(raw, 10);
|
|
23
|
+
return Number.isFinite(n) && n >= 0 ? n : 30_000;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parseQuickTunnelUrlFromOutput(log: string): string | null {
|
|
27
|
+
const pipe = log.match(urlRegexPipe);
|
|
28
|
+
if (pipe?.[1]) {
|
|
29
|
+
return pipe[1];
|
|
30
|
+
}
|
|
31
|
+
const direct = log.match(urlRegexTryCloudflare);
|
|
32
|
+
return direct?.[1] ?? null;
|
|
33
|
+
}
|
|
9
34
|
|
|
10
35
|
export function startCloudflaredTunnel(
|
|
11
36
|
options: Record<string, string | number | null>,
|
|
@@ -28,7 +53,8 @@ export function startCloudflaredTunnel(
|
|
|
28
53
|
args.push("--url", "localhost:8080");
|
|
29
54
|
}
|
|
30
55
|
|
|
31
|
-
const
|
|
56
|
+
const binPath = resolvedCloudflaredBinPath();
|
|
57
|
+
const child = spawn(binPath, args, {
|
|
32
58
|
stdio: ["ignore", "pipe", "pipe"],
|
|
33
59
|
});
|
|
34
60
|
|
|
@@ -40,37 +66,72 @@ export function startCloudflaredTunnel(
|
|
|
40
66
|
let settled = false;
|
|
41
67
|
let urlResolver!: (value: string | PromiseLike<string>) => void;
|
|
42
68
|
let urlRejector!: (reason: unknown) => void;
|
|
69
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
70
|
+
|
|
71
|
+
const clearUrlTimeout = () => {
|
|
72
|
+
if (timeoutId !== undefined) {
|
|
73
|
+
clearTimeout(timeoutId);
|
|
74
|
+
timeoutId = undefined;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
43
78
|
const url = new Promise<string>((resolve, reject) => {
|
|
44
79
|
urlResolver = (v) => {
|
|
45
80
|
if (!settled) {
|
|
46
81
|
settled = true;
|
|
82
|
+
clearUrlTimeout();
|
|
47
83
|
resolve(v);
|
|
48
84
|
}
|
|
49
85
|
};
|
|
50
86
|
urlRejector = (e) => {
|
|
51
87
|
if (!settled) {
|
|
52
88
|
settled = true;
|
|
89
|
+
clearUrlTimeout();
|
|
53
90
|
reject(e);
|
|
54
91
|
}
|
|
55
92
|
};
|
|
56
|
-
});
|
|
57
93
|
|
|
58
|
-
|
|
59
|
-
|
|
94
|
+
const timeoutMs = resolveQuickTunnelUrlTimeoutMs();
|
|
95
|
+
if (timeoutMs > 0) {
|
|
96
|
+
timeoutId = setTimeout(() => {
|
|
97
|
+
try {
|
|
98
|
+
child.kill("SIGINT");
|
|
99
|
+
} catch {
|
|
100
|
+
/* ignore */
|
|
101
|
+
}
|
|
102
|
+
urlRejector(
|
|
103
|
+
new Error(
|
|
104
|
+
`quick tunnel URL timed out after ${timeoutMs}ms (no public URL from cloudflared)`,
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
}, timeoutMs);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
60
110
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
111
|
+
const log: { buf: string } = { buf: "" };
|
|
112
|
+
const append = (data: Buffer) => {
|
|
113
|
+
log.buf += data.toString();
|
|
114
|
+
if (log.buf.length > MAX_CAPTURED_LOG) {
|
|
115
|
+
log.buf = log.buf.slice(-MAX_CAPTURED_LOG);
|
|
116
|
+
}
|
|
117
|
+
const url = parseQuickTunnelUrlFromOutput(log.buf);
|
|
118
|
+
if (url) {
|
|
119
|
+
urlResolver(url);
|
|
64
120
|
}
|
|
65
121
|
};
|
|
66
|
-
child.stdout?.on("data",
|
|
67
|
-
child.stderr?.on("data",
|
|
122
|
+
child.stdout?.on("data", append).on("error", urlRejector);
|
|
123
|
+
child.stderr?.on("data", append).on("error", urlRejector);
|
|
68
124
|
|
|
69
125
|
child.on("exit", (code, signal) => {
|
|
70
126
|
if (!settled) {
|
|
127
|
+
const tail = log.buf.trimEnd();
|
|
128
|
+
const excerpt = tail.length > 1200 ? `…${tail.slice(-1200)}` : tail;
|
|
129
|
+
const detail = excerpt ? `\ncloudflared output (tail):\n${excerpt}` : "";
|
|
71
130
|
urlRejector(
|
|
72
131
|
new Error(
|
|
73
|
-
`cloudflared exited before a tunnel URL was parsed (code=${code}, signal=${signal ?? "none"})
|
|
132
|
+
`cloudflared exited before a tunnel URL was parsed (code=${code}, signal=${signal ?? "none"}). ` +
|
|
133
|
+
`Parallel quick-tunnel requests are often rate-limited; buncargo starts tunnels sequentially with a short pause. ` +
|
|
134
|
+
`If this persists, try fewer expose targets or increase BUNCARGO_EXPOSE_TUNNEL_STAGGER_MS.${detail}`,
|
|
74
135
|
),
|
|
75
136
|
);
|
|
76
137
|
}
|
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
* Derived from unjs/untun (MIT), originally forked from node-cloudflared.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
6
7
|
import { tmpdir } from "node:os";
|
|
7
8
|
import path from "node:path";
|
|
8
9
|
|
|
9
10
|
export const CLOUDFLARED_VERSION =
|
|
10
|
-
process.env.CLOUDFLARED_VERSION || "
|
|
11
|
+
process.env.CLOUDFLARED_VERSION || "2026.3.0";
|
|
11
12
|
|
|
12
13
|
export const RELEASE_BASE =
|
|
13
14
|
"https://github.com/cloudflare/cloudflared/releases/";
|
|
@@ -21,6 +22,18 @@ export const cloudflaredBinPath = path.join(
|
|
|
21
22
|
: `cloudflared.${CLOUDFLARED_VERSION}`,
|
|
22
23
|
);
|
|
23
24
|
|
|
25
|
+
/** Spawn/install target: optional `BUNCARGO_CLOUDFLARED_PATH` overrides the bundled cache path. */
|
|
26
|
+
export function resolvedCloudflaredBinPath(): string {
|
|
27
|
+
const override = process.env.BUNCARGO_CLOUDFLARED_PATH?.trim();
|
|
28
|
+
if (override) {
|
|
29
|
+
if (!existsSync(override)) {
|
|
30
|
+
throw new Error(`BUNCARGO_CLOUDFLARED_PATH does not exist: ${override}`);
|
|
31
|
+
}
|
|
32
|
+
return path.resolve(override);
|
|
33
|
+
}
|
|
34
|
+
return cloudflaredBinPath;
|
|
35
|
+
}
|
|
36
|
+
|
|
24
37
|
export const cloudflaredNotice = `
|
|
25
38
|
🔥 Your installation of cloudflared software constitutes a symbol of your signature
|
|
26
39
|
indicating that you accept the terms of the Cloudflare License, Terms and Privacy Policy.
|
|
@@ -3,18 +3,90 @@
|
|
|
3
3
|
* License / download flow adapted from unjs/untun (MIT).
|
|
4
4
|
*/
|
|
5
5
|
import { existsSync } from "node:fs";
|
|
6
|
-
import {
|
|
6
|
+
import { sleep } from "../utils";
|
|
7
7
|
import { startCloudflaredTunnel } from "./cloudflared-process";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
cloudflaredBinPath,
|
|
10
|
+
cloudflaredNotice,
|
|
11
|
+
resolvedCloudflaredBinPath,
|
|
12
|
+
} from "./constants";
|
|
9
13
|
import { installCloudflared } from "./install";
|
|
10
14
|
|
|
15
|
+
function resolveMaxQuickTunnelAttempts(): number {
|
|
16
|
+
const raw = process.env.BUNCARGO_QUICK_TUNNEL_MAX_ATTEMPTS;
|
|
17
|
+
if (raw === undefined || raw === "") {
|
|
18
|
+
return 5;
|
|
19
|
+
}
|
|
20
|
+
const n = Number.parseInt(raw, 10);
|
|
21
|
+
return Number.isFinite(n) && n >= 1 ? n : 5;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveQuickTunnelRetryBaseMs(): number {
|
|
25
|
+
const raw = process.env.BUNCARGO_QUICK_TUNNEL_RETRY_BASE_MS;
|
|
26
|
+
if (raw === undefined || raw === "") {
|
|
27
|
+
return 2000;
|
|
28
|
+
}
|
|
29
|
+
const n = Number.parseInt(raw, 10);
|
|
30
|
+
return Number.isFinite(n) && n >= 0 ? n : 2000;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function usesBundledCloudflaredCache(): boolean {
|
|
34
|
+
return !process.env.BUNCARGO_CLOUDFLARED_PATH?.trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True when trycloudflare.com is overloaded / rate-limited or returns non-JSON (cloudflared then errors on unmarshal). */
|
|
38
|
+
export function isRetryableQuickTunnelError(message: string): boolean {
|
|
39
|
+
return (
|
|
40
|
+
message.includes("429") ||
|
|
41
|
+
message.includes("Too Many Requests") ||
|
|
42
|
+
message.includes('status_code="429') ||
|
|
43
|
+
// Plain-text "error" or HTML error pages — see cloudflare/cloudflared#972
|
|
44
|
+
message.includes("failed to unmarshal quick Tunnel") ||
|
|
45
|
+
message.includes("failed to unmarshall quick Tunnel") ||
|
|
46
|
+
message.includes("Error unmarshaling QuickTunnel") ||
|
|
47
|
+
message.includes("invalid character '<'") ||
|
|
48
|
+
message.includes("quick tunnel URL timed out")
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function startCloudflaredTunnelWithRetry(
|
|
53
|
+
cfArgs: Record<string, string | number | null>,
|
|
54
|
+
): Promise<ReturnType<typeof startCloudflaredTunnel>> {
|
|
55
|
+
const maxAttempts = resolveMaxQuickTunnelAttempts();
|
|
56
|
+
const baseMs = resolveQuickTunnelRetryBaseMs();
|
|
57
|
+
|
|
58
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
59
|
+
const tunnel = startCloudflaredTunnel(cfArgs);
|
|
60
|
+
try {
|
|
61
|
+
await tunnel.url;
|
|
62
|
+
return tunnel;
|
|
63
|
+
} catch (e) {
|
|
64
|
+
try {
|
|
65
|
+
tunnel.stop();
|
|
66
|
+
} catch {
|
|
67
|
+
/* ignore */
|
|
68
|
+
}
|
|
69
|
+
const msg = String(e);
|
|
70
|
+
if (attempt < maxAttempts && isRetryableQuickTunnelError(msg)) {
|
|
71
|
+
const delayMs = baseMs * attempt;
|
|
72
|
+
console.log(
|
|
73
|
+
`Cloudflare quick tunnel temporarily unavailable (${attempt}/${maxAttempts}), retrying in ${delayMs}ms…`,
|
|
74
|
+
);
|
|
75
|
+
await sleep(delayMs);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
throw e;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
throw new Error("startCloudflaredTunnelWithRetry: exhausted attempts");
|
|
82
|
+
}
|
|
83
|
+
|
|
11
84
|
export interface QuickTunnelOptions {
|
|
12
85
|
url?: string;
|
|
13
86
|
port?: number | string;
|
|
14
87
|
hostname?: string;
|
|
15
88
|
protocol?: "http" | "https";
|
|
16
89
|
verifyTLS?: boolean;
|
|
17
|
-
acceptCloudflareNotice?: boolean;
|
|
18
90
|
}
|
|
19
91
|
|
|
20
92
|
export interface QuickTunnel {
|
|
@@ -29,52 +101,22 @@ function resolvedLocalUrl(opts: QuickTunnelOptions): string {
|
|
|
29
101
|
);
|
|
30
102
|
}
|
|
31
103
|
|
|
32
|
-
function envAcceptsCloudflareNotice(): boolean {
|
|
33
|
-
const v = process.env.BUNCARGO_ACCEPT_CLOUDFLARE_NOTICE;
|
|
34
|
-
const u = process.env.UNTUN_ACCEPT_CLOUDFLARE_NOTICE;
|
|
35
|
-
return v === "1" || v === "true" || u === "1" || u === "true";
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
async function promptInstallCloudflared(): Promise<boolean> {
|
|
39
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
42
|
-
return new Promise((resolve) => {
|
|
43
|
-
const rl = createInterface({
|
|
44
|
-
input: process.stdin,
|
|
45
|
-
output: process.stdout,
|
|
46
|
-
});
|
|
47
|
-
rl.question(
|
|
48
|
-
"Do you agree with the above terms and wish to install the binary from GitHub? (y/N) ",
|
|
49
|
-
(answer) => {
|
|
50
|
-
rl.close();
|
|
51
|
-
resolve(/^y(es)?$/i.test(answer.trim()));
|
|
52
|
-
},
|
|
53
|
-
);
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
|
|
57
104
|
/**
|
|
58
105
|
* Start a Cloudflare quick tunnel to a local HTTP(S) URL.
|
|
59
|
-
*
|
|
106
|
+
* If the cloudflared binary is missing, prints the license notice and installs it from GitHub.
|
|
60
107
|
*/
|
|
61
108
|
export async function startQuickTunnel(
|
|
62
109
|
opts: QuickTunnelOptions,
|
|
63
|
-
): Promise<QuickTunnel
|
|
110
|
+
): Promise<QuickTunnel> {
|
|
64
111
|
const url = resolvedLocalUrl(opts);
|
|
65
112
|
|
|
66
113
|
console.log(`Starting cloudflared tunnel to ${url}`);
|
|
67
114
|
|
|
68
|
-
if
|
|
115
|
+
// Resolve path first (throws if BUNCARGO_CLOUDFLARED_PATH is invalid).
|
|
116
|
+
resolvedCloudflaredBinPath();
|
|
117
|
+
|
|
118
|
+
if (usesBundledCloudflaredCache() && !existsSync(cloudflaredBinPath)) {
|
|
69
119
|
console.log(cloudflaredNotice);
|
|
70
|
-
const canInstall =
|
|
71
|
-
opts.acceptCloudflareNotice ||
|
|
72
|
-
envAcceptsCloudflareNotice() ||
|
|
73
|
-
(await promptInstallCloudflared());
|
|
74
|
-
if (!canInstall) {
|
|
75
|
-
console.error("Skipping tunnel setup.");
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
120
|
await installCloudflared();
|
|
79
121
|
}
|
|
80
122
|
|
|
@@ -83,7 +125,7 @@ export async function startQuickTunnel(
|
|
|
83
125
|
if (!opts.verifyTLS) {
|
|
84
126
|
cfArgs["--no-tls-verify"] = null;
|
|
85
127
|
}
|
|
86
|
-
const tunnel =
|
|
128
|
+
const tunnel = await startCloudflaredTunnelWithRetry(cfArgs);
|
|
87
129
|
|
|
88
130
|
const cleanup = async () => {
|
|
89
131
|
tunnel.stop();
|
|
@@ -20,7 +20,7 @@ const LINUX_URL: Partial<Record<NodeJS.Architecture, string>> = {
|
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
const MACOS_URL: Partial<Record<NodeJS.Architecture, string>> = {
|
|
23
|
-
arm64: "cloudflared-darwin-
|
|
23
|
+
arm64: "cloudflared-darwin-arm64.tgz",
|
|
24
24
|
x64: "cloudflared-darwin-amd64.tgz",
|
|
25
25
|
};
|
|
26
26
|
|
package/src/core/tunnel.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AppConfig, DevEnvironment, ServiceConfig } from "../types";
|
|
2
2
|
import { startQuickTunnel } from "./quick-tunnel";
|
|
3
|
+
import { sleep } from "./utils";
|
|
3
4
|
|
|
4
5
|
export interface PublicExposeTarget {
|
|
5
6
|
kind: "service" | "app";
|
|
@@ -115,6 +116,15 @@ export function resolveExposeTargets<
|
|
|
115
116
|
return { targets, unknownNames, notEnabledNames };
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
function resolveExposeTunnelStaggerMs(): number {
|
|
120
|
+
const raw = process.env.BUNCARGO_EXPOSE_TUNNEL_STAGGER_MS;
|
|
121
|
+
if (raw === undefined || raw === "") {
|
|
122
|
+
return 900;
|
|
123
|
+
}
|
|
124
|
+
const n = Number.parseInt(raw, 10);
|
|
125
|
+
return Number.isFinite(n) && n >= 0 ? n : 900;
|
|
126
|
+
}
|
|
127
|
+
|
|
118
128
|
export async function startPublicTunnels(
|
|
119
129
|
targets: PublicExposeTarget[],
|
|
120
130
|
options: {
|
|
@@ -124,16 +134,23 @@ export async function startPublicTunnels(
|
|
|
124
134
|
} = {},
|
|
125
135
|
): Promise<PublicTunnel[]> {
|
|
126
136
|
const start = options.start ?? ((input) => startQuickTunnel(input));
|
|
137
|
+
const staggerMs = resolveExposeTunnelStaggerMs();
|
|
127
138
|
|
|
128
|
-
const
|
|
129
|
-
|
|
139
|
+
const tunnels: PublicTunnel[] = [];
|
|
140
|
+
try {
|
|
141
|
+
let index = 0;
|
|
142
|
+
for (const target of targets) {
|
|
143
|
+
if (index > 0 && staggerMs > 0) {
|
|
144
|
+
await sleep(staggerMs);
|
|
145
|
+
}
|
|
146
|
+
index += 1;
|
|
130
147
|
const localUrl = `http://localhost:${target.port}`;
|
|
131
148
|
const tunnel = (await start({
|
|
132
149
|
url: localUrl,
|
|
133
150
|
})) as TunnelBackendResult | undefined;
|
|
134
151
|
if (tunnel === undefined) {
|
|
135
152
|
throw new Error(
|
|
136
|
-
`Tunnel for "${target.name}" could not be started (
|
|
153
|
+
`Tunnel for "${target.name}" could not be started (tunnel backend returned no instance)`,
|
|
137
154
|
);
|
|
138
155
|
}
|
|
139
156
|
const publicUrl = await resolvePublicUrl(tunnel);
|
|
@@ -142,32 +159,19 @@ export async function startPublicTunnels(
|
|
|
142
159
|
`Tunnel for "${target.name}" did not provide a public URL`,
|
|
143
160
|
);
|
|
144
161
|
}
|
|
145
|
-
|
|
162
|
+
tunnels.push({
|
|
146
163
|
kind: target.kind,
|
|
147
164
|
name: target.name,
|
|
148
165
|
localUrl,
|
|
149
166
|
publicUrl,
|
|
150
167
|
close: toCloseFn(tunnel),
|
|
151
|
-
};
|
|
152
|
-
}),
|
|
153
|
-
);
|
|
154
|
-
|
|
155
|
-
const tunnels: PublicTunnel[] = [];
|
|
156
|
-
const errors: unknown[] = [];
|
|
157
|
-
for (const result of settled) {
|
|
158
|
-
if (result.status === "fulfilled") {
|
|
159
|
-
tunnels.push(result.value);
|
|
160
|
-
} else {
|
|
161
|
-
errors.push(result.reason);
|
|
168
|
+
});
|
|
162
169
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (errors.length > 0) {
|
|
170
|
+
return tunnels;
|
|
171
|
+
} catch (e) {
|
|
166
172
|
await stopPublicTunnels(tunnels);
|
|
167
|
-
throw
|
|
173
|
+
throw e;
|
|
168
174
|
}
|
|
169
|
-
|
|
170
|
-
return tunnels;
|
|
171
175
|
}
|
|
172
176
|
|
|
173
177
|
export async function stopPublicTunnels(
|