From 273f0023943bd9ab743b41c47d0ffba0ad0ea9db Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Thu, 22 Oct 2026 15:52:16 +0100 Subject: [PATCH 1/3] [vite-plugin] Use cf credentials for remote bindings Remote bindings fell back to Wrangler's credentials unless Vite was started by `cf dev`. Pass a cf auth hook, resolved from the project root, to the dev and preview remote proxy sessions, and seed a cf profile in the directory-bound profile e2e test. DEVX-2646 --- .../vite-plugin-remote-bindings-cf-auth.md | 7 ++++ .../e2e/remote-bindings.test.ts | 24 ++++++----- .../src/__tests__/auth.spec.ts | 41 +++++++++++++++++++ packages/vite-plugin-cloudflare/src/auth.ts | 40 +++++++++++++++++- .../src/miniflare-options.ts | 13 +++++- 5 files changed, 111 insertions(+), 14 deletions(-) create mode 100644 .changeset/vite-plugin-remote-bindings-cf-auth.md create mode 100644 packages/vite-plugin-cloudflare/src/__tests__/auth.spec.ts diff --git a/.changeset/vite-plugin-remote-bindings-cf-auth.md b/.changeset/vite-plugin-remote-bindings-cf-auth.md new file mode 100644 index 00000000000..a90bc1aa165 --- /dev/null +++ b/.changeset/vite-plugin-remote-bindings-cf-auth.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/vite-plugin": patch +--- + +Use cf credentials for remote bindings in `vite dev` and `vite preview` + +Remote bindings previously used cf credentials only when Vite was started by `cf dev`. Log in with `cf auth login` or set `CLOUDFLARE_API_TOKEN`. diff --git a/packages/vite-plugin-cloudflare/e2e/remote-bindings.test.ts b/packages/vite-plugin-cloudflare/e2e/remote-bindings.test.ts index 287240136b0..6682aa89eb5 100644 --- a/packages/vite-plugin-cloudflare/e2e/remote-bindings.test.ts +++ b/packages/vite-plugin-cloudflare/e2e/remote-bindings.test.ts @@ -204,30 +204,32 @@ describe.skipIf(isWindows)( test("uses directory-bound auth profile instead of triggering oauth", async ({ expect, }) => { - // Set up a fake HOME with a .wrangler global config directory containing: + // Set up a fake HOME with a cf global config directory containing: // - a named profile with a dummy oauth token // - a directory binding mapping the project path to that profile const fakeHome = path.join(projectPath, ".fake-home"); - const wranglerDir = path.join(fakeHome, ".wrangler"); + const xdgConfigHome = path.join(fakeHome, ".config"); + const cfDir = path.join(xdgConfigHome, "cloudflare"); - mkdirSync(path.join(wranglerDir, "config"), { recursive: true }); + mkdirSync(path.join(cfDir, "config"), { recursive: true }); writeFileSync( - path.join(wranglerDir, "config", "e2e-test-profile.toml"), - [ - 'oauth_token = "fake-oauth-token-for-e2e-test"', - 'refresh_token = "fake-refresh-token"', - `expiration_time = "2099-01-01T00:00:00+00:00"`, - ].join("\n") + path.join(cfDir, "config", "e2e-test-profile.json"), + JSON.stringify({ + oauth_token: "fake-oauth-token-for-e2e-test", + refresh_token: "fake-refresh-token", + expiration_time: "2099-01-01T00:00:00+00:00", + }) ); - mkdirSync(path.join(wranglerDir, "profiles"), { recursive: true }); + mkdirSync(path.join(cfDir, "profiles"), { recursive: true }); writeFileSync( - path.join(wranglerDir, "profiles", "directory-bindings.json"), + path.join(cfDir, "profiles", "directory-bindings.json"), JSON.stringify({ [projectPath]: "e2e-test-profile" }) ); const proc = await runLongLived("pnpm", "dev", projectPath, { HOME: fakeHome, + XDG_CONFIG_HOME: xdgConfigHome, CLOUDFLARE_API_TOKEN: undefined, CLOUDFLARE_API_KEY: undefined, CLOUDFLARE_EMAIL: undefined, diff --git a/packages/vite-plugin-cloudflare/src/__tests__/auth.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/auth.spec.ts new file mode 100644 index 00000000000..4a089f67fd9 --- /dev/null +++ b/packages/vite-plugin-cloudflare/src/__tests__/auth.spec.ts @@ -0,0 +1,41 @@ +import assert from "node:assert"; +import { createLogger as createViteLogger } from "vite"; +import { test, vi } from "vitest"; +import { createLogger, getRemoteBindingsAuth } from "../auth"; + +const mocks = vi.hoisted(() => ({ + cfAuth: { + setProfile: vi.fn(), + requireAuth: vi.fn().mockResolvedValue("cf-account-id"), + requireApiToken: vi.fn().mockReturnValue({ apiToken: "cf-token" }), + }, + resolveProfile: vi.fn().mockReturnValue("cf-profile"), +})); + +vi.mock("@cloudflare/workers-auth/cf", () => ({ + createCfAuth: () => mocks.cfAuth, + createCfProfileStore: () => ({ resolve: mocks.resolveProfile }), +})); + +test("remote bindings auth uses the cf profile for the project", async ({ + expect, +}) => { + const logger = createLogger(createViteLogger("silent")); + const auth = getRemoteBindingsAuth("/project", "account-id", logger); + assert(typeof auth === "function"); + + await expect(auth()).resolves.toEqual({ + accountId: "cf-account-id", + apiToken: { apiToken: "cf-token" }, + }); + expect(mocks.resolveProfile).toHaveBeenCalledWith({ cwd: "/project" }); + expect(mocks.cfAuth.setProfile).toHaveBeenCalledWith("cf-profile"); + expect(mocks.cfAuth.requireAuth).toHaveBeenCalledWith({ + account_id: "account-id", + }); + // A different hook would make the next options update restart the session + expect(getRemoteBindingsAuth("/project", "account-id", logger)).toBe(auth); + expect(getRemoteBindingsAuth("/project", "other-account", logger)).not.toBe( + auth + ); +}); diff --git a/packages/vite-plugin-cloudflare/src/auth.ts b/packages/vite-plugin-cloudflare/src/auth.ts index fa1f5d76422..80d82aed787 100644 --- a/packages/vite-plugin-cloudflare/src/auth.ts +++ b/packages/vite-plugin-cloudflare/src/auth.ts @@ -7,7 +7,8 @@ import { } from "@cloudflare/workers-auth/cf"; import { isNonInteractiveOrCI } from "@cloudflare/workers-utils"; import { debuglog } from "./utils"; -import type { Logger } from "@cloudflare/workers-utils"; +import type { CloudflareAuth } from "@cloudflare/workers-auth/cf"; +import type { AsyncHook, CfAccount, Logger } from "@cloudflare/workers-utils"; import type * as vite from "vite"; class AccountSelectionUnavailable extends Error { @@ -69,6 +70,43 @@ export function createAuth(profileDir: string, logger: Logger) { return auth; } +const remoteBindingsAuthHooks = new Map>(); + +/** + * Get the cf auth hook for remote bindings sessions. + * + * Hooks are cached because `maybeStartOrUpdateRemoteProxySession()` restarts + * the session whenever it receives a different hook. + * + * @param profileDir - Directory used to resolve the cf auth profile. + * @param accountId - The configured account ID, if any. + * @param logger - Logger for the auth flow. + * @returns A hook resolving the account ID and API token for the session. + */ +export function getRemoteBindingsAuth( + profileDir: string, + accountId: string | undefined, + logger: Logger +): AsyncHook { + const key = JSON.stringify([profileDir, accountId]); + let hook = remoteBindingsAuthHooks.get(key); + if (hook === undefined) { + let auth: CloudflareAuth | undefined; + hook = async () => { + // Only resolve the profile once a session actually needs credentials + auth ??= createAuth(profileDir, logger); + return { + accountId: await auth.requireAuth( + accountId ? { account_id: accountId } : {} + ), + apiToken: auth.requireApiToken(), + }; + }; + remoteBindingsAuthHooks.set(key, hook); + } + return hook; +} + /** Adapt Vite's logger to the shared Cloudflare API logger interface. */ export function createLogger(logger: vite.Logger): Logger { return { diff --git a/packages/vite-plugin-cloudflare/src/miniflare-options.ts b/packages/vite-plugin-cloudflare/src/miniflare-options.ts index b2ec8bb606f..bd18100abdf 100644 --- a/packages/vite-plugin-cloudflare/src/miniflare-options.ts +++ b/packages/vite-plugin-cloudflare/src/miniflare-options.ts @@ -28,6 +28,7 @@ import { Response as MiniflareResponse, } from "miniflare"; import { getAssetsConfig } from "./asset-config"; +import { createLogger, getRemoteBindingsAuth } from "./auth"; import { ASSET_WORKER_NAME, kRequestType, @@ -527,7 +528,11 @@ export async function getDevMiniflareOptions( profileDir: resolvedViteConfig.root, }, preExistingRemoteProxySession ?? null, - undefined, + getRemoteBindingsAuth( + resolvedViteConfig.root, + settings.accountId, + createLogger(viteDevServer.config.logger) + ), { cliDisplayName: resolvedPluginConfig.cliDisplayName, logger: createRemoteBindingsLogger( @@ -853,7 +858,11 @@ export async function getPreviewMiniflareOptions( profileDir: resolvedViteConfig.root, }, preExistingRemoteProxySessionData ?? null, - undefined, + getRemoteBindingsAuth( + resolvedViteConfig.root, + resolvedPluginConfig.settings.accountId, + createLogger(vitePreviewServer.config.logger) + ), { cliDisplayName: resolvedPluginConfig.cliDisplayName, logger: createRemoteBindingsLogger( From 724bbc961fd79ca70b3cf12e86fe5a8da6874238 Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Thu, 22 Oct 2026 16:02:57 +0100 Subject: [PATCH 2/3] Clarify when to log in in the changeset --- .changeset/vite-plugin-remote-bindings-cf-auth.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/vite-plugin-remote-bindings-cf-auth.md b/.changeset/vite-plugin-remote-bindings-cf-auth.md index a90bc1aa165..3634434fd25 100644 --- a/.changeset/vite-plugin-remote-bindings-cf-auth.md +++ b/.changeset/vite-plugin-remote-bindings-cf-auth.md @@ -4,4 +4,4 @@ Use cf credentials for remote bindings in `vite dev` and `vite preview` -Remote bindings previously used cf credentials only when Vite was started by `cf dev`. Log in with `cf auth login` or set `CLOUDFLARE_API_TOKEN`. +Remote bindings previously used cf credentials only when Vite was started by `cf dev`. If remote bindings report that you aren't authenticated, run `cf auth login` or set `CLOUDFLARE_API_TOKEN`. From 9b2f41c496a3f549e76b6dff7e4bc56da8c79446 Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Thu, 22 Oct 2026 17:11:24 +0100 Subject: [PATCH 3/3] Keep remote bindings auth hooks in the plugin's shared context --- .../vite-plugin-cloudflare/src/__tests__/auth.spec.ts | 10 +++++++--- packages/vite-plugin-cloudflare/src/auth.ts | 8 ++++---- packages/vite-plugin-cloudflare/src/context.ts | 8 ++++++++ .../vite-plugin-cloudflare/src/miniflare-options.ts | 2 ++ 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/vite-plugin-cloudflare/src/__tests__/auth.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/auth.spec.ts index 4a089f67fd9..80b99761b2e 100644 --- a/packages/vite-plugin-cloudflare/src/__tests__/auth.spec.ts +++ b/packages/vite-plugin-cloudflare/src/__tests__/auth.spec.ts @@ -2,6 +2,7 @@ import assert from "node:assert"; import { createLogger as createViteLogger } from "vite"; import { test, vi } from "vitest"; import { createLogger, getRemoteBindingsAuth } from "../auth"; +import type { AsyncHook, CfAccount } from "@cloudflare/workers-utils"; const mocks = vi.hoisted(() => ({ cfAuth: { @@ -20,8 +21,9 @@ vi.mock("@cloudflare/workers-auth/cf", () => ({ test("remote bindings auth uses the cf profile for the project", async ({ expect, }) => { + const hooks = new Map>(); const logger = createLogger(createViteLogger("silent")); - const auth = getRemoteBindingsAuth("/project", "account-id", logger); + const auth = getRemoteBindingsAuth(hooks, "/project", "account-id", logger); assert(typeof auth === "function"); await expect(auth()).resolves.toEqual({ @@ -34,8 +36,10 @@ test("remote bindings auth uses the cf profile for the project", async ({ account_id: "account-id", }); // A different hook would make the next options update restart the session - expect(getRemoteBindingsAuth("/project", "account-id", logger)).toBe(auth); - expect(getRemoteBindingsAuth("/project", "other-account", logger)).not.toBe( + expect(getRemoteBindingsAuth(hooks, "/project", "account-id", logger)).toBe( auth ); + expect( + getRemoteBindingsAuth(hooks, "/project", "other-account", logger) + ).not.toBe(auth); }); diff --git a/packages/vite-plugin-cloudflare/src/auth.ts b/packages/vite-plugin-cloudflare/src/auth.ts index 80d82aed787..43693341ffb 100644 --- a/packages/vite-plugin-cloudflare/src/auth.ts +++ b/packages/vite-plugin-cloudflare/src/auth.ts @@ -70,26 +70,26 @@ export function createAuth(profileDir: string, logger: Logger) { return auth; } -const remoteBindingsAuthHooks = new Map>(); - /** * Get the cf auth hook for remote bindings sessions. * * Hooks are cached because `maybeStartOrUpdateRemoteProxySession()` restarts * the session whenever it receives a different hook. * + * @param hooks - Hook cache that persists across Vite server restarts. * @param profileDir - Directory used to resolve the cf auth profile. * @param accountId - The configured account ID, if any. * @param logger - Logger for the auth flow. * @returns A hook resolving the account ID and API token for the session. */ export function getRemoteBindingsAuth( + hooks: Map>, profileDir: string, accountId: string | undefined, logger: Logger ): AsyncHook { const key = JSON.stringify([profileDir, accountId]); - let hook = remoteBindingsAuthHooks.get(key); + let hook = hooks.get(key); if (hook === undefined) { let auth: CloudflareAuth | undefined; hook = async () => { @@ -102,7 +102,7 @@ export function getRemoteBindingsAuth( apiToken: auth.requireApiToken(), }; }; - remoteBindingsAuthHooks.set(key, hook); + hooks.set(key, hook); } return hook; } diff --git a/packages/vite-plugin-cloudflare/src/context.ts b/packages/vite-plugin-cloudflare/src/context.ts index 2198b46b141..53cdcb14742 100644 --- a/packages/vite-plugin-cloudflare/src/context.ts +++ b/packages/vite-plugin-cloudflare/src/context.ts @@ -18,6 +18,7 @@ import type { ParsedInputWorkerConfig, ParsedOutputWorkerConfig, } from "@cloudflare/config"; +import type { AsyncHook, CfAccount } from "@cloudflare/workers-utils"; import type { MiniflareOptions } from "miniflare"; import type * as vite from "vite"; @@ -34,6 +35,8 @@ export interface SharedContext { configChangeCoordinator?: ConfigChangeCoordinator; /** Allowed hostnames for tunnel connections */ tunnelHostnames: Set; + /** Remote bindings auth hooks, reused so remote proxy sessions are not restarted */ + remoteBindingsAuthHooks?: Map>; } /** An explicitly typed additional module emitted by a Vite environment. */ @@ -118,6 +121,11 @@ export class PluginContext { return this.#sharedContext.workerNameToExportTypesMap; } + get remoteBindingsAuthHooks(): Map> { + this.#sharedContext.remoteBindingsAuthHooks ??= new Map(); + return this.#sharedContext.remoteBindingsAuthHooks; + } + beginRestartingDevServer(): void { this.#sharedContext.restartingDevServerCount++; } diff --git a/packages/vite-plugin-cloudflare/src/miniflare-options.ts b/packages/vite-plugin-cloudflare/src/miniflare-options.ts index bd18100abdf..c711e1c677d 100644 --- a/packages/vite-plugin-cloudflare/src/miniflare-options.ts +++ b/packages/vite-plugin-cloudflare/src/miniflare-options.ts @@ -529,6 +529,7 @@ export async function getDevMiniflareOptions( }, preExistingRemoteProxySession ?? null, getRemoteBindingsAuth( + ctx.remoteBindingsAuthHooks, resolvedViteConfig.root, settings.accountId, createLogger(viteDevServer.config.logger) @@ -859,6 +860,7 @@ export async function getPreviewMiniflareOptions( }, preExistingRemoteProxySessionData ?? null, getRemoteBindingsAuth( + ctx.remoteBindingsAuthHooks, resolvedViteConfig.root, resolvedPluginConfig.settings.accountId, createLogger(vitePreviewServer.config.logger)