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 +71 -1
- package/dist/api.d.mts +3 -0
- package/dist/api.mjs +2 -1
- package/dist/index.d.mts +25 -4
- package/dist/index.mjs +162 -84
- package/package.json +9 -9
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
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
|
|
72
|
-
*
|
|
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:
|
|
162
|
-
* - Quick tunnel mode:
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
769
|
-
const
|
|
770
|
-
|
|
771
|
-
if (
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
-
|
|
799
|
-
|
|
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
|
-
|
|
956
|
-
|
|
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
|
|
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.
|
|
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.
|
|
65
|
-
"@types/node": "^25.
|
|
66
|
-
"@typescript/native-preview": "^7.0.0-dev.
|
|
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.
|
|
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.
|
|
76
|
-
"vitest": "^4.1.
|
|
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.
|
|
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
|
},
|