diff --git a/.changeset/fix-hotkeys-case-insensitive.md b/.changeset/fix-hotkeys-case-insensitive.md new file mode 100644 index 0000000000..b198de5c94 --- /dev/null +++ b/.changeset/fix-hotkeys-case-insensitive.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +fix: hotkeys now work with Caps Lock enabled + +Wrangler's dev server hotkeys (e.g. `b` to open browser and `x` to exit) did not respond when Caps Lock was enabled. These hotkeys now work consistently whether or not Caps Lock is on. diff --git a/.changeset/preview-json-vars.md b/.changeset/preview-json-vars.md new file mode 100644 index 0000000000..a8cad6a130 --- /dev/null +++ b/.changeset/preview-json-vars.md @@ -0,0 +1,22 @@ +--- +"wrangler": patch +--- + +preserve native shape of non-string `vars` in worker previews + +`wrangler preview` previously coerced every non-string entry in `previews.vars` (arrays, objects, numbers, booleans) into a `plain_text` binding via `JSON.stringify`, so at runtime the worker saw a literal string instead of the value declared in `wrangler.jsonc`. `wrangler deploy` already serializes non-string vars as `json` bindings so the Workers runtime parses them back into native JS values; previews now match. + +Before: + +```ts +// wrangler.jsonc — previews.vars +{ "ALLOWLIST": ["a@example.com", "b@example.com"] } +// runtime +typeof env.ALLOWLIST === "string" // true (was '["a@example.com","b@example.com"]') +``` + +After: + +```ts +typeof env.ALLOWLIST === "object" // Array.isArray(env.ALLOWLIST) === true +``` diff --git a/.changeset/propagate-trace-id-header.md b/.changeset/propagate-trace-id-header.md new file mode 100644 index 0000000000..099d97efb3 --- /dev/null +++ b/.changeset/propagate-trace-id-header.md @@ -0,0 +1,7 @@ +--- +"miniflare": patch +--- + +Propagate `cf-trace-id` header on remote binding proxy requests + +When the `CF_TRACE_ID` environment variable is set, its value is now forwarded as a `cf-trace-id` header on outgoing remote binding proxy requests. This makes it easier to correlate traces when debugging remote bindings in local development. diff --git a/.changeset/small-rocks-live.md b/.changeset/small-rocks-live.md new file mode 100644 index 0000000000..a35b9e927a --- /dev/null +++ b/.changeset/small-rocks-live.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +Propagate `unsafe.bindings` and service binding `cross_account_grant` to worker previews + +Worker previews now propagate `unsafe.bindings` declared on the `previews` config block to the deployment metadata, mirroring the deploy-time behavior. Without this, internal binding shapes that wrangler doesn't yet model (notably service bindings carrying `cross_account_grant`) were silently dropped on previews while working fine on regular deploys. The same change wires through `cross_account_grant` on typed `services` bindings. diff --git a/.changeset/wise-taxis-jog.md b/.changeset/wise-taxis-jog.md new file mode 100644 index 0000000000..2d72ef3b3e --- /dev/null +++ b/.changeset/wise-taxis-jog.md @@ -0,0 +1,7 @@ +--- +"wrangler": minor +--- + +Add named tunnel support and tunnel shortcuts to `wrangler dev` + +You can now use `wrangler dev --tunnel --tunnel-name ` to start a dev session with an existing named Cloudflare Tunnel, or set `--tunnel-name` ahead of time and start it later by pressing `t` to start or close the tunnel. This gives you a stable public hostname for local development instead of the temporary `trycloudflare.com` URL used by Quick Tunnels. diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index d8e16ba00c..370a2227c4 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -198,10 +198,9 @@ "workers-sdk/no-wrangler-named-imports": "error", }, }, - // Gradually rolling out require-description-when-disabling. - // TODO: Remove the following overrides. + // TODO: Remove the following wrangler override. { - "files": ["packages/quick-edit-extension/**", "packages/wrangler/**"], + "files": ["packages/wrangler/**"], "rules": { "workers-sdk/require-description-when-disabling": "off", }, diff --git a/packages/miniflare/src/plugins/shared/constants.ts b/packages/miniflare/src/plugins/shared/constants.ts index 0e9eb67ff7..d33bcca7e1 100644 --- a/packages/miniflare/src/plugins/shared/constants.ts +++ b/packages/miniflare/src/plugins/shared/constants.ts @@ -83,6 +83,7 @@ export function remoteProxyClientWorker( binding: string, script?: () => string ) { + const cfTraceId = process.env.CF_TRACE_ID; return { compatibilityDate: "2025-01-01", modules: [ @@ -104,6 +105,14 @@ export function remoteProxyClientWorker( name: "binding", text: binding, }, + ...(cfTraceId + ? [ + { + name: "cfTraceId", + text: cfTraceId, + }, + ] + : []), ], }; } diff --git a/packages/miniflare/src/workers/dispatch-namespace/dispatch-namespace-proxy.worker.ts b/packages/miniflare/src/workers/dispatch-namespace/dispatch-namespace-proxy.worker.ts index 88b88a70f2..e5a60a5d0e 100644 --- a/packages/miniflare/src/workers/dispatch-namespace/dispatch-namespace-proxy.worker.ts +++ b/packages/miniflare/src/workers/dispatch-namespace/dispatch-namespace-proxy.worker.ts @@ -24,7 +24,8 @@ export default class DispatchNamespaceProxy extends WorkerEntrypoint => { if (!remoteProxyConnectionString) { @@ -43,6 +45,12 @@ export function makeFetch( } proxiedHeaders.set("MF-URL", request.url); proxiedHeaders.set("MF-Binding", bindingName); + if (cfTraceId) { + // Set directly on the outgoing request so Cloudflare's edge tracing picks it up + proxiedHeaders.set("cf-trace-id", cfTraceId); + // Also forward through to the binding call via the MF-Header proxy mechanism + proxiedHeaders.set("MF-Header-cf-trace-id", cfTraceId); + } const req = new Request(request, { headers: proxiedHeaders, }); @@ -59,7 +67,8 @@ export function makeFetch( export function makeRemoteProxyStub( remoteProxyConnectionString: string, bindingName: string, - metadata?: ProxyMetadata + metadata?: ProxyMetadata, + cfTraceId?: string ): Fetcher { const url = new URL(remoteProxyConnectionString); url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; @@ -89,7 +98,12 @@ export function makeRemoteProxyStub( return new Proxy(stub, { get(_, p) { if (p === "fetch") { - return makeFetch(remoteProxyConnectionString, bindingName, headers); + return makeFetch( + remoteProxyConnectionString, + bindingName, + headers, + cfTraceId + ); } return Reflect.get(stub, p); }, diff --git a/packages/miniflare/src/workers/shared/remote-proxy-client.worker.ts b/packages/miniflare/src/workers/shared/remote-proxy-client.worker.ts index 405ab964f8..444cd2b362 100644 --- a/packages/miniflare/src/workers/shared/remote-proxy-client.worker.ts +++ b/packages/miniflare/src/workers/shared/remote-proxy-client.worker.ts @@ -11,7 +11,9 @@ export default class Client extends WorkerEntrypoint { fetch(request: Request): Promise { return makeFetch( this.env.remoteProxyConnectionString, - this.env.binding + this.env.binding, + undefined, + this.env.cfTraceId )(request); } @@ -19,7 +21,12 @@ export default class Client extends WorkerEntrypoint { super(ctx, env); const stub = env.remoteProxyConnectionString - ? makeRemoteProxyStub(env.remoteProxyConnectionString, env.binding) + ? makeRemoteProxyStub( + env.remoteProxyConnectionString, + env.binding, + undefined, + env.cfTraceId + ) : undefined; return new Proxy(this, { diff --git a/packages/quick-edit-extension/src/cfs.ts b/packages/quick-edit-extension/src/cfs.ts index dd5f2cac19..9f30d27ddd 100644 --- a/packages/quick-edit-extension/src/cfs.ts +++ b/packages/quick-edit-extension/src/cfs.ts @@ -541,15 +541,16 @@ declare module "*.bin" { }); } + /* eslint-disable no-useless-escape -- + adapted from vscode-web-playground, see: https://github.com/microsoft/vscode-web-playground/blob/fde7a272cc7de/src/memfs.ts#L399-L401 + escapes are redundant in a character class but harmless + */ private _convertSimple2RegExpPattern(pattern: string): string { - return ( - pattern - // eslint-disable-next-line - .replace(/[\-\\\{\}\+\?\|\^\$\.\,\[\]\(\)\#\s]/g, "\\$&") - // eslint-disable-next-line - .replace(/[\*]/g, ".*") - ); + return pattern + .replace(/[\-\\\{\}\+\?\|\^\$\.\,\[\]\(\)\#\s]/g, "\\$&") + .replace(/[\*]/g, ".*"); } + /* eslint-enable no-useless-escape */ // --- search provider diff --git a/packages/vite-plugin-cloudflare/src/__tests__/preview-server.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/preview-server.spec.ts index fffb7c25ad..9f3abf941f 100644 --- a/packages/vite-plugin-cloudflare/src/__tests__/preview-server.spec.ts +++ b/packages/vite-plugin-cloudflare/src/__tests__/preview-server.spec.ts @@ -45,6 +45,7 @@ describe("preview server", () => { }) => { vi.mocked(startTunnel).mockReturnValue({ ready: vi.fn().mockResolvedValue({ + mode: "quick", publicUrl: new URL("https://example.trycloudflare.com"), }), extendExpiry: vi.fn(), diff --git a/packages/vite-plugin-cloudflare/src/__tests__/tunnel.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/tunnel.spec.ts index 47df29c732..607dbb5a72 100644 --- a/packages/vite-plugin-cloudflare/src/__tests__/tunnel.spec.ts +++ b/packages/vite-plugin-cloudflare/src/__tests__/tunnel.spec.ts @@ -27,6 +27,7 @@ describe("tunnel plugin", () => { beforeEach(() => { vi.mocked(startTunnel).mockReturnValue({ ready: vi.fn().mockResolvedValue({ + mode: "quick", publicUrl: new URL("https://example.trycloudflare.com"), }), extendExpiry: vi.fn(), @@ -43,6 +44,7 @@ describe("tunnel plugin", () => { }) => { vi.mocked(startTunnel).mockReturnValue({ ready: vi.fn().mockResolvedValue({ + mode: "quick", publicUrl: new URL("https://example.trycloudflare.com"), }), extendExpiry: vi.fn(), @@ -96,6 +98,7 @@ describe("tunnel plugin", () => { }) => { vi.mocked(startTunnel).mockReturnValue({ ready: vi.fn().mockResolvedValue({ + mode: "quick", publicUrl: new URL("https://example.trycloudflare.com"), }), extendExpiry: vi.fn(), @@ -136,6 +139,7 @@ describe("tunnel plugin", () => { vi.mocked(startTunnel) .mockReturnValueOnce({ ready: vi.fn().mockResolvedValue({ + mode: "quick", publicUrl: new URL("https://foo.trycloudflare.com"), }), extendExpiry: vi.fn(), @@ -143,6 +147,7 @@ describe("tunnel plugin", () => { }) .mockReturnValueOnce({ ready: vi.fn().mockResolvedValue({ + mode: "quick", publicUrl: new URL("https://bar.trycloudflare.com"), }), extendExpiry: vi.fn(), @@ -214,6 +219,7 @@ describe("tunnel plugin", () => { vi.mocked(startTunnel) .mockReturnValueOnce({ ready: vi.fn().mockResolvedValue({ + mode: "quick", publicUrl: new URL("https://foo.trycloudflare.com"), }), extendExpiry: vi.fn(), @@ -223,6 +229,7 @@ describe("tunnel plugin", () => { }) .mockReturnValueOnce({ ready: vi.fn().mockResolvedValue({ + mode: "quick", publicUrl: new URL("https://bar.trycloudflare.com"), }), extendExpiry: vi.fn(), @@ -324,6 +331,7 @@ describe("tunnel plugin", () => { it("prints tunnel details with server.printUrls", async ({ expect }) => { vi.mocked(startTunnel).mockReturnValue({ ready: vi.fn().mockResolvedValue({ + mode: "quick", publicUrl: new URL("https://example.trycloudflare.com"), }), extendExpiry: vi.fn(), diff --git a/packages/vite-plugin-cloudflare/src/plugins/tunnel.ts b/packages/vite-plugin-cloudflare/src/plugins/tunnel.ts index aa79566cf3..f2e186584d 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/tunnel.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/tunnel.ts @@ -103,15 +103,22 @@ export class TunnelManager { async #waitForPublicUrl(tunnel: Tunnel): Promise { try { - const { publicUrl } = await tunnel.ready(); + const result = await tunnel.ready(); if (this.#tunnel !== tunnel) { debuglog( "Tunnel was restarted before it finished starting. Ignoring this tunnel's public URL:", - publicUrl + result.mode === "quick" ? result.publicUrl : "(named tunnel)" ); return null; } + if (result.mode !== "quick") { + this.#publicUrl = undefined; + return null; + } + + const { publicUrl } = result; + debuglog("Tunnel is ready with public URL:", publicUrl); this.#publicUrl = publicUrl.toString(); return publicUrl.toString(); diff --git a/packages/workers-utils/src/tunnel.ts b/packages/workers-utils/src/tunnel.ts index 9e7713a3c3..b161f52d0a 100644 --- a/packages/workers-utils/src/tunnel.ts +++ b/packages/workers-utils/src/tunnel.ts @@ -18,10 +18,17 @@ const DEFAULT_TUNNEL_REMINDER_INTERVAL_MS = 10 * 60 * 1_000; */ const QUICK_TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/; -export interface TunnelResult { +export interface QuickTunnelResult { + mode: "quick"; publicUrl: URL; } +export interface NamedTunnelResult { + mode: "named"; +} + +export type TunnelResult = QuickTunnelResult | NamedTunnelResult; + export interface Tunnel { ready: () => Promise; dispose: () => void; @@ -30,6 +37,7 @@ export interface Tunnel { export interface TunnelOptions { origin: URL; + token?: string; timeoutMs?: number; expiryMs?: number; reminderIntervalMs?: number; @@ -57,18 +65,17 @@ export function startTunnel(options: TunnelOptions): Tunnel { const reminderIntervalMs = options.reminderIntervalMs ?? DEFAULT_TUNNEL_REMINDER_INTERVAL_MS; const defaultExpiryMs = options.expiryMs ?? DEFAULT_TUNNEL_EXPIRY_MS; + const isNamedTunnel = options.token !== undefined; const timeFormatter = new Intl.DateTimeFormat(undefined, { timeStyle: "short", }); - const cloudflaredArgs = [ - "tunnel", - "--no-autoupdate", - "--url", - options.origin.href, - ]; + const cloudflaredArgs = isNamedTunnel + ? ["tunnel", "--no-autoupdate", "run"] + : ["tunnel", "--no-autoupdate", "--url", options.origin.href]; const cloudflaredPromise = spawnCloudflared(cloudflaredArgs, { stdio: "pipe", + env: options.token ? { TUNNEL_TOKEN: options.token } : undefined, skipVersionCheck: true, logger, }).then((process) => { @@ -82,17 +89,23 @@ export function startTunnel(options: TunnelOptions): Tunnel { }); const readyPromise = cloudflaredPromise - .then((process) => - waitForQuickTunnelReady(process, timeoutMs, { + .then((process) => { + if (isNamedTunnel) { + return { mode: "named" } as const; + } + + return waitForQuickTunnelReady(process, timeoutMs, { logger, origin: options.origin, - }) - ) + }); + }) .then((result) => { expiresAt = Date.now() + defaultExpiryMs; scheduleExpiryTimeout(); - scheduleReminder(result.publicUrl.origin); + scheduleReminder( + result.mode === "quick" ? result.publicUrl.origin : undefined + ); return result; }); @@ -118,7 +131,7 @@ export function startTunnel(options: TunnelOptions): Tunnel { } } - function scheduleReminder(publicURL: string) { + function scheduleReminder(publicURL: string | undefined) { if (reminderIntervalMs > 0) { reminderInterval = setInterval(() => { if (disposed) { @@ -131,7 +144,11 @@ export function startTunnel(options: TunnelOptions): Tunnel { } logger?.log( - `The tunnel is still open at ${publicURL}. It expires in ${formatTunnelDuration(remainingMs)}. ${options.extendHint ?? ""}` + `${ + publicURL + ? `The tunnel is still open at ${publicURL}.` + : "The tunnel is still open." + } It expires in ${formatTunnelDuration(remainingMs)}. ${options.extendHint ?? ""}` ); }, reminderIntervalMs); reminderInterval.unref?.(); @@ -262,7 +279,7 @@ function waitForQuickTunnelReady( if (match && !resolved) { resolved = true; clearTimeout(timeoutId); - resolve({ publicUrl: new URL(match[0]) }); + resolve({ mode: "quick", publicUrl: new URL(match[0]) }); } }); } diff --git a/packages/workers-utils/tests/tunnel.test.ts b/packages/workers-utils/tests/tunnel.test.ts index d0cd825ce6..24f362b6b6 100644 --- a/packages/workers-utils/tests/tunnel.test.ts +++ b/packages/workers-utils/tests/tunnel.test.ts @@ -89,10 +89,27 @@ describe("startTunnel", () => { ); await expect(tunnel.ready()).resolves.toEqual({ + mode: "quick", publicUrl: new URL("https://foo-bar-baz.trycloudflare.com"), }); }); + it("should resolve named tunnels after spawning cloudflared", async ({ + expect, + }) => { + const proc = createMockProcess(); + vi.mocked(spawnCloudflared).mockResolvedValue(proc as never); + + const tunnel = startTunnel({ + origin: new URL("http://localhost:8787"), + token: "NAMED_TUNNEL_TOKEN", + timeoutMs: TEST_TIMEOUT_MS, + }); + onTestFinished(() => tunnel.dispose()); + + await expect(tunnel.ready()).resolves.toEqual({ mode: "named" }); + }); + it("should pass the correct args to spawnCloudflared", async ({ expect }) => { const proc = createMockProcess(); vi.mocked(spawnCloudflared).mockResolvedValue(proc as never); @@ -114,6 +131,28 @@ describe("startTunnel", () => { ); }); + it("should pass the correct args for named tunnels", async ({ expect }) => { + const proc = createMockProcess(); + vi.mocked(spawnCloudflared).mockResolvedValue(proc as never); + + const tunnel = startTunnel({ + origin: new URL("http://localhost:8787"), + token: "NAMED_TUNNEL_TOKEN", + timeoutMs: TEST_TIMEOUT_MS, + }); + + await tunnel.ready(); + + expect(spawnCloudflared).toHaveBeenCalledWith( + ["tunnel", "--no-autoupdate", "run"], + { + env: { TUNNEL_TOKEN: "NAMED_TUNNEL_TOKEN" }, + stdio: "pipe", + skipVersionCheck: true, + } + ); + }); + it("should reject if cloudflared exits before producing a URL", async ({ expect, }) => { @@ -273,6 +312,7 @@ describe("startTunnel", () => { await emitStderrNextTick(proc, "url-tunnel.trycloudflare.com\n"); await expect(tunnel.ready()).resolves.toEqual({ + mode: "quick", publicUrl: new URL("https://split-url-tunnel.trycloudflare.com"), }); }); diff --git a/packages/wrangler/src/__tests__/cli-hotkeys.test.ts b/packages/wrangler/src/__tests__/cli-hotkeys.test.ts index 3e6b5c8062..dec3946d3c 100644 --- a/packages/wrangler/src/__tests__/cli-hotkeys.test.ts +++ b/packages/wrangler/src/__tests__/cli-hotkeys.test.ts @@ -80,11 +80,49 @@ describe("Hot Keys", () => { expect(handlerA).toHaveBeenCalled(); handlerA.mockClear(); - writeToMockedStdin("A"); + // Caps Lock and Shift+A both come through readline as { name: "a", shift: true }. + writeToMockedStdin({ + name: "a", + sequence: "A", + ctrl: false, + meta: false, + shift: true, + }); expect(handlerA).toHaveBeenCalled(); handlerA.mockClear(); }); + it("does not fire plain key handler when ctrl or meta is also held with shift", async ({ + expect, + }) => { + const handlerA = vi.fn(); + const options = [ + { keys: ["a"], label: "first option", handler: handlerA }, + ]; + + registerHotKeys(options); + + // ctrl+shift+a should NOT fire the "a" handler + writeToMockedStdin({ + name: "a", + sequence: "", + ctrl: true, + meta: false, + shift: true, + }); + expect(handlerA).not.toHaveBeenCalled(); + + // meta+shift+a should NOT fire the "a" handler + writeToMockedStdin({ + name: "a", + sequence: "", + ctrl: false, + meta: true, + shift: true, + }); + expect(handlerA).not.toHaveBeenCalled(); + }); + it("handles meta keys", async ({ expect }) => { const handlerCtrl = vi.fn(); const handlerMeta = vi.fn(); diff --git a/packages/wrangler/src/__tests__/dev.test.ts b/packages/wrangler/src/__tests__/dev.test.ts index a197c14d79..9853c901cf 100644 --- a/packages/wrangler/src/__tests__/dev.test.ts +++ b/packages/wrangler/src/__tests__/dev.test.ts @@ -3000,7 +3000,44 @@ describe.sequential("wrangler dev", () => { }); fs.writeFileSync("index.js", `export default {};`); const config = await runWranglerUntilConfig("dev --tunnel"); - expect(config.input.dev?.tunnel).toBe(true); + expect(config.input.dev?.tunnel).toEqual({ + enabled: true, + name: undefined, + }); + }); + + it("should pass --tunnel-name with --tunnel through to dev config", async ({ + expect, + }) => { + writeWranglerConfig({ + main: "index.js", + compatibility_date: "2024-01-01", + }); + fs.writeFileSync("index.js", `export default {};`); + const config = await runWranglerUntilConfig( + "dev --tunnel --tunnel-name=my-tunnel" + ); + expect(config.input.dev?.tunnel).toEqual({ + enabled: true, + name: "my-tunnel", + }); + }); + + it("should allow --tunnel-name without enabling tunnel", async ({ + expect, + }) => { + writeWranglerConfig({ + main: "index.js", + compatibility_date: "2024-01-01", + }); + fs.writeFileSync("index.js", `export default {};`); + const config = await runWranglerUntilConfig( + "dev --tunnel-name=my-tunnel" + ); + expect(config.input.dev?.tunnel).toEqual({ + enabled: false, + name: "my-tunnel", + }); }); it("should default tunnel to undefined when not specified", async ({ @@ -3012,7 +3049,10 @@ describe.sequential("wrangler dev", () => { }); fs.writeFileSync("index.js", `export default {};`); const config = await runWranglerUntilConfig("dev"); - expect(config.input.dev?.tunnel).toBeUndefined(); + expect(config.input.dev?.tunnel).toEqual({ + enabled: false, + name: undefined, + }); }); it("should error when --tunnel and --remote are both specified", async ({ diff --git a/packages/wrangler/src/__tests__/dev/start-dev.test.ts b/packages/wrangler/src/__tests__/dev/start-dev.test.ts index 6aca389106..afb4fc286f 100644 --- a/packages/wrangler/src/__tests__/dev/start-dev.test.ts +++ b/packages/wrangler/src/__tests__/dev/start-dev.test.ts @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => { const configSet = vi.fn(); const fakeDevEnv = { config: { set: configSet }, + on: vi.fn(), proxy: { ready: { promise: new Promise(() => {}) } }, teardown: vi.fn(), }; diff --git a/packages/wrangler/src/__tests__/preview.test.ts b/packages/wrangler/src/__tests__/preview.test.ts index c6a31b8f29..ad98280e78 100644 --- a/packages/wrangler/src/__tests__/preview.test.ts +++ b/packages/wrangler/src/__tests__/preview.test.ts @@ -93,6 +93,26 @@ describe("wrangler preview", () => { }); }); + test("should extract non-string vars as json bindings so the runtime preserves the native shape", ({ + expect, + }) => { + const config = configWithPreviews({ + vars: { + ALLOWLIST: ["a@example.com", "b@example.com"], + CONFIG: { feature: true, retries: 3 }, + COUNT: 42, + ENABLED: true, + }, + }); + const bindings = extractConfigBindings(config); + expect(bindings).toMatchObject({ + ALLOWLIST: { type: "json", json: ["a@example.com", "b@example.com"] }, + CONFIG: { type: "json", json: { feature: true, retries: 3 } }, + COUNT: { type: "json", json: 42 }, + ENABLED: { type: "json", json: true }, + }); + }); + test("should extract kv_namespaces", ({ expect }) => { const config = configWithPreviews({ kv_namespaces: [{ binding: "MY_KV", id: "kv-id-123" }], @@ -210,6 +230,58 @@ describe("wrangler preview", () => { MY_FLAGS: { type: "flagship", app_id: "flagship-app-id" }, }); }); + + test("should pass cross_account_grant on service bindings", ({ + expect, + }) => { + const config = configWithPreviews({ + services: [ + { + binding: "API", + service: "api-worker", + // cross_account_grant is internal/non-public-facing on + // the typed schema; mirror how callers set it at + // runtime (deploy path supports the same field). + cross_account_grant: "grant-target", + } as { + binding: string; + service: string; + cross_account_grant: string; + }, + ], + }); + const bindings = extractConfigBindings(config); + expect(bindings).toMatchObject({ + API: { + type: "service", + service: "api-worker", + cross_account_grant: "grant-target", + }, + }); + }); + + test("should fold unsafe.bindings into the previews env", ({ expect }) => { + const config = configWithPreviews({ + unsafe: { + bindings: [ + { + type: "service", + name: "VPC_BRIDGE", + service: "vpc-bridge-worker", + cross_account_grant: "grant-target", + }, + ], + }, + }); + const bindings = extractConfigBindings(config); + expect(bindings).toMatchObject({ + VPC_BRIDGE: { + type: "service", + service: "vpc-bridge-worker", + cross_account_grant: "grant-target", + }, + }); + }); }); describe("preview command", () => { diff --git a/packages/wrangler/src/__tests__/tunnel/tunnel-resolve.test.ts b/packages/wrangler/src/__tests__/tunnel/tunnel-resolve.test.ts index b13fbd1ca4..2abacd387b 100644 --- a/packages/wrangler/src/__tests__/tunnel/tunnel-resolve.test.ts +++ b/packages/wrangler/src/__tests__/tunnel/tunnel-resolve.test.ts @@ -1,16 +1,9 @@ -import { describe, it } from "vitest"; -import { resolveTunnelId } from "../../tunnel/client"; +import { describe, it, vi } from "vitest"; +import { createCloudflareClient } from "../../cfetch/internal"; +import { resolveNamedTunnel, resolveTunnelId } from "../../tunnel/client"; import type Cloudflare from "cloudflare"; -function asyncIterableFromArray(items: T[]): AsyncIterable { - return { - async *[Symbol.asyncIterator]() { - for (const item of items) { - yield item; - } - }, - }; -} +vi.mock("../../cfetch/internal"); describe("resolveTunnelId", () => { it("returns UUID input without calling API", async ({ expect }) => { @@ -38,12 +31,12 @@ describe("resolveTunnelId", () => { cloudflared: { list({ name }: { name?: string }) { expect(name).toBe("my-tunnel"); - return asyncIterableFromArray([ + return [ { id: "11111111-1111-4111-8111-111111111111", name: "my-tunnel", }, - ]); + ]; }, }, }, @@ -54,4 +47,175 @@ describe("resolveTunnelId", () => { "11111111-1111-4111-8111-111111111111" ); }); + + it("resolves a named tunnel target from matching ingress rules", async ({ + expect, + }) => { + vi.mocked(createCloudflareClient).mockReturnValue({ + zeroTrust: { + tunnels: { + cloudflared: { + // @ts-expect-error -- partial mock + list({ name }: { name?: string }) { + expect(name).toBe("my-tunnel"); + return [ + { + id: "11111111-1111-4111-8111-111111111111", + name: "my-tunnel", + }, + ]; + }, + configurations: { + // @ts-expect-error -- partial mock + get(tunnelId: string) { + expect(tunnelId).toBe("11111111-1111-4111-8111-111111111111"); + return Promise.resolve({ + config: { + ingress: [ + { + hostname: "dev.example.com", + service: "http://localhost:8787", + }, + { + hostname: "other.example.com", + service: "http://localhost:3000", + }, + ], + }, + }); + }, + }, + token: { + // @ts-expect-error -- partial mock + get(tunnelId: string) { + expect(tunnelId).toBe("11111111-1111-4111-8111-111111111111"); + return Promise.resolve("TOKEN"); + }, + }, + }, + }, + }, + }); + + await expect( + resolveNamedTunnel("my-tunnel", new URL("http://localhost:8787"), { + accountId: "account", + complianceRegion: undefined, + }) + ).resolves.toEqual({ + hostnames: ["dev.example.com"], + token: "TOKEN", + }); + }); + + it("throws when a named tunnel has no ingress for the local port", async ({ + expect, + }) => { + vi.mocked(createCloudflareClient).mockReturnValue({ + zeroTrust: { + tunnels: { + cloudflared: { + // @ts-expect-error -- partial mock + list() { + return [ + { + id: "11111111-1111-4111-8111-111111111111", + name: "my-tunnel", + }, + ]; + }, + configurations: { + // @ts-expect-error -- partial mock + get() { + return Promise.resolve({ + config: { + ingress: [ + { + hostname: "dev.example.com", + service: "http://localhost:3000", + }, + { + hostname: "admin.example.com", + service: "http://localhost:4000", + }, + ], + }, + }); + }, + }, + // @ts-expect-error -- partial mock + token: { + get() { + throw new Error("should not be called"); + }, + }, + }, + }, + }, + }); + + await expect( + resolveNamedTunnel("my-tunnel", new URL("http://localhost:8787"), { + accountId: "account", + complianceRegion: undefined, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(` + [Error: No ingress rules in tunnel "my-tunnel" route to http://localhost:8787/ + + Update the tunnel ingress rules in the Cloudflare dashboard: + https://dash.cloudflare.com/?to=/:account/tunnels + + Resolved ingress mappings: + - dev.example.com -> http://localhost:3000 + - admin.example.com -> http://localhost:4000 + ] + `); + }); + + it("shows compact setup guidance when a named tunnel has no ingress rules", async ({ + expect, + }) => { + vi.mocked(createCloudflareClient).mockReturnValue({ + zeroTrust: { + tunnels: { + cloudflared: { + // @ts-expect-error -- partial mock + list() { + return [ + { + id: "11111111-1111-4111-8111-111111111111", + name: "my-tunnel", + }, + ]; + }, + configurations: { + // @ts-expect-error -- partial mock + get() { + return Promise.resolve({ config: { ingress: [] } }); + }, + }, + // @ts-expect-error -- partial mock + token: { + get() { + throw new Error("should not be called"); + }, + }, + }, + }, + }, + }); + + await expect( + resolveNamedTunnel("my-tunnel", new URL("http://localhost:8787"), { + accountId: "account", + complianceRegion: undefined, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(` + [Error: Tunnel "my-tunnel" has no ingress rules configured. + + Add an ingress rule for http://localhost:8787/ in the Cloudflare dashboard: + https://dash.cloudflare.com/?to=/:account/tunnels + ] + `); + }); }); diff --git a/packages/wrangler/src/api/dev.ts b/packages/wrangler/src/api/dev.ts index b24528107d..68481267d4 100644 --- a/packages/wrangler/src/api/dev.ts +++ b/packages/wrangler/src/api/dev.ts @@ -224,6 +224,7 @@ export async function unstable_dev( containerEngine: options?.experimental?.containerEngine, types: false, tunnel: undefined, + tunnelName: undefined, }; //outside of test mode, rebuilds work fine, but only one instance of wrangler will work at a time diff --git a/packages/wrangler/src/api/startDevWorker/ProxyController.ts b/packages/wrangler/src/api/startDevWorker/ProxyController.ts index 9210e1c34e..5296e05669 100644 --- a/packages/wrangler/src/api/startDevWorker/ProxyController.ts +++ b/packages/wrangler/src/api/startDevWorker/ProxyController.ts @@ -475,7 +475,10 @@ export class ProxyController extends Controller { break; case "sseResponseDetected": // Only warn about SSE if a quick tunnel is active - if (this.latestConfig?.dev?.tunnel) { + if ( + this.latestConfig?.dev?.tunnel?.enabled && + this.latestConfig.dev.tunnel.name === undefined + ) { logger.once.warn( "Quick tunnels do not support Server-Sent Events (SSE). Use a named Cloudflare Tunnel if you need SSE over a public URL." ); diff --git a/packages/wrangler/src/api/startDevWorker/types.ts b/packages/wrangler/src/api/startDevWorker/types.ts index 5796002232..2bafe7fba4 100644 --- a/packages/wrangler/src/api/startDevWorker/types.ts +++ b/packages/wrangler/src/api/startDevWorker/types.ts @@ -190,8 +190,11 @@ export interface StartDevWorkerInput { /** Re-generate your worker types when your Wrangler configuration file changes */ generateTypes?: boolean; - /** Whether a Cloudflare Quick Tunnel is active for this dev session */ - tunnel?: boolean; + /** Tunnel configuration for this dev session. */ + tunnel?: { + enabled: boolean; + name?: string; + }; }; legacy?: { site?: Hook; diff --git a/packages/wrangler/src/cli-hotkeys.ts b/packages/wrangler/src/cli-hotkeys.ts index 0803b41c7c..7da1204f49 100644 --- a/packages/wrangler/src/cli-hotkeys.ts +++ b/packages/wrangler/src/cli-hotkeys.ts @@ -68,6 +68,16 @@ export default function ( ); } + function isKeyMatch(input: string, key: string) { + if (input === key) { + return true; + } + + // When "a" is pressed with Caps Lock on, readline emits `{ name: "a", shift: true }` + // This keeps the hotkeys case-insensitive + return /^[a-z]$/.test(key) && input === `shift+${key}`; + } + const unregisterKeyPress = onKeyPress(async (key) => { const entries: string[] = []; @@ -91,7 +101,7 @@ export default function ( continue; } - if (keys.includes(char)) { + if (keys.some((registeredKey) => isKeyMatch(char, registeredKey))) { try { await handler(); } catch { diff --git a/packages/wrangler/src/cli.ts b/packages/wrangler/src/cli.ts index dad4fc351b..fd5d9ebf9b 100644 --- a/packages/wrangler/src/cli.ts +++ b/packages/wrangler/src/cli.ts @@ -92,6 +92,7 @@ export type { }; export { printBindings as unstable_printBindings } from "./utils/print-bindings"; +export { resolveNamedTunnel as unstable_resolveNamedTunnel } from "./tunnel/client"; // Export internal APIs required by the Vitest integration as `unstable_` export { splitSqlQuery as unstable_splitSqlQuery } from "./d1/splitter"; diff --git a/packages/wrangler/src/dev.ts b/packages/wrangler/src/dev.ts index 0b6a173fe4..537c0cb2e9 100644 --- a/packages/wrangler/src/dev.ts +++ b/packages/wrangler/src/dev.ts @@ -261,9 +261,14 @@ export const dev = createCommand({ }, tunnel: { describe: - "Expose your local dev server via a Cloudflare Quick Tunnel (https://try.cloudflare.com)", + "Expose your local dev server via a Cloudflare Tunnel. Use `--tunnel` for a Quick Tunnel and `--tunnel-name` with `--tunnel` for a named tunnel.", type: "boolean", }, + "tunnel-name": { + describe: + "Use an existing named Cloudflare Tunnel when `--tunnel` is enabled.", + type: "string", + }, }, async validateArgs(args) { if (args.nodeCompat) { diff --git a/packages/wrangler/src/dev/hotkeys.ts b/packages/wrangler/src/dev/hotkeys.ts index 03dc9121f0..990fa9e7d3 100644 --- a/packages/wrangler/src/dev/hotkeys.ts +++ b/packages/wrangler/src/dev/hotkeys.ts @@ -7,7 +7,7 @@ import openInBrowser from "../open-in-browser"; import { debounce } from "../utils/debounce"; import { openInspector } from "./inspect"; import type { DevEnv } from "../api"; -import type { Tunnel } from "@cloudflare/workers-utils"; +import type { TunnelManager } from "../tunnel/dev"; export default function registerDevHotKeys( devEnvs: DevEnv[], @@ -15,13 +15,14 @@ export default function registerDevHotKeys( forceLocal?: boolean; remote: boolean; tunnel?: boolean; + tunnelName?: string; }, options: { render?: boolean; - getTunnel?: () => Tunnel | undefined; + tunnelManager?: TunnelManager; } = {} ) { - const { render = true, getTunnel } = options; + const { render = true, tunnelManager } = options; const primaryDevEnv = devEnvs[0]; const unregisterHotKeys = registerHotKeys( [ @@ -106,7 +107,7 @@ export default function registerDevHotKeys( { keys: ["l"], // Remote mode is not supported when using tunnels - disabled: () => args.forceLocal || args.tunnel, + disabled: () => args.forceLocal || !!tunnelManager?.isOpen(), handler: async () => { await primaryDevEnv.config.patch({ dev: { @@ -117,18 +118,31 @@ export default function registerDevHotKeys( }, }, { - // We remind users about the tunnel hotkey every 10mins - // Hiding this hotkey to reduce noise on startup keys: ["t"], - disabled: () => !args.tunnel, + label: () => + tunnelManager?.isOpen() ? "close tunnel" : "start tunnel", + disabled: () => primaryDevEnv.config.latestConfig?.dev?.remote === true, handler: async () => { - const tunnel = getTunnel?.(); + try { + if (!tunnelManager?.isOpen()) { + await tunnelManager?.start(true); + return; + } - if (!tunnel) { - return; + await tunnelManager.stop(); + } catch (error) { + logger.error( + error instanceof Error ? error.message : String(error) + ); } - - tunnel.extendExpiry(); + }, + }, + { + keys: ["a"], + label: "extend tunnel by 1 hour", + disabled: () => !tunnelManager?.isOpen(), + handler: async () => { + tunnelManager?.getTunnel()?.extendExpiry(); }, }, { diff --git a/packages/wrangler/src/dev/start-dev.ts b/packages/wrangler/src/dev/start-dev.ts index 40e02bce21..57d67b394c 100644 --- a/packages/wrangler/src/dev/start-dev.ts +++ b/packages/wrangler/src/dev/start-dev.ts @@ -1,9 +1,8 @@ import assert from "node:assert"; import path from "node:path"; -import { bold, dim, green } from "@cloudflare/cli-shared-helpers/colors"; +import { bold, green } from "@cloudflare/cli-shared-helpers/colors"; import { generateContainerBuildId } from "@cloudflare/containers-shared"; -import { getRegistryPath, startTunnel } from "@cloudflare/workers-utils"; -import chalk from "chalk"; +import { getRegistryPath } from "@cloudflare/workers-utils"; import dedent from "ts-dedent"; import { DevEnv } from "../api"; import { MultiworkerRuntimeController } from "../api/startDevWorker/MultiworkerRuntimeController"; @@ -14,6 +13,7 @@ import registerDevHotKeys from "../dev/hotkeys"; import isInteractive from "../is-interactive"; import { logger } from "../logger"; import { getSiteAssetPaths } from "../sites"; +import { TunnelManager } from "../tunnel/dev"; import { requireApiToken, requireAuth } from "../user"; import { collectKeyValues, @@ -24,14 +24,14 @@ import type { StartDevOptionsBindings } from "../api/startDevWorker/utils"; import type { StartDevOptions } from "../dev"; import type { EnablePagesAssetsServiceBindingOptions } from "../miniflare-cli/types"; import type { CfAccount } from "./create-worker-preview"; -import type { Config, Tunnel } from "@cloudflare/workers-utils"; +import type { Config } from "@cloudflare/workers-utils"; /** * Starts one (primary) or more (secondary) DevEnv environments given the `args`. */ export async function startDev(args: StartDevOptions) { let devEnv: DevEnv | DevEnv[] | undefined; - let tunnel: Tunnel | undefined; + let tunnelManager: TunnelManager | undefined; let unregisterHotKeys: (() => void) | undefined; try { if (args.logLevel) { @@ -51,7 +51,10 @@ export async function startDev(args: StartDevOptions) { unregisterHotKeys = registerDevHotKeys( Array.isArray(devEnv) ? devEnv : [devEnv], args, - { getTunnel: () => tunnel, render: false } + { + tunnelManager, + render: false, + } ); } } @@ -105,26 +108,24 @@ export async function startDev(args: StartDevOptions) { }) )) ); - if (isInteractive() && args.showInteractiveDevSession !== false) { - unregisterHotKeys = registerDevHotKeys(devEnv, args, { - getTunnel: () => tunnel, - }); - } } else { devEnv = new DevEnv(); await setupDevEnv(devEnv, args.config, authHook, args); - - if (isInteractive() && args.showInteractiveDevSession !== false) { - unregisterHotKeys = registerDevHotKeys([devEnv], args, { - getTunnel: () => tunnel, - }); - } } - const [primaryDevEnv, ...secondary] = Array.isArray(devEnv) - ? devEnv - : [devEnv]; + const devEnvs = Array.isArray(devEnv) ? devEnv : [devEnv]; + const [primaryDevEnv, ...secondary] = devEnvs; + + tunnelManager = new TunnelManager(primaryDevEnv, args); + + primaryDevEnv.on("teardown", () => { + tunnelManager?.getTunnel()?.dispose(); + }); + + if (isInteractive() && args.showInteractiveDevSession !== false) { + unregisterHotKeys = registerDevHotKeys(devEnvs, args, { tunnelManager }); + } // The ProxyWorker will have a stable host and port, so only listen for the first update void primaryDevEnv.proxy.ready.promise.then(({ url }) => { @@ -160,46 +161,7 @@ export async function startDev(args: StartDevOptions) { // The port is already resolved by ConfigController, so we can build the // origin URL now and let cloudflared connect as soon as the server binds. if (args.tunnel) { - const config = primaryDevEnv.config.latestConfig; - const protocol = config?.dev?.server?.secure ? "https" : "http"; - const hostname = config?.dev?.server?.hostname ?? "localhost"; - const port = config?.dev?.server?.port ?? 8787; - const origin = new URL( - `${protocol}://${formatHostname(hostname)}:${port}` - ); - - logger.log(dim("⎔ Starting tunnel (usually takes a few seconds)...")); - tunnel = startTunnel({ - origin, - extendHint: "Press [t] to extend by 1 hour.", - logger, - }); - - // Clean up tunnel on teardown - primaryDevEnv.on("teardown", () => { - tunnel?.dispose(); - }); - - // User requested tunnel sharing explicitly. If it fails, let it throw - // and prevent dev server from starting without a tunnel. - const { publicUrl } = await tunnel.ready(); - - logger.log( - `⬣ Sharing via Cloudflare Tunnel: ` + - `${chalk.green(publicUrl.origin)}\n` - ); - logger.warn( - chalk.dim("This URL is ") + - "publicly accessible" + - chalk.dim(". Anyone with it can:\n") + - chalk.dim( - "- Call ungated endpoints\n" + - "- Trigger logic that uses remote bindings\n" + - "- Reach internal services if your Worker proxies requests\n" + - "\n" + - "Consider using a named tunnel with Cloudflare Access to restrict access." - ) - ); + await tunnelManager.start(); } return { @@ -216,7 +178,7 @@ export async function startDev(args: StartDevOptions) { unregisterHotKeys?.(); })(), (async () => { - tunnel?.dispose(); + tunnelManager?.getTunnel()?.dispose(); })(), ]); throw e; @@ -308,7 +270,10 @@ async function setupDevEnv( // initialise with a random id containerBuildId: generateContainerBuildId(), generateTypes: args.types, - tunnel: args.tunnel, + tunnel: { + enabled: args.tunnel ?? false, + name: args.tunnelName, + }, }, legacy: { site: (configParam) => { diff --git a/packages/wrangler/src/pages/dev.ts b/packages/wrangler/src/pages/dev.ts index ec3498c4a3..f8208706bf 100644 --- a/packages/wrangler/src/pages/dev.ts +++ b/packages/wrangler/src/pages/dev.ts @@ -992,6 +992,7 @@ export const pagesDevCommand = createCommand({ tsconfig: undefined, minify: undefined, legacyEnv: undefined, + tunnelName: undefined, env: undefined, envFile: undefined, ip, diff --git a/packages/wrangler/src/preview/api.ts b/packages/wrangler/src/preview/api.ts index dbc6709ba4..95bc59e2c7 100644 --- a/packages/wrangler/src/preview/api.ts +++ b/packages/wrangler/src/preview/api.ts @@ -11,6 +11,7 @@ import type { export interface Binding { type: string; text?: string; + json?: unknown; namespace_id?: string; workflow_name?: string; destination_address?: string; diff --git a/packages/wrangler/src/preview/shared.ts b/packages/wrangler/src/preview/shared.ts index 7da6940cd9..dcd700ef11 100644 --- a/packages/wrangler/src/preview/shared.ts +++ b/packages/wrangler/src/preview/shared.ts @@ -80,6 +80,8 @@ export function getBindingValue(binding: Binding): string { switch (binding.type) { case "plain_text": return `"${binding.text}"`; + case "json": + return JSON.stringify(binding.json); case "secret_text": return "********"; case "kv_namespace": @@ -137,10 +139,18 @@ export function extractConfigBindings(config: Config): EnvBindings { const vars = previews?.vars ?? {}; for (const [name, value] of Object.entries(vars)) { - env[name] = { - type: "plain_text", - text: typeof value === "string" ? value : JSON.stringify(value), - }; + // Non-string vars (arrays/objects/numbers/booleans) need the `json` + // binding type so the Workers runtime parses them back into native JS + // values. Coercing them to plain_text via JSON.stringify makes + // `env[name]` a literal string at runtime, breaking any caller that + // expects the shape declared in wrangler.jsonc — `wrangler deploy` + // preserves the native shape via the `json` binding (see + // `deployment-bundle/create-worker-upload-form.ts`), so previews + // should match. + env[name] = + typeof value === "string" + ? { type: "plain_text", text: value } + : { type: "json", json: value }; } for (const kv of previews?.kv_namespaces ?? []) { @@ -160,10 +170,17 @@ export function extractConfigBindings(config: Config): EnvBindings { } for (const service of previews?.services ?? []) { + // `cross_account_grant` is internal/non-public-facing, so we access it + // through the runtime shape instead of the public type. + const crossAccountGrant = (service as { cross_account_grant?: string }) + .cross_account_grant; env[service.binding] = { type: "service", service: service.service, entrypoint: service.entrypoint, + ...(crossAccountGrant !== undefined && { + cross_account_grant: crossAccountGrant, + }), }; } @@ -314,6 +331,11 @@ export function extractConfigBindings(config: Config): EnvBindings { env[config.assets.binding] = { type: "assets" }; } + for (const binding of previews?.unsafe?.bindings ?? []) { + const { name, type, ...rest } = binding; + env[name] = { type, ...rest } as Binding; + } + return env; } diff --git a/packages/wrangler/src/tunnel/client.ts b/packages/wrangler/src/tunnel/client.ts index 73b57f8bb9..6aff294f9a 100644 --- a/packages/wrangler/src/tunnel/client.ts +++ b/packages/wrangler/src/tunnel/client.ts @@ -1,8 +1,24 @@ -import { UserError } from "@cloudflare/workers-utils"; +import { FatalError, UserError } from "@cloudflare/workers-utils"; import { Cloudflare as CloudflareSDK } from "cloudflare"; +import { createCloudflareClient } from "../cfetch/internal"; +import { requireAuth } from "../user"; +import type { Config } from "@cloudflare/workers-utils"; import type Cloudflare from "cloudflare"; import type { CloudflareTunnel } from "cloudflare/resources/shared"; -import type { CloudflaredCreateResponse } from "cloudflare/resources/zero-trust/tunnels/cloudflared"; +import type { + CloudflaredCreateResponse, + ConfigurationGetResponse, +} from "cloudflare/resources/zero-trust/tunnels/cloudflared"; + +const TUNNELS_DASHBOARD_URL = + "https://dash.cloudflare.com/?to=/:account/tunnels"; +const LOCAL_TUNNEL_HOSTNAMES = new Set([ + "localhost", + "127.0.0.1", + "::1", + "0.0.0.0", + "::", +]); /** * Error message for tunnel permission issues when using OAuth login. @@ -162,6 +178,165 @@ export async function getTunnelToken( }); } +/** + * Resolves the named tunnel to hostnames whose ingress rules + * target the current local dev origin and the token needed + * to start `cloudflared tunnel run`. + */ +export async function resolveNamedTunnel( + name: string, + origin: URL, + options: { + accountId: string | undefined; + complianceRegion: Config["compliance_region"]; + } +): Promise<{ + hostnames: string[]; + token: string; +}> { + let accountId = options.accountId; + + if (!accountId) { + // If no accountId is specified, prompt for login and to select an account + accountId = await requireAuth({ + account_id: options.accountId, + compliance_region: options.complianceRegion, + }); + } + + const sdk = createCloudflareClient({ + compliance_region: options.complianceRegion, + }); + const tunnel = await withTunnelErrorHandling(async () => { + for await (const item of sdk.zeroTrust.tunnels.cloudflared.list({ + account_id: accountId, + name, + is_deleted: false, + })) { + if (item.name === name) { + return normalizeTunnelResponse(item); + } + } + + return null; + }); + + if (!tunnel) { + throw new UserError( + `No Cloudflare Tunnel named "${name}" was found in this account. Use "wrangler tunnel list" to see available tunnels.`, + { telemetryMessage: "tunnel resolve named missing tunnel" } + ); + } + + const tunnelId = tunnel.id; + if (!tunnelId) { + throw new FatalError( + `Tunnel "${name}" was found but has no ID. This is unexpected.`, + { telemetryMessage: "tunnel resolve named missing tunnel id" } + ); + } + + const configuration = await withTunnelErrorHandling(() => + sdk.zeroTrust.tunnels.cloudflared.configurations.get(tunnelId, { + account_id: accountId, + }) + ); + const hostnames = getMatchingIngressHostnames( + origin, + configuration.config?.ingress ?? [] + ); + if (hostnames.length === 0) { + throw new UserError( + createMissingIngressMessage( + name, + origin, + configuration.config?.ingress ?? [] + ), + { telemetryMessage: "tunnel resolve named ingress mismatch" } + ); + } + + const token = await getTunnelToken(sdk, accountId, tunnelId); + + return { hostnames, token }; +} + +/** + * Return ingress hostnames whose configured service targets the current local dev origin. + */ +function getMatchingIngressHostnames( + origin: URL, + ingressConfig: ConfigurationGetResponse.Config.Ingress[] +): string[] { + const hostnames = new Set(); + const originUrl = normalizeURL(origin); + + for (const ingress of ingressConfig) { + try { + const serviceUrl = normalizeURL(ingress.service); + + if (ingress.hostname && serviceUrl.toString() === originUrl.toString()) { + hostnames.add(ingress.hostname); + } + } catch { + // Ignore invalid service URLs in ingress rules + } + } + + return [...hostnames]; +} + +function normalizeURL(url: URL | string): URL { + const normalizedUrl = new URL(url); + + if (LOCAL_TUNNEL_HOSTNAMES.has(normalizedUrl.hostname)) { + normalizedUrl.hostname = "localhost"; + } + + if (!normalizedUrl.port) { + switch (normalizedUrl.protocol) { + case "http:": + normalizedUrl.port = "80"; + break; + case "https:": + normalizedUrl.port = "443"; + break; + } + } + + return normalizedUrl; +} + +function createMissingIngressMessage( + name: string, + origin: URL, + ingress: ConfigurationGetResponse.Config.Ingress[] +): string { + if (ingress.length === 0) { + return [ + `Tunnel "${name}" has no ingress rules configured.`, + "", + `Add an ingress rule for ${origin} in the Cloudflare dashboard:`, + TUNNELS_DASHBOARD_URL, + "", + ].join("\n"); + } + + return [ + `No ingress rules in tunnel "${name}" route to ${origin}`, + "", + "Update the tunnel ingress rules in the Cloudflare dashboard:", + TUNNELS_DASHBOARD_URL, + "", + "Resolved ingress mappings:", + ...ingress.map( + ({ hostname, service }) => + ` - ${hostname ?? "(no hostname)"} -> ${service}` + ), + "", + ].join("\n"); +} + /** * Normalize tunnel response from SDK to consistent format. * The SDK returns a union type (CloudflareTunnel | TunnelWARPConnectorTunnel) diff --git a/packages/wrangler/src/tunnel/dev.ts b/packages/wrangler/src/tunnel/dev.ts new file mode 100644 index 0000000000..28df0de0de --- /dev/null +++ b/packages/wrangler/src/tunnel/dev.ts @@ -0,0 +1,169 @@ +import { dim } from "@cloudflare/cli-shared-helpers/colors"; +import { startTunnel } from "@cloudflare/workers-utils"; +import chalk from "chalk"; +import { formatHostname } from "../dev/start-dev"; +import { logger } from "../logger"; +import { resolveNamedTunnel } from "./client"; +import type { DevEnv } from "../api"; +import type { StartDevOptions } from "../dev"; +import type { Tunnel } from "@cloudflare/workers-utils"; + +export class TunnelManager { + private primaryDevEnv: DevEnv; + private args: StartDevOptions; + private tunnel: Tunnel | undefined; + + constructor(primaryDevEnv: DevEnv, args: StartDevOptions) { + this.primaryDevEnv = primaryDevEnv; + this.args = args; + } + + getTunnel(): Tunnel | undefined { + return this.tunnel; + } + + isOpen(): boolean { + return this.tunnel !== undefined; + } + + async start(shortcutPressed = false): Promise { + if (this.tunnel) { + return; + } + + logger.log(dim("⎔ Starting tunnel (usually takes a few seconds)...")); + + const origin = this.getTunnelOrigin(); + const tunnelConfig = this.getCurrentTunnelConfig(); + const isQuickTunnel = tunnelConfig?.name === undefined; + + logger.warn( + (isQuickTunnel + ? chalk.dim("Once connected, this tunnel will be ") + + "publicly accessible" + : chalk.dim( + "Once connected, this tunnel may be reachable from the internet" + )) + + chalk.dim(". Anyone who can reach it can:\n") + + chalk.dim( + "- Call ungated endpoints\n" + + "- Trigger logic that uses remote bindings\n" + + "- Reach internal services if your Worker proxies requests\n" + + "\n" + + (isQuickTunnel + ? "Consider using a named tunnel with Cloudflare Access to restrict access.\n" + : "Consider using Cloudflare Access to restrict access.\n") + ) + + (shortcutPressed ? "\nPress [t] again to close the tunnel." : "") + ); + + const namedTunnel = + tunnelConfig?.name !== undefined + ? await resolveNamedTunnel(tunnelConfig.name, origin, { + accountId: this.args.accountId, + complianceRegion: + this.primaryDevEnv.config.latestConfig?.complianceRegion, + }) + : undefined; + + const nextTunnel = startTunnel({ + origin, + token: namedTunnel?.token, + extendHint: "Press [a] to extend by 1 hour.", + logger, + }); + + this.tunnel = nextTunnel; + + try { + const result = await nextTunnel.ready(); + if (this.tunnel !== nextTunnel) { + return; + } + + await this.syncTunnelState(true); + this.logTunnelDetails(result, namedTunnel); + } catch (error) { + if (this.tunnel === nextTunnel) { + this.tunnel = undefined; + await this.syncTunnelState(false); + } + + throw error; + } + } + + async stop(): Promise { + if (!this.tunnel) { + return; + } + + logger.log(dim("⎔ Closing tunnel...")); + this.tunnel.dispose(); + this.tunnel = undefined; + await this.syncTunnelState(false); + logger.log("⬣ Tunnel closed"); + } + + private getCurrentTunnelConfig() { + return this.primaryDevEnv.config.latestConfig?.dev?.tunnel; + } + + private getTunnelOrigin() { + const config = this.primaryDevEnv.config.latestConfig; + const protocol = config?.dev?.server?.secure ? "https" : "http"; + const hostname = config?.dev?.server?.hostname ?? "localhost"; + const port = config?.dev?.server?.port ?? 8787; + + return new URL(`${protocol}://${formatHostname(hostname)}:${port}`); + } + + private async syncTunnelState(enabled: boolean) { + const latestDevConfig = this.primaryDevEnv.config.latestConfig?.dev; + + if ( + !latestDevConfig?.tunnel || + latestDevConfig.tunnel.enabled === enabled + ) { + return; + } + + await this.primaryDevEnv.config.patch({ + dev: { + ...latestDevConfig, + tunnel: { + ...latestDevConfig.tunnel, + enabled, + }, + }, + }); + } + + private logTunnelDetails( + result: Awaited>, + namedTunnel: + | { + hostnames: string[]; + token: string; + } + | undefined + ) { + const publicUrls = + result.mode === "quick" + ? [result.publicUrl.toString()] + : namedTunnel + ? namedTunnel.hostnames.map((hostname) => `https://${hostname}`) + : []; + + if (publicUrls.length === 1) { + logger.log( + `⬣ Sharing via Cloudflare Tunnel: ${chalk.green(publicUrls[0])}` + ); + } else if (publicUrls.length > 1) { + logger.log( + "⬣ Sharing via Cloudflare Tunnel:\n" + + publicUrls.map((url) => ` ${chalk.green(url)}`).join("\n") + ); + } + } +}