unplugin-cloudflare-tunnel 0.0.5 → 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/.github/README.md CHANGED
@@ -29,16 +29,80 @@ npm add unplugin-cloudflare-tunnel
29
29
 
30
30
  ## Usage
31
31
 
32
+ ### Modes
33
+
34
+ The plugin supports two modes:
35
+
36
+ - **Quick mode**: temporary `trycloudflare.com` URL, no Cloudflare credentials required
37
+ - **Named mode**: persistent tunnel on your own hostname
38
+
39
+ Mode selection rules:
40
+
41
+ - `mode: 'quick'` → always quick mode
42
+ - `mode: 'named'` → always named mode, requires `hostname`
43
+ - if `mode` is omitted:
44
+ - `hostname` provided → named mode
45
+ - otherwise → quick mode
46
+
47
+ ### Common options
48
+
49
+ - `mode?: 'quick' | 'named'`
50
+ - `protocol?: 'http2' | 'quic'` — defaults to `http2` for better local dev reliability
51
+ - `logLevel?: 'debug' | 'info' | 'warn' | 'error' | 'fatal'`
52
+ - `port?: number`
53
+ - `logFile?: string`
54
+ - `debug?: boolean`
55
+ - `enabled?: boolean`
56
+
57
+ ### Quick mode example
58
+
59
+ ```ts
60
+ // vite.config.ts
61
+ import { defineConfig } from 'vite'
62
+ import CloudflareTunnel from 'unplugin-cloudflare-tunnel/vite'
63
+
64
+ export default defineConfig({
65
+ plugins: [
66
+ CloudflareTunnel({
67
+ mode: 'quick',
68
+ protocol: 'http2',
69
+ }),
70
+ ],
71
+ })
72
+ ```
73
+
74
+ ### Named mode example
75
+
76
+ ```ts
77
+ // vite.config.ts
78
+ import { defineConfig } from 'vite'
79
+ import CloudflareTunnel from 'unplugin-cloudflare-tunnel/vite'
80
+
81
+ export default defineConfig({
82
+ plugins: [
83
+ CloudflareTunnel({
84
+ mode: 'named',
85
+ hostname: 'dev.example.com',
86
+ apiToken: process.env.CLOUDFLARE_API_TOKEN,
87
+ protocol: 'http2',
88
+ }),
89
+ ],
90
+ })
91
+ ```
92
+
32
93
  <details>
33
94
  <summary>Vite</summary><br>
34
95
 
35
96
  ```ts
36
97
  // vite.config.ts
98
+ import { defineConfig } from 'vite'
37
99
  import CloudflareTunnel from 'unplugin-cloudflare-tunnel/vite'
38
100
 
39
101
  export default defineConfig({
40
102
  plugins: [
41
- CloudflareTunnel(),
103
+ CloudflareTunnel({
104
+ mode: 'quick',
105
+ }),
42
106
  ],
43
107
  })
44
108
  ```
@@ -134,6 +198,12 @@ Or create a `virtual.d.ts` file in your project:
134
198
  - **Named tunnel mode**: Returns your custom domain URL like `https://dev.example.com`
135
199
  - **Plugin disabled**: Returns an empty string `""`
136
200
 
201
+ ### Notes on modes
202
+
203
+ - Named-only options such as `hostname`, `apiToken`, `accountId`, `zoneId`, `tunnelName`, `dns`, `ssl`, and `cleanup` are only valid in named mode.
204
+ - Quick mode ignores Cloudflare account setup entirely and creates an ephemeral tunnel.
205
+ - `protocol` applies to both quick and named modes.
206
+
137
207
  ### Notes
138
208
 
139
209
  - The virtual module is only available during development mode
