unplugin-cloudflare-tunnel 0.0.4 → 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/dist/index.mjs CHANGED
@@ -1,9 +1,9 @@
1
- import { a as number, c as string, i as nullish, l as unknown, n as array, o as object, r as boolean, s as optional, t as any } from "./schemas-CwcXCIyR.mjs";
1
+ import { a as number, c as string, i as nullish, l as unknown, n as array, o as object, r as boolean, s as optional, t as any } from "./schemas-Cpk3vGGi.mjs";
2
2
  import { createUnplugin } from "unplugin";
3
3
  import NodeFS from "node:fs/promises";
4
4
  import { bin, install } from "cloudflared";
5
5
  import * as NodeChildProcess from "node:child_process";
6
-
6
+ import * as NodeUtil from "node:util";
7
7
  //#region src/index.ts
8
8
  /**
9
9
  * @fileoverview Cloudflare Tunnel Unplugin
@@ -42,8 +42,7 @@ const ANSI = {
42
42
  blue: "\x1B[34m",
43
43
  yellow: "\x1B[33m"
44
44
  };
45
- const ANSI_ESCAPE = String.fromCharCode(27);
46
- const ANSI_STYLE_SEQUENCE_REGEX = new RegExp(`${ANSI_ESCAPE}\\[[0-9;]*m`, "g");
45
+ const ANSI_STYLE_SEQUENCE_REGEX = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
47
46
  function stripAnsi(text) {
48
47
  return text.replace(ANSI_STYLE_SEQUENCE_REGEX, "");
49
48
  }
@@ -67,7 +66,8 @@ const AccountSchema = object({
67
66
  });
68
67
  const ZoneSchema = object({
69
68
  id: string(),
70
- name: string()
69
+ name: string(),
70
+ account: optional(object({ id: string() }))
71
71
  });
72
72
  const TunnelSchema = object({
73
73
  id: string(),
@@ -107,7 +107,11 @@ const unpluginFactory = (options = {}) => {
107
107
  globalThis[GLOBAL_STATE] = globalState;
108
108
  let child = globalState.child;
109
109
  const VIRTUAL_MODULE_ID = "virtual:unplugin-cloudflare-tunnel";
110
- const isQuickMode = !("hostname" in options);
110
+ const requestedMode = options.mode;
111
+ if (requestedMode && !["quick", "named"].includes(requestedMode)) throw new Error("[unplugin-cloudflare-tunnel] mode must be one of: 'quick', 'named'");
112
+ const hasHostname = "hostname" in options && typeof options.hostname === "string";
113
+ const isQuickMode = requestedMode ? requestedMode === "quick" : !hasHostname;
114
+ if (requestedMode === "named" && !hasHostname) throw new Error("[unplugin-cloudflare-tunnel] hostname is required when mode is set to named");
111
115
  if (isQuickMode) {
112
116
  const invalidOptions = [
113
117
  "apiToken",
@@ -118,7 +122,7 @@ const unpluginFactory = (options = {}) => {
118
122
  "ssl",
119
123
  "cleanup"
120
124
  ].filter((opt) => opt in options);
121
- if (invalidOptions.length > 0) throw new Error(`[unplugin-cloudflare-tunnel] The following options are only supported in named tunnel mode (when hostname is provided): ${invalidOptions.join(", ")}. Either provide a hostname for named tunnel mode, or remove these options for quick tunnel mode.`);
125
+ if (invalidOptions.length > 0) throw new Error(`[unplugin-cloudflare-tunnel] The following options are only supported in named tunnel mode: ${invalidOptions.join(", ")}. Set mode to 'named' and provide a hostname, or remove these options for quick tunnel mode.`);
122
126
  }
123
127
  let providedApiToken;
124
128
  let hostname;
@@ -142,11 +146,37 @@ const unpluginFactory = (options = {}) => {
142
146
  sslOption = namedOptions.ssl;
143
147
  cleanupConfig = namedOptions.cleanup || {};
144
148
  }
145
- const { port: userProvidedPort, logFile, logLevel, debug = false } = options;
149
+ const { port: userProvidedPort, logFile, logLevel, protocol = "http2", debug = false } = options;
146
150
  const effectivePluginLogLevel = logLevel ?? (debug ? "debug" : "info");
151
+ const redactForDebug = (value) => {
152
+ if (typeof value === "string") {
153
+ if (value.startsWith("eyJ") && value.length > 40) return "[REDACTED_TOKEN]";
154
+ return value;
155
+ }
156
+ if (Array.isArray(value)) return value.map((item) => redactForDebug(item));
157
+ if (value && typeof value === "object") {
158
+ const entries = Object.entries(value).map(([key, nestedValue]) => {
159
+ if (/token|authorization|secret|password/i.test(key)) return [key, "[REDACTED]"];
160
+ return [key, redactForDebug(nestedValue)];
161
+ });
162
+ return Object.fromEntries(entries);
163
+ }
164
+ return value;
165
+ };
166
+ const formatDebugValue = (value) => {
167
+ const redactedValue = redactForDebug(value);
168
+ if (typeof redactedValue === "string") return redactedValue;
169
+ return NodeUtil.inspect(redactedValue, {
170
+ depth: null,
171
+ colors: supportsColor(),
172
+ compact: false,
173
+ breakLength: 120,
174
+ sorted: true
175
+ });
176
+ };
147
177
  const pluginLog = {
148
178
  debug: (...args) => {
149
- if (debug || effectivePluginLogLevel === "debug") console.log("[cloudflare-tunnel:debug]", ...args);
179
+ if (debug || effectivePluginLogLevel === "debug") console.log("[cloudflare-tunnel:debug]", ...args.map((arg) => formatDebugValue(arg)));
150
180
  },
151
181
  info: (message) => {
152
182
  if (shouldLog(effectivePluginLogLevel, "info")) console.log(`[unplugin-cloudflare-tunnel] ${message}`);
@@ -176,10 +206,7 @@ const unpluginFactory = (options = {}) => {
176
206
  globalState.__lastAnnouncedTunnelKey = params.key;
177
207
  const cols = process.stdout.columns ?? 80;
178
208
  const maxWidth = Math.max(10, cols - 2);
179
- const headerText = "unplugin-cloudflare-tunnel";
180
- const header = (() => {
181
- return `${colorize("[", ANSI.yellow)}${headerText}${colorize("]", ANSI.yellow)}`;
182
- })();
209
+ const header = `${colorize("[", ANSI.yellow)}unplugin-cloudflare-tunnel${colorize("]", ANSI.yellow)}`;
183
210
  const urlLine = colorize(params.url, ANSI.blue + ANSI.bold);
184
211
  const localLine = params.localTarget ? makeLocalDisplay(params.localTarget) : "";
185
212
  const headerPlainLen = stripAnsi(header).length;
@@ -192,12 +219,12 @@ const unpluginFactory = (options = {}) => {
192
219
  return `${" ".repeat(pad)}${text}`;
193
220
  };
194
221
  if (cols < 70) {
195
- const out$1 = [];
196
- out$1.push("");
197
- out$1.push(`${header} ${colorize("Tunnel URL", ANSI.bold)} ${urlLine}`);
198
- if (localLine) out$1.push(`${header} ${colorize("Local", ANSI.dim + ANSI.bold)} ${localLine}`);
199
- out$1.push("");
200
- console.log(out$1.join("\n"));
222
+ const out = [];
223
+ out.push("");
224
+ out.push(`${header} ${colorize("Tunnel URL", ANSI.bold)} ${urlLine}`);
225
+ if (localLine) out.push(`${header} ${colorize("Local", ANSI.dim + ANSI.bold)} ${localLine}`);
226
+ out.push("");
227
+ console.log(out.join("\n"));
201
228
  return;
202
229
  }
203
230
  const out = [];
@@ -206,8 +233,6 @@ const unpluginFactory = (options = {}) => {
206
233
  out.push(rule);
207
234
  out.push(center(colorize("Tunnel URL", ANSI.bold)));
208
235
  out.push(center(urlLine));
209
- out.push(center(urlLine));
210
- out.push(center(urlLine));
211
236
  if (localLine) {
212
237
  out.push("");
213
238
  out.push(center(colorize("Local", ANSI.dim + ANSI.bold)));
@@ -229,19 +254,23 @@ const unpluginFactory = (options = {}) => {
229
254
  "fatal"
230
255
  ].includes(logLevel)) throw new Error("[unplugin-cloudflare-tunnel] logLevel must be one of: debug, info, warn, error, fatal");
231
256
  const effectiveLogLevel = logLevel ?? (debug ? "info" : "warn");
232
- debugLog("Effective cloudflared log level:", effectiveLogLevel);
257
+ const cloudflaredProcessLogLevel = effectiveLogLevel === "debug" ? "debug" : "info";
258
+ debugLog("Effective cloudflared log level filter:", effectiveLogLevel);
259
+ debugLog("Effective cloudflared process log level:", cloudflaredProcessLogLevel);
260
+ debugLog("Effective cloudflared protocol:", protocol);
233
261
  if (dnsOption) {
234
262
  if (!dnsOption.startsWith("*.") && dnsOption !== hostname) throw new Error("[unplugin-cloudflare-tunnel] dns option must either be a wildcard (e.g., '*.example.com') or exactly match the hostname");
235
263
  }
236
264
  if (sslOption) {
237
265
  if (!sslOption.startsWith("*.") && sslOption !== hostname) throw new Error("[unplugin-cloudflare-tunnel] ssl option must either be a wildcard (e.g., '*.example.com') or exactly match the hostname");
238
266
  }
239
- const trackSslCertificate = (certificateId, hosts, tunnelName$1, timestamp = (/* @__PURE__ */ new Date()).toISOString()) => {
267
+ if (!["http2", "quic"].includes(protocol)) throw new Error("[unplugin-cloudflare-tunnel] protocol must be one of: 'http2', 'quic'");
268
+ const trackSslCertificate = (certificateId, hosts, tunnelName, timestamp = (/* @__PURE__ */ new Date()).toISOString()) => {
240
269
  const trackingKey = `ssl-cert-${certificateId}`;
241
270
  globalState[trackingKey] = {
242
271
  id: certificateId,
243
272
  hosts,
244
- tunnelName: tunnelName$1,
273
+ tunnelName,
245
274
  timestamp,
246
275
  pluginVersion: "1.0.0"
247
276
  };
@@ -365,13 +394,14 @@ const unpluginFactory = (options = {}) => {
365
394
  await new Promise((resolve) => setTimeout(resolve, delay));
366
395
  }
367
396
  };
368
- const spawnQuickTunnel = async (localTarget) => {
397
+ const spawnQuickTunnel = async (localTarget, protocol) => {
369
398
  const cloudflaredArgs = ["tunnel"];
370
399
  cloudflaredArgs.push("--loglevel", "info");
371
400
  if (logFile) cloudflaredArgs.push("--logfile", logFile);
401
+ cloudflaredArgs.push("--protocol", protocol);
372
402
  cloudflaredArgs.push("--url", localTarget);
373
403
  debugLog("Spawning quick tunnel:", bin, cloudflaredArgs);
374
- const child$1 = NodeChildProcess.spawn(bin, cloudflaredArgs, {
404
+ const child = NodeChildProcess.spawn(bin, cloudflaredArgs, {
375
405
  stdio: [
376
406
  "ignore",
377
407
  "pipe",
@@ -381,27 +411,43 @@ const unpluginFactory = (options = {}) => {
381
411
  windowsHide: true,
382
412
  shell: process.platform === "win32"
383
413
  });
384
- debugLog(`[unplugin-cloudflare-tunnel] Quick tunnel process spawned with PID: ${child$1.pid}`);
414
+ debugLog(`[unplugin-cloudflare-tunnel] Quick tunnel process spawned with PID: ${child.pid}`);
385
415
  return new Promise((resolve, reject) => {
386
416
  let urlFound = false;
417
+ let settled = false;
418
+ const rejectOnce = (error) => {
419
+ if (settled) return;
420
+ settled = true;
421
+ reject(error);
422
+ };
423
+ const resolveOnce = (result) => {
424
+ if (settled) return;
425
+ settled = true;
426
+ resolve(result);
427
+ };
387
428
  const timeout = setTimeout(() => {
388
- if (!urlFound) reject(/* @__PURE__ */ new Error("Quick tunnel URL not found in output within 30 seconds"));
429
+ if (!urlFound) {
430
+ try {
431
+ child.kill("SIGTERM");
432
+ } catch {}
433
+ rejectOnce(/* @__PURE__ */ new Error("Quick tunnel URL not found in output within 30 seconds"));
434
+ }
389
435
  }, 3e4);
390
- child$1.stdout?.on("data", (data) => {
436
+ child.stdout?.on("data", (data) => {
391
437
  const output = data.toString();
392
438
  if (!globalState.shuttingDown || debug) {
393
439
  if (effectiveLogLevel === "debug" || effectiveLogLevel === "info") console.log(`[cloudflared stdout] ${output.trim()}`);
394
440
  else for (const line of output.split("\n")) if (!INFO_LOG_REGEX.test(line)) console.log(`[cloudflared stdout] ${line.trim()}`);
395
441
  }
396
442
  });
397
- child$1.stderr?.on("data", (data) => {
443
+ child.stderr?.on("data", (data) => {
398
444
  const error = data.toString().trim();
399
445
  const urlMatch = error.match(/https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/);
400
446
  if (urlMatch && !urlFound) {
401
447
  urlFound = true;
402
448
  clearTimeout(timeout);
403
- resolve({
404
- child: child$1,
449
+ resolveOnce({
450
+ child,
405
451
  url: urlMatch[0]
406
452
  });
407
453
  }
@@ -414,13 +460,13 @@ const unpluginFactory = (options = {}) => {
414
460
  else for (const line of error.split("\n")) if (!INFO_LOG_REGEX.test(line)) console.error(`[cloudflared stderr] ${line.trim()}`);
415
461
  }
416
462
  });
417
- child$1.on("error", (error) => {
463
+ child.on("error", (error) => {
418
464
  clearTimeout(timeout);
419
- reject(/* @__PURE__ */ new Error(`Failed to start quick tunnel process: ${error.message}`));
465
+ rejectOnce(/* @__PURE__ */ new Error(`Failed to start quick tunnel process: ${error.message}`));
420
466
  });
421
- child$1.on("exit", (code, signal) => {
467
+ child.on("exit", (code, signal) => {
422
468
  clearTimeout(timeout);
423
- if (!urlFound) reject(/* @__PURE__ */ new Error(`Quick tunnel process exited before URL was found (code: ${code}, signal: ${signal})`));
469
+ if (!urlFound) rejectOnce(/* @__PURE__ */ new Error(`Quick tunnel process exited before URL was found (code: ${code}, signal: ${signal})`));
424
470
  });
425
471
  });
426
472
  };
@@ -527,14 +573,15 @@ const unpluginFactory = (options = {}) => {
527
573
  debugLog("[unplugin-cloudflare-tunnel] Starting quick tunnel mode...");
528
574
  debugLog("Quick tunnel mode - no API token or hostname required");
529
575
  await ensureCloudflaredBinary(bin);
530
- const localTarget$1 = getLocalTarget(serverHost, port);
531
- debugLog("← Quick tunnel connecting to local target", localTarget$1);
576
+ const localTarget = getLocalTarget(serverHost, port);
577
+ debugLog("← Quick tunnel connecting to local target", localTarget);
532
578
  try {
533
- const { child: quickChild, url } = await spawnQuickTunnel(localTarget$1);
579
+ const { child: quickChild, url } = await spawnQuickTunnel(localTarget, protocol);
534
580
  tunnelUrl = url;
535
581
  child = quickChild;
536
582
  globalState.child = child;
537
583
  globalState.configHash = newConfigHash;
584
+ globalState.tunnelUrl = Promise.resolve(url);
538
585
  registerExitHandler();
539
586
  registerListeningHandler(() => {
540
587
  const { host: actualServerHost, port: actualPort } = normalizeAddress(server.httpServer?.address());
@@ -554,10 +601,11 @@ const unpluginFactory = (options = {}) => {
554
601
  killCloudflared("SIGTERM");
555
602
  await new Promise((resolve) => setTimeout(resolve, 1e3));
556
603
  const newLocalTarget = getLocalTarget(actualServerHost, actualPort ?? port);
557
- const { child: newChild, url: newUrl } = await spawnQuickTunnel(newLocalTarget);
604
+ const { child: newChild, url: newUrl } = await spawnQuickTunnel(newLocalTarget, protocol);
558
605
  tunnelUrl = newUrl;
559
606
  child = newChild;
560
607
  globalState.child = child;
608
+ globalState.tunnelUrl = Promise.resolve(newUrl);
561
609
  announceTunnel({
562
610
  key: `quick:${newUrl}:${actualPort ?? port}`,
563
611
  url: newUrl,
@@ -586,17 +634,15 @@ const unpluginFactory = (options = {}) => {
586
634
  }
587
635
  }
588
636
  debugLog("[unplugin-cloudflare-tunnel] Starting named tunnel mode...");
589
- const apiToken = providedApiToken || process.env.CLOUDFLARE_API_KEY;
590
- if (!apiToken) throw new Error("[unplugin-cloudflare-tunnel] API token is required. Provide it via 'apiToken' option or set the CLOUDFLARE_API_KEY environment variable. Get your token at: https://dash.cloudflare.com/profile/api-tokens");
637
+ const apiToken = providedApiToken || process.env.CLOUDFLARE_API_TOKEN;
638
+ if (!apiToken) throw new Error("[unplugin-cloudflare-tunnel] API token is required. Provide it via 'apiToken' option or set the CLOUDFLARE_API_TOKEN environment variable. Get your token at: https://dash.cloudflare.com/profile/api-tokens");
591
639
  debugLog(`[unplugin-cloudflare-tunnel] Using port ${port}${userProvidedPort === port ? " (user-provided)" : " (from bundler config)"}`);
592
640
  await ensureCloudflaredBinary(bin);
593
- const accounts = await cf(apiToken, "GET", "/accounts", void 0, array(AccountSchema));
594
- const accountId = forcedAccount || accounts[0]?.id;
595
- if (!accountId) throw new Error("Unable to determine Cloudflare account ID");
596
641
  const apexDomain = hostname.split(".").slice(-2).join(".");
597
642
  const parentDomain = hostname.split(".").slice(1).join(".");
598
643
  debugLog("← Apex domain", apexDomain);
599
644
  debugLog("← Parent domain", parentDomain);
645
+ let resolvedZone;
600
646
  let zoneId = forcedZone;
601
647
  if (!zoneId) {
602
648
  let zones = [];
@@ -606,8 +652,12 @@ const unpluginFactory = (options = {}) => {
606
652
  debugLog("← Error fetching zone for parent domain", error);
607
653
  }
608
654
  if (zones.length === 0) zones = await cf(apiToken, "GET", `/zones?name=${apexDomain}`, void 0, array(ZoneSchema));
609
- zoneId = zones[0]?.id;
655
+ resolvedZone = zones[0];
656
+ zoneId = resolvedZone?.id;
610
657
  }
658
+ let accountId = forcedAccount || resolvedZone?.account?.id;
659
+ if (!accountId) accountId = (await cf(apiToken, "GET", "/accounts", void 0, array(AccountSchema)))[0]?.id;
660
+ if (!accountId) throw new Error("Unable to determine Cloudflare account ID");
611
661
  if (!zoneId) throw new Error(`Zone ${apexDomain} not found in account ${accountId}`);
612
662
  const { autoCleanup = true } = cleanupConfig;
613
663
  let tunnel = (await cf(apiToken, "GET", `/accounts/${accountId}/cfd_tunnel?name=${tunnelName}`, void 0, array(TunnelSchema)))[0];
@@ -731,26 +781,17 @@ const unpluginFactory = (options = {}) => {
731
781
  console.error(`[unplugin-cloudflare-tunnel] ⚠️ SSL management error: ${sslError.message}`);
732
782
  throw sslError;
733
783
  }
734
- const cloudflaredArgs = ["tunnel"];
735
- cloudflaredArgs.push("--loglevel", effectiveLogLevel);
736
- if (logFile) cloudflaredArgs.push("--logfile", logFile);
737
- debugLog("Spawning cloudflared", bin, cloudflaredArgs);
738
- cloudflaredArgs.push("run", "--token", token);
739
- child = NodeChildProcess.spawn(bin, cloudflaredArgs, {
740
- stdio: [
741
- "ignore",
742
- "pipe",
743
- "pipe"
744
- ],
745
- detached: false,
746
- windowsHide: true,
747
- shell: process.platform === "win32"
748
- });
749
- debugLog(`[unplugin-cloudflare-tunnel] Process spawned with PID: ${child.pid}`);
750
- globalState.child = child;
751
- globalState.configHash = newConfigHash;
752
- registerExitHandler();
753
784
  let tunnelReady = false;
785
+ let localTargetForAnnouncement = localTarget;
786
+ let activeTunnelProtocol;
787
+ const announceNamedTunnelIfReady = () => {
788
+ if (!tunnelReady) return;
789
+ announceTunnel({
790
+ key: `named:${hostname}:${localTargetForAnnouncement}`,
791
+ url: `https://${hostname}`,
792
+ localTarget: localTargetForAnnouncement
793
+ });
794
+ };
754
795
  const logCloudflaredLines = (kind, text) => {
755
796
  if (globalState.shuttingDown && !debug) return;
756
797
  const isVerbose = effectiveLogLevel === "debug" || effectiveLogLevel === "info";
@@ -770,42 +811,70 @@ const unpluginFactory = (options = {}) => {
770
811
  else console.error(`${prefix} ${line}`);
771
812
  }
772
813
  };
773
- child.stdout?.on("data", (data) => {
774
- const output = data.toString();
775
- logCloudflaredLines("stdout", output);
776
- if (output.includes("Connection") && output.includes("registered")) {
777
- if (!tunnelReady) {
778
- tunnelReady = true;
779
- pluginLog.info(`Tunnel connected for https://${hostname}`);
814
+ const spawnNamedTunnelProcess = (protocol) => {
815
+ const cloudflaredArgs = ["tunnel"];
816
+ cloudflaredArgs.push("--loglevel", cloudflaredProcessLogLevel);
817
+ if (logFile) cloudflaredArgs.push("--logfile", logFile);
818
+ cloudflaredArgs.push("--protocol", protocol);
819
+ debugLog("Spawning cloudflared", bin, cloudflaredArgs);
820
+ const spawnedChild = NodeChildProcess.spawn(bin, [
821
+ ...cloudflaredArgs,
822
+ "run",
823
+ "--token",
824
+ token
825
+ ], {
826
+ stdio: [
827
+ "ignore",
828
+ "pipe",
829
+ "pipe"
830
+ ],
831
+ detached: false,
832
+ windowsHide: true,
833
+ shell: process.platform === "win32"
834
+ });
835
+ child = spawnedChild;
836
+ globalState.child = spawnedChild;
837
+ globalState.configHash = newConfigHash;
838
+ debugLog(`[unplugin-cloudflare-tunnel] Process spawned with PID: ${spawnedChild.pid}`);
839
+ const handleCloudflaredOutput = (kind, text) => {
840
+ if (text.includes("Failed to parse ICMP reply") || text.includes("unknow ip version 0")) {
841
+ if (logLevel === "debug") console.log(`[cloudflared debug] ${text.trim()}`);
842
+ return;
780
843
  }
781
- }
782
- });
783
- child.stderr?.on("data", (data) => {
784
- const error = data.toString().trim();
785
- if (error.includes("Failed to parse ICMP reply") || error.includes("unknow ip version 0")) {
786
- if (logLevel === "debug") console.log(`[cloudflared debug] ${error}`);
787
- return;
788
- }
789
- logCloudflaredLines("stderr", error);
790
- });
791
- child.on("error", (error) => {
792
- console.error(`[unplugin-cloudflare-tunnel] ❌ Failed to start tunnel process: ${error.message}`);
793
- if (error.message.includes("ENOENT")) console.error(`[unplugin-cloudflare-tunnel] Hint: cloudflared binary may not be installed correctly`);
794
- });
795
- child.on("exit", (code, signal) => {
796
- if (code !== 0 && code !== null) {
797
- console.error(`[unplugin-cloudflare-tunnel] Tunnel process exited with code ${code}`);
798
- if (signal) console.error(`[unplugin-cloudflare-tunnel] Process terminated by signal: ${signal}`);
799
- } else if (code === 0) console.log(`[unplugin-cloudflare-tunnel] Tunnel process exited cleanly`);
800
- });
844
+ logCloudflaredLines(kind, text);
845
+ if (/registered tunnel connection|connection.*registered/i.test(text)) {
846
+ activeTunnelProtocol = protocol;
847
+ if (!tunnelReady) {
848
+ tunnelReady = true;
849
+ pluginLog.info(`Tunnel connected for https://${hostname} via ${protocol.toUpperCase()}`);
850
+ }
851
+ announceNamedTunnelIfReady();
852
+ }
853
+ };
854
+ spawnedChild.stdout?.on("data", (data) => {
855
+ handleCloudflaredOutput("stdout", data.toString());
856
+ });
857
+ spawnedChild.stderr?.on("data", (data) => {
858
+ handleCloudflaredOutput("stderr", data.toString());
859
+ });
860
+ spawnedChild.on("error", (error) => {
861
+ console.error(`[unplugin-cloudflare-tunnel] Failed to start tunnel process: ${error.message}`);
862
+ if (error.message.includes("ENOENT")) console.error(`[unplugin-cloudflare-tunnel] Hint: cloudflared binary may not be installed correctly`);
863
+ });
864
+ spawnedChild.on("exit", (code, signal) => {
865
+ if (globalState.child !== spawnedChild) return;
866
+ if (code !== 0 && code !== null) {
867
+ console.error(`[unplugin-cloudflare-tunnel] ❌ Tunnel process exited with code ${code}`);
868
+ if (signal) console.error(`[unplugin-cloudflare-tunnel] Process terminated by signal: ${signal}`);
869
+ } else if (code === 0) console.log(`[unplugin-cloudflare-tunnel] ✅ Tunnel process exited cleanly`);
870
+ });
871
+ };
872
+ spawnNamedTunnelProcess(protocol);
873
+ registerExitHandler();
801
874
  registerListeningHandler(() => {
802
875
  const { host: actualServerHost, port: actualPort } = normalizeAddress(server.httpServer?.address());
803
- const actualLocalTarget = getLocalTarget(actualServerHost, actualPort ?? port);
804
- announceTunnel({
805
- key: `named:${hostname}:${actualPort ?? port}`,
806
- url: `https://${hostname}`,
807
- localTarget: actualLocalTarget
808
- });
876
+ localTargetForAnnouncement = getLocalTarget(actualServerHost, actualPort ?? port);
877
+ announceNamedTunnelIfReady();
809
878
  });
810
879
  server.httpServer?.once("close", () => {
811
880
  killCloudflared("SIGTERM");
@@ -817,6 +886,7 @@ const unpluginFactory = (options = {}) => {
817
886
  pluginLog.warn(`Port conflict detected - server is using port ${actualPort} instead of ${port}`);
818
887
  pluginLog.info("Updating tunnel configuration...");
819
888
  const newLocalTarget = getLocalTarget(actualServerHost, actualPort ?? port);
889
+ localTargetForAnnouncement = newLocalTarget;
820
890
  debugLog("← Updating local target to", newLocalTarget);
821
891
  await cf(apiToken, "PUT", `/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, { config: { ingress: [{
822
892
  hostname,
@@ -830,6 +900,8 @@ const unpluginFactory = (options = {}) => {
830
900
  dnsOption,
831
901
  sslOption
832
902
  });
903
+ if (tunnelReady && activeTunnelProtocol) pluginLog.info(`Tunnel remains connected via ${activeTunnelProtocol.toUpperCase()} after port update`);
904
+ announceNamedTunnelIfReady();
833
905
  }
834
906
  } catch (error) {
835
907
  console.error(`[unplugin-cloudflare-tunnel] ❌ Failed to update tunnel for port change: ${error.message}`);
@@ -876,6 +948,18 @@ const unpluginFactory = (options = {}) => {
876
948
  }
877
949
  if (modified) debugLog(`[unplugin-cloudflare-tunnel] Configured ${label} devServer.allowedHosts to include ${hostToAllow}`);
878
950
  };
951
+ const ensureViteAllowedHosts = (serverConfig) => {
952
+ const hostToAllow = isQuickMode ? ".trycloudflare.com" : hostname;
953
+ if (!hostToAllow) return;
954
+ const current = serverConfig.allowedHosts;
955
+ if (current === true) return;
956
+ if (typeof current === "undefined") serverConfig.allowedHosts = [hostToAllow];
957
+ else if (typeof current === "string") {
958
+ if (current !== hostToAllow) serverConfig.allowedHosts = [current, hostToAllow];
959
+ } else if (Array.isArray(current)) {
960
+ if (!current.includes(hostToAllow)) current.push(hostToAllow);
961
+ }
962
+ };
879
963
  const setupWebpackLikeDevServerIntegration = (compiler, bundler) => {
880
964
  if ((compiler?.options?.mode ?? process.env.NODE_ENV) === "production") return;
881
965
  const optionsContainer = compiler.options;
@@ -957,19 +1041,8 @@ const unpluginFactory = (options = {}) => {
957
1041
  config: (config) => {
958
1042
  announceConnecting();
959
1043
  if (!config.server) config.server = {};
960
- if (isQuickMode) {
961
- config.server.allowedHosts = [".trycloudflare.com"];
962
- return;
963
- }
964
- if (!config.server.allowedHosts) {
965
- config.server.allowedHosts = [hostname];
966
- debugLog(`[unplugin-cloudflare-tunnel] Configured Vite to allow requests from ${hostname}`);
967
- } else if (Array.isArray(config.server.allowedHosts)) {
968
- if (!config.server.allowedHosts.includes(hostname)) {
969
- config.server.allowedHosts.push(hostname);
970
- debugLog(`[unplugin-cloudflare-tunnel] Added ${hostname} to allowed hosts`);
971
- }
972
- }
1044
+ ensureViteAllowedHosts(config.server);
1045
+ if (!isQuickMode) debugLog(`[unplugin-cloudflare-tunnel] Configured Vite to allow requests from ${hostname}`);
973
1046
  },
974
1047
  configureServer: (server) => {
975
1048
  const configuredPromise = configureServer(server);
@@ -1012,7 +1085,5 @@ function getLocalTarget(host, port) {
1012
1085
  return `http://${host.includes(":") ? `[${host}]` : host}:${port}`;
1013
1086
  }
1014
1087
  const CloudflareTunnel = createUnplugin(unpluginFactory);
1015
- var src_default = CloudflareTunnel;
1016
-
1017
1088
  //#endregion
1018
- export { CloudflareTunnel, src_default as default };
1089
+ export { CloudflareTunnel, CloudflareTunnel as default };