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.
Files changed (47) hide show
  1. package/dist/cli/bin.js +10 -10
  2. package/dist/cli/index.js +4 -2
  3. package/dist/core/network.js +1 -1
  4. package/dist/core/quick-tunnel/cloudflared-process.d.ts +3 -0
  5. package/dist/core/quick-tunnel/constants.d.ts +2 -0
  6. package/dist/core/quick-tunnel/index.d.ts +4 -3
  7. package/dist/core/utils.js +1 -1
  8. package/dist/docker/index.js +2 -2
  9. package/dist/environment/index.js +5 -5
  10. package/dist/index-1fset27q.js +72 -0
  11. package/dist/index-2bcjw5n0.js +666 -0
  12. package/dist/index-39s6ez1q.js +250 -0
  13. package/dist/index-5vg657rh.js +72 -0
  14. package/dist/index-6att53sd.js +250 -0
  15. package/dist/index-94kgbw4m.js +72 -0
  16. package/dist/index-96q4yh56.js +72 -0
  17. package/dist/index-bgcx898h.js +451 -0
  18. package/dist/index-c28x1pjb.js +250 -0
  19. package/dist/index-c2v0t0y2.js +250 -0
  20. package/dist/index-cm05c27w.js +417 -0
  21. package/dist/index-emcawhxm.js +250 -0
  22. package/dist/index-fkgqg6w2.js +125 -0
  23. package/dist/index-gfjdt37q.js +391 -0
  24. package/dist/index-gfs10vb8.js +389 -0
  25. package/dist/index-pbwvaz4v.js +666 -0
  26. package/dist/index-pmbmwg3x.js +72 -0
  27. package/dist/index-pt8t9tkg.js +389 -0
  28. package/dist/index-qnpd5fn5.js +666 -0
  29. package/dist/index-qtprmjbm.js +399 -0
  30. package/dist/index-qz66apm2.js +250 -0
  31. package/dist/index-thsdxz7m.js +250 -0
  32. package/dist/index-twwcjn9p.js +228 -0
  33. package/dist/index-tyk17rfn.js +666 -0
  34. package/dist/index-vj8kaz2d.js +72 -0
  35. package/dist/index-vr4ygtyj.js +415 -0
  36. package/dist/index-wmgx8rsm.js +666 -0
  37. package/dist/index-ymdvr5sn.js +666 -0
  38. package/dist/index-yw46g4tr.js +666 -0
  39. package/dist/index-znaek8z2.js +72 -0
  40. package/dist/index.js +25 -25
  41. package/dist/loader/index.js +6 -6
  42. package/package.json +3 -3
  43. package/src/core/quick-tunnel/cloudflared-process.ts +73 -12
  44. package/src/core/quick-tunnel/constants.ts +14 -1
  45. package/src/core/quick-tunnel/index.ts +82 -40
  46. package/src/core/quick-tunnel/install.ts +1 -1
  47. 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-n5g93an7.js";
6
+ } from "./index-c28x1pjb.js";
7
7
  import {
8
8
  clearDevEnvCache,
9
9
  getDevEnv,
10
10
  loadDevEnv
11
- } from "./index-bycj26kj.js";
11
+ } from "./index-znaek8z2.js";
12
12
  import {
13
13
  createDevEnvironment
14
- } from "./index-n6z0qw70.js";
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-d8tyv5se.js";
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-mf4vjhm3.js";
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,
@@ -4,15 +4,15 @@ import {
4
4
  findConfigFile,
5
5
  getDevEnv,
6
6
  loadDevEnv
7
- } from "../index-bycj26kj.js";
8
- import"../index-n6z0qw70.js";
9
- import"../index-d8tyv5se.js";
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-mf4vjhm3.js";
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",
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 { cloudflaredBinPath } from "./constants";
6
+ import { resolvedCloudflaredBinPath } from "./constants";
7
7
 
8
- const urlRegex = /\|\s+(https?:\/\/\S+)/;
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 child = spawn(cloudflaredBinPath, args, {
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
- const parser = (data: Buffer) => {
59
- const str = data.toString();
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
- const urlMatch = str.match(urlRegex);
62
- if (urlMatch) {
63
- urlResolver(urlMatch[1] ?? "");
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", parser).on("error", urlRejector);
67
- child.stderr?.on("data", parser).on("error", urlRejector);
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 || "2023.10.0";
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 { createInterface } from "node:readline";
6
+ import { sleep } from "../utils";
7
7
  import { startCloudflaredTunnel } from "./cloudflared-process";
8
- import { cloudflaredBinPath, cloudflaredNotice } from "./constants";
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
- * Returns undefined if the user declines the cloudflared install (when binary is missing).
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 | undefined> {
110
+ ): Promise<QuickTunnel> {
64
111
  const url = resolvedLocalUrl(opts);
65
112
 
66
113
  console.log(`Starting cloudflared tunnel to ${url}`);
67
114
 
68
- if (!existsSync(cloudflaredBinPath)) {
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 = startCloudflaredTunnel(cfArgs);
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-amd64.tgz",
23
+ arm64: "cloudflared-darwin-arm64.tgz",
24
24
  x64: "cloudflared-darwin-amd64.tgz",
25
25
  };
26
26
 
@@ -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 settled = await Promise.allSettled(
129
- targets.map(async (target) => {
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 (cloudflared missing or install declined)`,
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
- return {
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 errors[0];
173
+ throw e;
168
174
  }
169
-
170
- return tunnels;
171
175
  }
172
176
 
173
177
  export async function stopPublicTunnels(