package/dist/api.d.mts CHANGED
@@ -21,6 +21,9 @@ declare const AccountSchema: ZodMiniObject<{
21
21
  declare const ZoneSchema: ZodMiniObject<{
22
22
  id: ZodMiniString<string>;
23
23
  name: ZodMiniString<string>;
24
+ account: ZodMiniOptional<ZodMiniObject<{
25
+ id: ZodMiniString<string>;
26
+ }, $strip>>;
24
27
  }, $strip>;
25
28
  declare const TunnelSchema: ZodMiniObject<{
26
29
  id: ZodMiniString<string>;
package/dist/api.mjs CHANGED
@@ -18,7 +18,8 @@ const AccountSchema = object({
18
18
  });
19
19
  const ZoneSchema = object({
20
20
  id: string(),
21
- name: string()
21
+ name: string(),
22
+ account: optional(object({ id: string() }))
22
23
  });
23
24
  const TunnelSchema = object({
24
25
  id: string(),
package/dist/index.d.mts CHANGED
@@ -29,6 +29,9 @@ declare const AccountSchema: ZodMiniObject<{
29
29
  declare const ZoneSchema: ZodMiniObject<{
30
30
  id: ZodMiniString<string>;
31
31
  name: ZodMiniString<string>;
32
+ account: ZodMiniOptional<ZodMiniObject<{
33
+ id: ZodMiniString<string>;
34
+ }, $strip>>;
32
35
  }, $strip>;
33
36
  declare const TunnelSchema: ZodMiniObject<{
34
37
  id: ZodMiniString<string>;
@@ -56,6 +59,15 @@ type DNSRecord = output<typeof DNSRecordSchema>;
56
59
  * Base configuration options shared between named and quick tunnel modes
57
60
  */
58
61
  interface BaseTunnelOptions {
62
+ /**
63
+ * Tunnel mode.
64
+ * - `quick`: temporary `trycloudflare.com` URL, no hostname required
65
+ * - `named`: persistent tunnel using your configured hostname
66
+ *
67
+ * When omitted, the plugin uses named mode if `hostname` is provided,
68
+ * otherwise it uses quick mode.
69
+ */
70
+ mode?: 'quick' | 'named';
59
71
  /**
60
72
  * Local port your dev server listens on
61
73
  * If not specified, will automatically use the bundler's configured port
@@ -68,10 +80,19 @@ interface BaseTunnelOptions {
68
80
  */
69
81
  logFile?: string;
70
82
  /**
71
- * Log level for cloudflared process
72
- * @default undefined (uses cloudflared default)
83
+ * Log level for cloudflared output shown by the plugin.
84
+ * The plugin still runs cloudflared with at least `info` internally so it can
85
+ * detect tunnel readiness and print the tunnel URL.
86
+ * @default undefined
73
87
  */
74
88
  logLevel?: 'debug' | 'info' | 'warn' | 'error' | 'fatal';
89
+ /**
90
+ * Transport protocol used by cloudflared.
91
+ * `http2` is the default because it is more reliable for local development
92
+ * networks than QUIC.
93
+ * @default 'http2'
94
+ */
95
+ protocol?: 'http2' | 'quic';
75
96
  /**
76
97
  * Enable additional verbose logging for easier debugging.
77
98
  * When true, the plugin will output extra information prefixed with
@@ -158,8 +179,8 @@ interface QuickTunnelOptions extends BaseTunnelOptions {}
158
179
  * Configuration options for the Cloudflare Tunnel plugin
159
180
  *
160
181
  * Two modes are supported:
161
- * - Named tunnel mode: Provide `hostname` for a persistent tunnel with custom domain
162
- * - Quick tunnel mode: Omit `hostname` for a temporary tunnel with random trycloudflare.com URL
182
+ * - Named tunnel mode: set `mode: 'named'` or provide `hostname`
183
+ * - Quick tunnel mode: set `mode: 'quick'` or omit `hostname`
163
184
  */
164
185
  type CloudflareTunnelOptions = NamedTunnelOptions | QuickTunnelOptions;
165
186
  declare const CloudflareTunnel: UnpluginInstance<CloudflareTunnelOptions | undefined, false>;
package/dist/index.mjs CHANGED
@@ -3,6 +3,7 @@ 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
+ import * as NodeUtil from "node:util";
6
7
  //#region src/index.ts
7
8
  /**
8
9
  * @fileoverview Cloudflare Tunnel Unplugin
@@ -65,7 +66,8 @@ const AccountSchema = object({
65
66
  });
66
67
  const ZoneSchema = object({
67
68
  id: string(),
68
- name: string()
69
+ name: string(),
70
+ account: optional(object({ id: string() }))
69
71
  });
70
72
  const TunnelSchema = object({
71
73
  id: string(),
@@ -105,7 +107,11 @@ const unpluginFactory = (options = {}) => {
105
107
  globalThis[GLOBAL_STATE] = globalState;
106
108
  let child = globalState.child;
107
109
  const VIRTUAL_MODULE_ID = "virtual:unplugin-cloudflare-tunnel";
108
- 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");
109
115
  if (isQuickMode) {
110
116
  const invalidOptions = [
111
117
  "apiToken",
@@ -116,7 +122,7 @@ const unpluginFactory = (options = {}) => {
116
122
  "ssl",
117
123
  "cleanup"
118
124
  ].filter((opt) => opt in options);
119
- 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.`);
120
126
  }
121
127
  let providedApiToken;
122
128
  let hostname;
@@ -140,11 +146,37 @@ const unpluginFactory = (options = {}) => {
140
146
  sslOption = namedOptions.ssl;
141
147
  cleanupConfig = namedOptions.cleanup || {};
142
148
  }
143
- const { port: userProvidedPort, logFile, logLevel, debug = false } = options;
149
+ const { port: userProvidedPort, logFile, logLevel, protocol = "http2", debug = false } = options;
144
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
+ };
145
177
  const pluginLog = {
146
178
  debug: (...args) => {
147
- 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)));
148
180
  },
149
181
  info: (message) => {
150
182
  if (shouldLog(effectivePluginLogLevel, "info")) console.log(`[unplugin-cloudflare-tunnel] ${message}`);
@@ -201,8 +233,6 @@ const unpluginFactory = (options = {}) => {
201
233
  out.push(rule);
202
234
  out.push(center(colorize("Tunnel URL", ANSI.bold)));
203
235
  out.push(center(urlLine));
204
- out.push(center(urlLine));
205
- out.push(center(urlLine));
206
236
  if (localLine) {
207
237
  out.push("");
208
238
  out.push(center(colorize("Local", ANSI.dim + ANSI.bold)));
@@ -224,13 +254,17 @@ const unpluginFactory = (options = {}) => {
224
254
  "fatal"
225
255
  ].includes(logLevel)) throw new Error("[unplugin-cloudflare-tunnel] logLevel must be one of: debug, info, warn, error, fatal");
226
256
  const effectiveLogLevel = logLevel ?? (debug ? "info" : "warn");
227
- 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);
228
261
  if (dnsOption) {
229
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");
230
263
  }
231
264
  if (sslOption) {
232
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");
233
266
  }
267
+ if (!["http2", "quic"].includes(protocol)) throw new Error("[unplugin-cloudflare-tunnel] protocol must be one of: 'http2', 'quic'");
234
268
  const trackSslCertificate = (certificateId, hosts, tunnelName, timestamp = (/* @__PURE__ */ new Date()).toISOString()) => {
235
269
  const trackingKey = `ssl-cert-${certificateId}`;
236
270
  globalState[trackingKey] = {
@@ -360,10 +394,11 @@ const unpluginFactory = (options = {}) => {
360
394
  await new Promise((resolve) => setTimeout(resolve, delay));
361
395
  }
362
396
  };
363
- const spawnQuickTunnel = async (localTarget) => {
397
+ const spawnQuickTunnel = async (localTarget, protocol) => {
364
398
  const cloudflaredArgs = ["tunnel"];
365
399
  cloudflaredArgs.push("--loglevel", "info");
366
400
  if (logFile) cloudflaredArgs.push("--logfile", logFile);
401
+ cloudflaredArgs.push("--protocol", protocol);
367
402
  cloudflaredArgs.push("--url", localTarget);
368
403
  debugLog("Spawning quick tunnel:", bin, cloudflaredArgs);
369
404
  const child = NodeChildProcess.spawn(bin, cloudflaredArgs, {
@@ -379,8 +414,24 @@ const unpluginFactory = (options = {}) => {
379
414
  debugLog(`[unplugin-cloudflare-tunnel] Quick tunnel process spawned with PID: ${child.pid}`);
380
415
  return new Promise((resolve, reject) => {
381
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
+ };
382
428
  const timeout = setTimeout(() => {
383
- 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
+ }
384
435
  }, 3e4);
385
436
  child.stdout?.on("data", (data) => {
386
437
  const output = data.toString();
@@ -395,7 +446,7 @@ const unpluginFactory = (options = {}) => {
395
446
  if (urlMatch && !urlFound) {
396
447
  urlFound = true;
397
448
  clearTimeout(timeout);
398
- resolve({
449
+ resolveOnce({
399
450
  child,
400
451
  url: urlMatch[0]
401
452
  });
@@ -411,11 +462,11 @@ const unpluginFactory = (options = {}) => {
411
462
  });
412
463
  child.on("error", (error) => {
413
464
  clearTimeout(timeout);
414
- 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}`));
415
466
  });
416
467
  child.on("exit", (code, signal) => {
417
468
  clearTimeout(timeout);
418
- 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})`));
419
470
  });
420
471
  });
421
472
  };
@@ -525,11 +576,12 @@ const unpluginFactory = (options = {}) => {
525
576
  const localTarget = getLocalTarget(serverHost, port);
526
577
  debugLog("← Quick tunnel connecting to local target", localTarget);
527
578
  try {
528
- const { child: quickChild, url } = await spawnQuickTunnel(localTarget);
579
+ const { child: quickChild, url } = await spawnQuickTunnel(localTarget, protocol);
529
580
  tunnelUrl = url;
530
581
  child = quickChild;
531
582
  globalState.child = child;
532
583
  globalState.configHash = newConfigHash;
584
+ globalState.tunnelUrl = Promise.resolve(url);
533
585
  registerExitHandler();
534
586
  registerListeningHandler(() => {
535
587
  const { host: actualServerHost, port: actualPort } = normalizeAddress(server.httpServer?.address());
@@ -549,10 +601,11 @@ const unpluginFactory = (options = {}) => {
549
601
  killCloudflared("SIGTERM");
550
602
  await new Promise((resolve) => setTimeout(resolve, 1e3));
551
603
  const newLocalTarget = getLocalTarget(actualServerHost, actualPort ?? port);
552
- const { child: newChild, url: newUrl } = await spawnQuickTunnel(newLocalTarget);
604
+ const { child: newChild, url: newUrl } = await spawnQuickTunnel(newLocalTarget, protocol);
553
605
  tunnelUrl = newUrl;
554
606
  child = newChild;
555
607
  globalState.child = child;
608
+ globalState.tunnelUrl = Promise.resolve(newUrl);
556
609
  announceTunnel({
557
610
  key: `quick:${newUrl}:${actualPort ?? port}`,
558
611
  url: newUrl,
@@ -585,13 +638,11 @@ const unpluginFactory = (options = {}) => {
585
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");
586
639
  debugLog(`[unplugin-cloudflare-tunnel] Using port ${port}${userProvidedPort === port ? " (user-provided)" : " (from bundler config)"}`);
587
640
  await ensureCloudflaredBinary(bin);
588
- const accounts = await cf(apiToken, "GET", "/accounts", void 0, array(AccountSchema));
589
- const accountId = forcedAccount || accounts[0]?.id;
590
- if (!accountId) throw new Error("Unable to determine Cloudflare account ID");
591
641
  const apexDomain = hostname.split(".").slice(-2).join(".");
592
642
  const parentDomain = hostname.split(".").slice(1).join(".");
593
643
  debugLog("← Apex domain", apexDomain);
594
644
  debugLog("← Parent domain", parentDomain);
645
+ let resolvedZone;
595
646
  let zoneId = forcedZone;
596
647
  if (!zoneId) {
597
648
  let zones = [];
@@ -601,8 +652,12 @@ const unpluginFactory = (options = {}) => {
601
652
  debugLog("← Error fetching zone for parent domain", error);
602
653
  }
603
654
  if (zones.length === 0) zones = await cf(apiToken, "GET", `/zones?name=${apexDomain}`, void 0, array(ZoneSchema));
604
- zoneId = zones[0]?.id;
655
+ resolvedZone = zones[0];
656
+ zoneId = resolvedZone?.id;
605
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");
606
661
  if (!zoneId) throw new Error(`Zone ${apexDomain} not found in account ${accountId}`);
607
662
  const { autoCleanup = true } = cleanupConfig;
608
663
  let tunnel = (await cf(apiToken, "GET", `/accounts/${accountId}/cfd_tunnel?name=${tunnelName}`, void 0, array(TunnelSchema)))[0];
@@ -726,26 +781,17 @@ const unpluginFactory = (options = {}) => {
726
781
  console.error(`[unplugin-cloudflare-tunnel] ⚠️ SSL management error: ${sslError.message}`);
727
782
  throw sslError;
728
783
  }
729
- const cloudflaredArgs = ["tunnel"];
730
- cloudflaredArgs.push("--loglevel", effectiveLogLevel);
731
- if (logFile) cloudflaredArgs.push("--logfile", logFile);
732
- debugLog("Spawning cloudflared", bin, cloudflaredArgs);
733
- cloudflaredArgs.push("run", "--token", token);
734
- child = NodeChildProcess.spawn(bin, cloudflaredArgs, {
735
- stdio: [
736
- "ignore",
737
- "pipe",
738
- "pipe"
739
- ],
740
- detached: false,
741
- windowsHide: true,
742
- shell: process.platform === "win32"
743
- });
744
- debugLog(`[unplugin-cloudflare-tunnel] Process spawned with PID: ${child.pid}`);
745
- globalState.child = child;
746
- globalState.configHash = newConfigHash;
747
- registerExitHandler();
748
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
+ };
749
795
  const logCloudflaredLines = (kind, text) => {
750
796
  if (globalState.shuttingDown && !debug) return;
751
797
  const isVerbose = effectiveLogLevel === "debug" || effectiveLogLevel === "info";
@@ -765,42 +811,70 @@ const unpluginFactory = (options = {}) => {
765
811
  else console.error(`${prefix} ${line}`);
766
812
  }
767
813
  };
768
- child.stdout?.on("data", (data) => {
769
- const output = data.toString();
770
- logCloudflaredLines("stdout", output);
771
- if (output.includes("Connection") && output.includes("registered")) {
772
- if (!tunnelReady) {
773
- tunnelReady = true;
774
- 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;
775
843
  }
776
- }
777
- });
778
- child.stderr?.on("data", (data) => {
779
- const error = data.toString().trim();
780
- if (error.includes("Failed to parse ICMP reply") || error.includes("unknow ip version 0")) {
781
- if (logLevel === "debug") console.log(`[cloudflared debug] ${error}`);
782
- return;
783
- }
784
- logCloudflaredLines("stderr", error);
785
- });
786
- child.on("error", (error) => {
787
- console.error(`[unplugin-cloudflare-tunnel] ❌ Failed to start tunnel process: ${error.message}`);
788
- if (error.message.includes("ENOENT")) console.error(`[unplugin-cloudflare-tunnel] Hint: cloudflared binary may not be installed correctly`);
789
- });
790
- child.on("exit", (code, signal) => {
791
- if (code !== 0 && code !== null) {
792
- console.error(`[unplugin-cloudflare-tunnel] Tunnel process exited with code ${code}`);
793
- if (signal) console.error(`[unplugin-cloudflare-tunnel] Process terminated by signal: ${signal}`);
794
- } else if (code === 0) console.log(`[unplugin-cloudflare-tunnel] Tunnel process exited cleanly`);
795
- });
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();
796
874
  registerListeningHandler(() => {
797
875
  const { host: actualServerHost, port: actualPort } = normalizeAddress(server.httpServer?.address());
798
- const actualLocalTarget = getLocalTarget(actualServerHost, actualPort ?? port);
799
- announceTunnel({
800
- key: `named:${hostname}:${actualPort ?? port}`,
801
- url: `https://${hostname}`,
802
- localTarget: actualLocalTarget
803
- });
876
+ localTargetForAnnouncement = getLocalTarget(actualServerHost, actualPort ?? port);
877
+ announceNamedTunnelIfReady();
804
878
  });
805
879
  server.httpServer?.once("close", () => {
806
880
  killCloudflared("SIGTERM");
@@ -812,6 +886,7 @@ const unpluginFactory = (options = {}) => {
812
886
  pluginLog.warn(`Port conflict detected - server is using port ${actualPort} instead of ${port}`);
813
887
  pluginLog.info("Updating tunnel configuration...");
814
888
  const newLocalTarget = getLocalTarget(actualServerHost, actualPort ?? port);
889
+ localTargetForAnnouncement = newLocalTarget;
815
890
  debugLog("← Updating local target to", newLocalTarget);
816
891
  await cf(apiToken, "PUT", `/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, { config: { ingress: [{
817
892
  hostname,
@@ -825,6 +900,8 @@ const unpluginFactory = (options = {}) => {
825
900
  dnsOption,
826
901
  sslOption
827
902
  });
903
+ if (tunnelReady && activeTunnelProtocol) pluginLog.info(`Tunnel remains connected via ${activeTunnelProtocol.toUpperCase()} after port update`);
904
+ announceNamedTunnelIfReady();
828
905
  }
829
906
  } catch (error) {
830
907
  console.error(`[unplugin-cloudflare-tunnel] ❌ Failed to update tunnel for port change: ${error.message}`);
@@ -871,6 +948,18 @@ const unpluginFactory = (options = {}) => {
871
948
  }
872
949
  if (modified) debugLog(`[unplugin-cloudflare-tunnel] Configured ${label} devServer.allowedHosts to include ${hostToAllow}`);
873
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
+ };
874
963
  const setupWebpackLikeDevServerIntegration = (compiler, bundler) => {
875
964
  if ((compiler?.options?.mode ?? process.env.NODE_ENV) === "production") return;
876
965
  const optionsContainer = compiler.options;
@@ -952,19 +1041,8 @@ const unpluginFactory = (options = {}) => {
952
1041
  config: (config) => {
953
1042
  announceConnecting();
954
1043
  if (!config.server) config.server = {};
955
- if (isQuickMode) {
956
- config.server.allowedHosts = [".trycloudflare.com"];
957
- return;
958
- }
959
- if (!config.server.allowedHosts) {
960
- config.server.allowedHosts = [hostname];
961
- debugLog(`[unplugin-cloudflare-tunnel] Configured Vite to allow requests from ${hostname}`);
962
- } else if (Array.isArray(config.server.allowedHosts)) {
963
- if (!config.server.allowedHosts.includes(hostname)) {
964
- config.server.allowedHosts.push(hostname);
965
- debugLog(`[unplugin-cloudflare-tunnel] Added ${hostname} to allowed hosts`);
966
- }
967
- }
1044
+ ensureViteAllowedHosts(config.server);
1045
+ if (!isQuickMode) debugLog(`[unplugin-cloudflare-tunnel] Configured Vite to allow requests from ${hostname}`);
968
1046
  },
969
1047
  configureServer: (server) => {
970
1048
  const configuredPromise = configureServer(server);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unplugin-cloudflare-tunnel",
3
- "version": "0.0.5",
3
+ "version": "0.1.0",
4
4
  "description": "A plugin that automatically creates and manages Cloudflare tunnels for local development",
5
5
  "type": "module",
6
6
  "imports": {
@@ -54,26 +54,26 @@
54
54
  },
55
55
  "devDependencies": {
56
56
  "@arethetypeswrong/core": "^0.18.2",
57
- "@biomejs/biome": "^2.4.10",
57
+ "@biomejs/biome": "^2.4.12",
58
58
  "@changesets/changelog-github": "^0.6.0",
59
59
  "@changesets/cli": "^2.30.0",
60
60
  "@j178/prek": "^0.3.8",
61
61
  "@rspack/core": "^1.7.11",
62
62
  "@sxzz/test-utils": "^0.5.16",
63
63
  "@total-typescript/ts-reset": "^0.6.1",
64
- "@types/bun": "^1.3.11",
65
- "@types/node": "^25.5.2",
66
- "@typescript/native-preview": "^7.0.0-dev.20260406.1",
64
+ "@types/bun": "^1.3.12",
65
+ "@types/node": "^25.6.0",
66
+ "@typescript/native-preview": "^7.0.0-dev.20260414.1",
67
67
  "publint": "^0.3.18",
68
68
  "rollup": "^4.60.1",
69
69
  "simple-git-hooks": "^2.13.1",
70
- "tsdown": "^0.21.7",
70
+ "tsdown": "^0.21.8",
71
71
  "tsx": "^4.21.0",
72
72
  "typescript": "^6.0.2",
73
73
  "unplugin-unused": "^0.5.7",
74
74
  "unplugin-utils": "^0.3.1",
75
- "vite": "^8.0.5",
76
- "vitest": "^4.1.2",
75
+ "vite": "^8.0.8",
76
+ "vitest": "^4.1.4",
77
77
  "webpack-dev-server": "^5.2.3",
78
78
  "zod": "^4.3.6"
79
79
  },
@@ -126,7 +126,7 @@
126
126
  "farm",
127
127
  "astro"
128
128
  ],
129
- "packageManager": "bun@1.3.11",
129
+ "packageManager": "bun@1.3.12",
130
130
  "simple-git-hooks": {
131
131
  "pre-commit": "bun prek run --all-files --config='.github/.pre-commit.yml'"
132
132
  },