buncargo 3.2.3 → 3.2.4

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/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-emcawhxm.js";
7
7
  import {
8
8
  clearDevEnvCache,
9
9
  getDevEnv,
10
10
  loadDevEnv
11
- } from "./index-bycj26kj.js";
11
+ } from "./index-1fset27q.js";
12
12
  import {
13
13
  createDevEnvironment
14
- } from "./index-n6z0qw70.js";
14
+ } from "./index-tyk17rfn.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-vr4ygtyj.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-1fset27q.js";
8
+ import"../index-tyk17rfn.js";
9
+ import"../index-twwcjn9p.js";
12
10
  import"../index-5t9jxqm0.js";
13
- import"../index-mf4vjhm3.js";
11
+ import"../index-vr4ygtyj.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.4",
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",
@@ -5,7 +5,22 @@
5
5
  import { type ChildProcess, spawn } from "node:child_process";
6
6
  import { cloudflaredBinPath } 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
+ export function parseQuickTunnelUrlFromOutput(log: string): string | null {
17
+ const pipe = log.match(urlRegexPipe);
18
+ if (pipe?.[1]) {
19
+ return pipe[1];
20
+ }
21
+ const direct = log.match(urlRegexTryCloudflare);
22
+ return direct?.[1] ?? null;
23
+ }
9
24
 
10
25
  export function startCloudflaredTunnel(
11
26
  options: Record<string, string | number | null>,
@@ -55,22 +70,30 @@ export function startCloudflaredTunnel(
55
70
  };
56
71
  });
57
72
 
58
- const parser = (data: Buffer) => {
59
- const str = data.toString();
60
-
61
- const urlMatch = str.match(urlRegex);
62
- if (urlMatch) {
63
- urlResolver(urlMatch[1] ?? "");
73
+ const log: { buf: string } = { buf: "" };
74
+ const append = (data: Buffer) => {
75
+ log.buf += data.toString();
76
+ if (log.buf.length > MAX_CAPTURED_LOG) {
77
+ log.buf = log.buf.slice(-MAX_CAPTURED_LOG);
78
+ }
79
+ const url = parseQuickTunnelUrlFromOutput(log.buf);
80
+ if (url) {
81
+ urlResolver(url);
64
82
  }
65
83
  };
66
- child.stdout?.on("data", parser).on("error", urlRejector);
67
- child.stderr?.on("data", parser).on("error", urlRejector);
84
+ child.stdout?.on("data", append).on("error", urlRejector);
85
+ child.stderr?.on("data", append).on("error", urlRejector);
68
86
 
69
87
  child.on("exit", (code, signal) => {
70
88
  if (!settled) {
89
+ const tail = log.buf.trimEnd();
90
+ const excerpt = tail.length > 1200 ? `…${tail.slice(-1200)}` : tail;
91
+ const detail = excerpt ? `\ncloudflared output (tail):\n${excerpt}` : "";
71
92
  urlRejector(
72
93
  new Error(
73
- `cloudflared exited before a tunnel URL was parsed (code=${code}, signal=${signal ?? "none"})`,
94
+ `cloudflared exited before a tunnel URL was parsed (code=${code}, signal=${signal ?? "none"}). ` +
95
+ `Parallel quick-tunnel requests are often rate-limited; buncargo starts tunnels sequentially with a short pause. ` +
96
+ `If this persists, try fewer expose targets or increase BUNCARGO_EXPOSE_TUNNEL_STAGGER_MS.${detail}`,
74
97
  ),
75
98
  );
76
99
  }
@@ -4,10 +4,48 @@
4
4
  */
5
5
  import { existsSync } from "node:fs";
6
6
  import { createInterface } from "node:readline";
7
+ import { sleep } from "../utils";
7
8
  import { startCloudflaredTunnel } from "./cloudflared-process";
8
9
  import { cloudflaredBinPath, cloudflaredNotice } from "./constants";
9
10
  import { installCloudflared } from "./install";
10
11
 
12
+ const MAX_QUICK_TUNNEL_ATTEMPTS = 5;
13
+
14
+ function isQuickTunnelRateLimitedError(message: string): boolean {
15
+ return (
16
+ message.includes("429") ||
17
+ message.includes("Too Many Requests") ||
18
+ message.includes('status_code="429')
19
+ );
20
+ }
21
+
22
+ async function startCloudflaredTunnelWithRetry(
23
+ cfArgs: Record<string, string | number | null>,
24
+ ): Promise<ReturnType<typeof startCloudflaredTunnel>> {
25
+ for (let attempt = 1; attempt <= MAX_QUICK_TUNNEL_ATTEMPTS; attempt++) {
26
+ const tunnel = startCloudflaredTunnel(cfArgs);
27
+ try {
28
+ await tunnel.url;
29
+ return tunnel;
30
+ } catch (e) {
31
+ const msg = String(e);
32
+ if (
33
+ attempt < MAX_QUICK_TUNNEL_ATTEMPTS &&
34
+ isQuickTunnelRateLimitedError(msg)
35
+ ) {
36
+ const delayMs = 2000 * attempt;
37
+ console.log(
38
+ `Cloudflare quick tunnel rate-limited (${attempt}/${MAX_QUICK_TUNNEL_ATTEMPTS}), retrying in ${delayMs}ms…`,
39
+ );
40
+ await sleep(delayMs);
41
+ continue;
42
+ }
43
+ throw e;
44
+ }
45
+ }
46
+ throw new Error("startCloudflaredTunnelWithRetry: exhausted attempts");
47
+ }
48
+
11
49
  export interface QuickTunnelOptions {
12
50
  url?: string;
13
51
  port?: number | string;
@@ -83,7 +121,7 @@ export async function startQuickTunnel(
83
121
  if (!opts.verifyTLS) {
84
122
  cfArgs["--no-tls-verify"] = null;
85
123
  }
86
- const tunnel = startCloudflaredTunnel(cfArgs);
124
+ const tunnel = await startCloudflaredTunnelWithRetry(cfArgs);
87
125
 
88
126
  const cleanup = async () => {
89
127
  tunnel.stop();
@@ -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,9 +134,16 @@ 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,
@@ -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(