diff --git a/.changeset/quiet-rivers-remote-bindings.md b/.changeset/quiet-rivers-remote-bindings.md new file mode 100644 index 0000000000..fbbbde4425 --- /dev/null +++ b/.changeset/quiet-rivers-remote-bindings.md @@ -0,0 +1,11 @@ +--- +"@cloudflare/remote-bindings": minor +--- + +Add `@cloudflare/remote-bindings`, a standalone package that powers remote bindings during local development without depending on `wrangler`. + +It exposes `startRemoteProxySession`, `maybeStartOrUpdateRemoteProxySession`, and `pickRemoteBindings`, replacing the previous DevEnv-based proxy with a lightweight, direct edge-preview API client plus a minimal Node.js HTTP/WebSocket proxy. + +Auth is customisable: by default `createEnvAuthResolver` reads `CLOUDFLARE_*` credentials or refreshes the stored OAuth token discovered via the environment, so a top-level CLI can delegate remote bindings down a `cf dev → vite dev → @cloudflare/remote-bindings` chain and have tokens refreshed mid-run. Everything needed is configurable through environment variables: `CLOUDFLARE_CONFIG_DIR` / `CLOUDFLARE_AUTH_CONFIG_FILE` (token location and format, including JSON/JSONC), `CLOUDFLARE_OAUTH_CLIENT_ID` (the app to refresh with), `CLOUDFLARE_ALLOW_GLOBAL_API_KEY`, and `CLOUDFLARE_LOGIN_COMMAND`. Consumers calling the package directly can also inject their own credentials, storage backend, client ID, or `loginHint`. + +When no credentials are available and no stored token can be refreshed (e.g. the user hasn't logged in yet), the resolver fails fast with a clear, CLI-agnostic message — it never attempts an interactive login, since the top-level CLI owns authentication. diff --git a/.changeset/spotty-meadows-vite-remote.md b/.changeset/spotty-meadows-vite-remote.md new file mode 100644 index 0000000000..e8c56d7211 --- /dev/null +++ b/.changeset/spotty-meadows-vite-remote.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/vite-plugin": patch +--- + +Source remote bindings directly from `@cloudflare/remote-bindings` instead of going through `wrangler`. + +This removes the plugin's reliance on `wrangler` for establishing remote binding proxy sessions and enables environment-variable-driven auth discovery, so remote bindings keep working (and refresh their OAuth token) when the plugin is driven by a top-level CLI. diff --git a/.changeset/wise-lions-wrangler-remote.md b/.changeset/wise-lions-wrangler-remote.md new file mode 100644 index 0000000000..fe3fa50d3c --- /dev/null +++ b/.changeset/wise-lions-wrangler-remote.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +Establish remote binding proxy sessions via the new `@cloudflare/remote-bindings` package. + +`wrangler dev`'s remote bindings now use a lightweight direct edge-preview client and a minimal local proxy instead of spinning up a full dev environment, while preserving wrangler's existing auth resolution and error reporting. There is no change to user-facing behaviour or configuration. diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index a004f3dc2e..802857d6f0 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -95,6 +95,9 @@ "!packages/create-cloudflare/**/templates*/**/c3.ts", // TODO: we really should be linting this folder "packages/wrangler/templates/**", + // Worker template bundled into the package at build time; mirrors the + // (ignored) wrangler ProxyServerWorker template. + "packages/remote-bindings/templates/**", // Ignore generated files "packages/containers-shared/src/client/**", "packages/local-explorer-ui/src/api/generated/**", diff --git a/packages/remote-bindings/package.json b/packages/remote-bindings/package.json new file mode 100644 index 0000000000..6c60284751 --- /dev/null +++ b/packages/remote-bindings/package.json @@ -0,0 +1,61 @@ +{ + "name": "@cloudflare/remote-bindings", + "version": "0.0.0", + "description": "Remote bindings support for Cloudflare Workers local development", + "homepage": "https://github.com/cloudflare/workers-sdk/tree/main/packages/remote-bindings#readme", + "bugs": { + "url": "https://github.com/cloudflare/workers-sdk/issues" + }, + "license": "MIT OR Apache-2.0", + "author": "workers-devprod@cloudflare.com", + "repository": { + "type": "git", + "url": "https://github.com/cloudflare/workers-sdk.git", + "directory": "packages/remote-bindings" + }, + "files": [ + "dist" + ], + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "scripts": { + "build": "tsdown", + "check:type": "tsc -p ./tsconfig.json", + "test": "vitest", + "test:ci": "vitest run" + }, + "dependencies": { + "@cloudflare/deploy-helpers": "workspace:*", + "@cloudflare/workers-auth": "workspace:*", + "@cloudflare/workers-utils": "workspace:*", + "undici": "catalog:default" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@types/node": "catalog:default", + "capnweb": "catalog:default", + "esbuild": "catalog:default", + "get-port": "^7.0.0", + "tsdown": "0.16.3", + "typescript": "catalog:default", + "vitest": "catalog:default" + }, + "peerDependencies": { + "miniflare": "workspace:*" + }, + "engines": { + "node": ">20.0.0" + }, + "volta": { + "extends": "../../package.json" + }, + "workers-sdk": { + "prerelease": true + } +} diff --git a/packages/remote-bindings/scripts/deps.ts b/packages/remote-bindings/scripts/deps.ts new file mode 100644 index 0000000000..81e0980eae --- /dev/null +++ b/packages/remote-bindings/scripts/deps.ts @@ -0,0 +1,15 @@ +/** + * Dependencies that _are not_ bundled along with @cloudflare/remote-bindings. + * + * These must be explicitly documented with a reason why they cannot be bundled. + * This list is validated by `tools/deployments/validate-package-dependencies.ts`. + */ +export const EXTERNAL_DEPENDENCIES = [ + // Bundling `undici` would produce a duplicate copy in every downstream + // consumer that already depends on undici (e.g. wrangler), which breaks + // `instanceof Request`/`Response`/`Headers` checks across the boundary and + // prevents the local proxy from interoperating with the consumer's undici. + // Keeping it external lets the package manager deduplicate undici to a + // single shared instance. + "undici", +]; diff --git a/packages/remote-bindings/src/api/fetch.ts b/packages/remote-bindings/src/api/fetch.ts new file mode 100644 index 0000000000..27be227891 --- /dev/null +++ b/packages/remote-bindings/src/api/fetch.ts @@ -0,0 +1,39 @@ +import { fetchResultBase } from "@cloudflare/workers-utils"; +import type { Logger } from "../logger"; +import type { AuthCredentials } from "../types"; +import type { ComplianceConfig } from "@cloudflare/workers-utils"; +import type { RequestInit } from "undici"; + +/** Identifies remote-bindings requests to the Cloudflare API. */ +const USER_AGENT = "@cloudflare/remote-bindings"; + +function toComplianceConfig(region: string | undefined): ComplianceConfig { + return { compliance_region: region as ComplianceConfig["compliance_region"] }; +} + +/** + * Make an authenticated Cloudflare API request and return its `result`. + * + * Thin wrapper over workers-utils' shared API client (`fetchResultBase`), so we + * don't duplicate authorization headers, base-URL / compliance-region handling, + * or error parsing. + */ +export async function fetchResult( + auth: AuthCredentials, + resource: string, + init: RequestInit | undefined, + complianceRegion: string | undefined, + logger: Logger, + abortSignal?: AbortSignal +): Promise { + return fetchResultBase( + toComplianceConfig(complianceRegion), + resource, + init ?? {}, + USER_AGENT, + logger, + undefined, + abortSignal, + auth.apiToken + ); +} diff --git a/packages/remote-bindings/src/api/preview-session.ts b/packages/remote-bindings/src/api/preview-session.ts new file mode 100644 index 0000000000..c102c9c4ac --- /dev/null +++ b/packages/remote-bindings/src/api/preview-session.ts @@ -0,0 +1,97 @@ +import crypto from "node:crypto"; +import { getAccessHeaders } from "@cloudflare/workers-auth"; +import { fetch } from "undici"; +import { fetchResult } from "./fetch"; +import { getWorkersDevSubdomain } from "./subdomain"; +import type { Logger } from "../logger"; +import type { AuthCredentials, PreviewSession } from "../types"; + +/** + * Try to exchange a session token via the exchange URL. + * + * The edge-preview API may return an `exchange_url` alongside the session + * token. When present, we must fetch it to get a re-encoded token. This + * handles various edge configurations (including Cloudflare Access-protected + * preview domains). + * + * Returns null if the exchange fails for any reason — the caller should + * fall back to the raw token. + */ +async function tryExchangeToken( + exchangeUrl: string, + logger: Logger, + abortSignal?: AbortSignal +): Promise { + try { + const url = new URL(exchangeUrl); + + // Attach Cloudflare Access headers when the exchange domain is + // Access-protected (e.g. staging). Resolved via `@cloudflare/workers-auth` + // so the same Access handling is shared with wrangler. + const headers = await getAccessHeaders(url.hostname, { + logger, + isNonInteractiveOrCI: () => true, + }); + + const response = await fetch(url, { + signal: abortSignal, + headers, + }); + + if (!response.ok) { + return null; + } + + const body = (await response.json()) as { token?: string }; + if (typeof body?.token !== "string") { + return null; + } + return body.token; + } catch (e) { + if (e instanceof Error && e.name === "AbortError") { + throw e; + } + return null; + } +} + +/** + * Create an edge-preview session. This is the first of two API calls needed + * to set up a remote preview — it creates a session token that's used when + * uploading the worker. + * + * If the API returns an `exchange_url`, we perform the token exchange to get + * a re-encoded token. Falls back to the raw token if exchange fails. + */ +export async function createPreviewSession( + auth: AuthCredentials, + name: string | undefined, + complianceRegion: string | undefined, + logger: Logger, + abortSignal?: AbortSignal +): Promise { + const initUrl = `/accounts/${auth.accountId}/workers/subdomain/edge-preview`; + + const { token, exchange_url } = await fetchResult<{ + token: string; + exchange_url?: string; + }>(auth, initUrl, undefined, complianceRegion, logger, abortSignal); + + // Exchange the token if the API tells us to + const sessionToken = exchange_url + ? ((await tryExchangeToken(exchange_url, logger, abortSignal)) ?? token) + : token; + + const subdomain = await getWorkersDevSubdomain( + auth, + complianceRegion, + logger + ); + const host = `${name ?? crypto.randomUUID()}.${subdomain}`; + + return { + value: sessionToken, + host, + name, + }; +} diff --git a/packages/remote-bindings/src/api/preview-token.ts b/packages/remote-bindings/src/api/preview-token.ts new file mode 100644 index 0000000000..e1cc3ea19f --- /dev/null +++ b/packages/remote-bindings/src/api/preview-token.ts @@ -0,0 +1,53 @@ +import { fetchResult } from "./fetch"; +import { createProxyWorkerUploadForm } from "./upload-form"; +import type { Logger } from "../logger"; +import type { AuthCredentials, PreviewSession, PreviewToken } from "../types"; +import type { Binding } from "@cloudflare/workers-utils"; + +/** + * Upload the ProxyServerWorker to the edge-preview API and get a preview token. + * This is the second of two API calls needed to set up a remote preview. + * + * The worker is uploaded with `minimal_mode: true`, which tells the edge to + * provide raw bindings (pass-through to the real resources). + */ +export async function createPreviewToken( + auth: AuthCredentials, + session: PreviewSession, + bindings: Record, + workerName: string, + complianceRegion: string | undefined, + logger: Logger, + abortSignal?: AbortSignal +): Promise { + const url = `/accounts/${auth.accountId}/workers/scripts/${encodeURIComponent(workerName)}/edge-preview`; + + const formData = createProxyWorkerUploadForm(bindings); + formData.set( + "wrangler-session-config", + JSON.stringify({ workers_dev: true, minimal_mode: true }) + ); + + const { preview_token } = await fetchResult<{ + preview_token: string; + tail_url: string; + }>( + auth, + url, + { + method: "POST", + body: formData, + headers: { + "cf-preview-upload-config-token": session.value, + }, + }, + complianceRegion, + logger, + abortSignal + ); + + return { + value: preview_token, + host: session.host, + }; +} diff --git a/packages/remote-bindings/src/api/subdomain.ts b/packages/remote-bindings/src/api/subdomain.ts new file mode 100644 index 0000000000..9d4c965b09 --- /dev/null +++ b/packages/remote-bindings/src/api/subdomain.ts @@ -0,0 +1,21 @@ +import { fetchResult } from "./fetch"; +import type { Logger } from "../logger"; +import type { AuthCredentials } from "../types"; + +/** + * Get the workers.dev subdomain for an account. + */ +export async function getWorkersDevSubdomain( + auth: AuthCredentials, + complianceRegion: string | undefined, + logger: Logger +): Promise { + const result = await fetchResult<{ subdomain: string }>( + auth, + `/accounts/${auth.accountId}/workers/subdomain`, + undefined, + complianceRegion, + logger + ); + return result.subdomain; +} diff --git a/packages/remote-bindings/src/api/upload-form.ts b/packages/remote-bindings/src/api/upload-form.ts new file mode 100644 index 0000000000..16d588d043 --- /dev/null +++ b/packages/remote-bindings/src/api/upload-form.ts @@ -0,0 +1,51 @@ +import { createWorkerUploadForm } from "@cloudflare/deploy-helpers"; +import proxyServerWorkerScript from "virtual:proxy-server-worker"; +import type { Binding } from "@cloudflare/workers-utils"; +import type { FormData } from "undici"; + +/** + * Create a FormData upload for the ProxyServerWorker with the given bindings. + * + * Reuses the canonical `createWorkerUploadForm` (from `@cloudflare/deploy-helpers`, + * which has no dependency on wrangler) so every remote binding type is serialised + * identically to a real deploy. The worker itself is a single pre-bundled ES + * module, and all bindings are marked `raw` so the edge gives the proxy worker + * direct, pass-through access to the real resources. + */ +export function createProxyWorkerUploadForm( + bindings: Record +): FormData { + const rawBindings: Record = {}; + for (const [name, binding] of Object.entries(bindings)) { + rawBindings[name] = { ...binding, raw: true } as Binding; + } + + return createWorkerUploadForm( + { + name: "remote-bindings-proxy", + main: { + name: "ProxyServerWorker.mjs", + filePath: "ProxyServerWorker.mjs", + type: "esm", + content: proxyServerWorkerScript, + }, + modules: [], + migrations: undefined, + compatibility_date: "2025-04-28", + compatibility_flags: [], + keepVars: undefined, + keepSecrets: undefined, + keepBindings: undefined, + logpush: undefined, + sourceMaps: undefined, + placement: undefined, + tail_consumers: undefined, + limits: undefined, + assets: undefined, + containers: undefined, + observability: undefined, + cache: undefined, + }, + rawBindings + ); +} diff --git a/packages/remote-bindings/src/auth.ts b/packages/remote-bindings/src/auth.ts new file mode 100644 index 0000000000..261a0d5bad --- /dev/null +++ b/packages/remote-bindings/src/auth.ts @@ -0,0 +1,51 @@ +import { createEnvApiTokenResolver } from "@cloudflare/workers-auth"; +import { getEnvironmentVariableFactory } from "@cloudflare/workers-utils"; +import type { AuthCredentials } from "./types"; +import type { EnvApiTokenResolverOptions } from "@cloudflare/workers-auth"; + +/** + * `CLOUDFLARE_ACCOUNT_ID` (legacy alias `CF_ACCOUNT_ID`) — the account whose + * edge-preview endpoints the remote proxy talks to. + */ +const getAccountIdFromEnv = getEnvironmentVariableFactory({ + variableName: "CLOUDFLARE_ACCOUNT_ID", + deprecatedName: "CF_ACCOUNT_ID", +}); + +export interface EnvAuthResolverOptions extends EnvApiTokenResolverOptions { + /** Account ID hint. Falls back to `CLOUDFLARE_ACCOUNT_ID` when unset. */ + accountId?: string; +} + +/** + * Build an auth resolver driven entirely by the environment. + * + * This is what makes `@cloudflare/remote-bindings` usable deep in a + * `cf dev → vite dev → remote-bindings` chain without wrangler. Token + * resolution (env credentials, or the stored OAuth token refreshed on expiry) + * is delegated to `@cloudflare/workers-auth`'s `createEnvApiTokenResolver`; this + * adds the account ID to produce the {@link AuthCredentials} the edge-preview + * API needs. + * + * The returned function is invoked fresh on every API request, so a token + * rotated on disk is always honoured. + */ +export function createEnvAuthResolver( + options: EnvAuthResolverOptions = {} +): () => Promise { + const resolveApiToken = createEnvApiTokenResolver(options); + + return async (): Promise => { + const apiToken = await resolveApiToken(); + + const accountId = options.accountId ?? getAccountIdFromEnv(); + if (!accountId) { + throw new Error( + "Unable to determine the Cloudflare account ID for remote bindings. " + + "Set CLOUDFLARE_ACCOUNT_ID or provide an `accountId`/`auth`." + ); + } + + return { accountId, apiToken }; + }; +} diff --git a/packages/remote-bindings/src/index.ts b/packages/remote-bindings/src/index.ts new file mode 100644 index 0000000000..77e240831a --- /dev/null +++ b/packages/remote-bindings/src/index.ts @@ -0,0 +1,21 @@ +export { pickRemoteBindings } from "./pick-remote-bindings"; +export { startRemoteProxySession } from "./start-session"; +export { maybeStartOrUpdateRemoteProxySession } from "./maybe-start-session"; +export type { + MaybeStartOrUpdateRemoteProxySessionOptions, + RemoteProxySessionData, + RemoteProxyWorker, +} from "./maybe-start-session"; +export { createEnvAuthResolver } from "./auth"; +export type { EnvAuthResolverOptions } from "./auth"; +export type { Logger } from "./logger"; +export type { + ApiCredentials, + AuthCredentials, + Binding, + PreviewSession, + PreviewToken, + RemoteProxyConnectionString, + RemoteProxySession, + StartRemoteProxySessionOptions, +} from "./types"; diff --git a/packages/remote-bindings/src/logger.ts b/packages/remote-bindings/src/logger.ts new file mode 100644 index 0000000000..1053a0b047 --- /dev/null +++ b/packages/remote-bindings/src/logger.ts @@ -0,0 +1,25 @@ +import type { OAuthFlowLogger } from "@cloudflare/workers-auth"; + +/** + * The logger surface used internally by the package. Matches + * `@cloudflare/workers-auth`'s `OAuthFlowLogger` so it can be passed straight + * through to the OAuth flow and Access detection. + */ +export type Logger = OAuthFlowLogger; + +const noop = (): void => {}; + +/** + * Normalise a partial logger (callers often pass only `debug`, or wrangler's + * full `logger` singleton) into a complete {@link Logger}, defaulting any + * missing methods to no-ops. + */ +export function normalizeLogger(logger?: Partial): Logger { + return { + debug: logger?.debug?.bind(logger) ?? noop, + info: logger?.info?.bind(logger) ?? noop, + log: logger?.log?.bind(logger) ?? noop, + warn: logger?.warn?.bind(logger) ?? noop, + error: logger?.error?.bind(logger) ?? noop, + }; +} diff --git a/packages/remote-bindings/src/maybe-start-session.ts b/packages/remote-bindings/src/maybe-start-session.ts new file mode 100644 index 0000000000..66b5ae814c --- /dev/null +++ b/packages/remote-bindings/src/maybe-start-session.ts @@ -0,0 +1,98 @@ +import assert from "node:assert"; +import { pickRemoteBindings } from "./pick-remote-bindings"; +import { startRemoteProxySession } from "./start-session"; +import type { Logger } from "./logger"; +import type { + RemoteProxySession, + StartRemoteProxySessionOptions, +} from "./types"; +import type { Binding } from "@cloudflare/workers-utils"; + +/** The worker whose remote bindings should be proxied. */ +export interface RemoteProxyWorker { + /** The name of the worker (used as the preview session name). */ + name?: string; + /** All of the worker's bindings (local + remote). */ + bindings: Record; + /** The account that owns the worker. */ + accountId?: string; + /** If running in a non-public compliance region (e.g. "eu"), set this here. */ + complianceRegion?: string; +} + +/** Data describing a running remote proxy session. */ +export interface RemoteProxySessionData { + session: RemoteProxySession; + remoteBindings: Record; +} + +export interface MaybeStartOrUpdateRemoteProxySessionOptions { + /** + * Auth credentials, or a callback that resolves them lazily. When omitted, + * the built-in env-var/OAuth resolver is used (see + * {@link import("./auth").createEnvAuthResolver}). + */ + auth?: StartRemoteProxySessionOptions["auth"]; + /** Logger for debug output. */ + logger?: Partial; +} + +function bindingsEqual(a: unknown, b: unknown): boolean { + try { + assert.deepStrictEqual(a, b); + return true; + } catch { + return false; + } +} + +/** + * Start, update, reuse, or tear down a remote proxy session for a worker. + * + * This is the auth-agnostic lifecycle helper that consumers (e.g. the Vite + * plugin) call on every config (re)load. It: + * + * - picks the worker's remote bindings; + * - returns `null` (disposing any existing session) when there are none; + * - starts a new session when there isn't one; + * - updates the existing session's bindings when they've changed; + * - otherwise reuses the existing session untouched. + * + * Auth is resolved by the underlying {@link startRemoteProxySession} — by + * default via the env-var/OAuth resolver, so a top-level CLI's discovered token + * is reused and refreshed mid-run. + * + * @returns the session data, or `null` when the worker defines no remote bindings. + */ +export async function maybeStartOrUpdateRemoteProxySession( + worker: RemoteProxyWorker, + preExisting?: RemoteProxySessionData | null, + options?: MaybeStartOrUpdateRemoteProxySessionOptions +): Promise { + const remoteBindings = pickRemoteBindings(worker.bindings); + + let session = preExisting?.session; + + if (Object.keys(remoteBindings).length === 0) { + if (session) { + await session.dispose(); + } + return null; + } + + if (!session) { + session = await startRemoteProxySession(remoteBindings, { + workerName: worker.name, + accountId: worker.accountId, + complianceRegion: worker.complianceRegion, + auth: options?.auth, + logger: options?.logger, + }); + } else if (!bindingsEqual(remoteBindings, preExisting?.remoteBindings)) { + await session.updateBindings(remoteBindings); + } + + await session.ready; + + return { session, remoteBindings }; +} diff --git a/packages/remote-bindings/src/pick-remote-bindings.ts b/packages/remote-bindings/src/pick-remote-bindings.ts new file mode 100644 index 0000000000..0402bdeab9 --- /dev/null +++ b/packages/remote-bindings/src/pick-remote-bindings.ts @@ -0,0 +1,26 @@ +import { getBindingLocalSupport } from "@cloudflare/workers-utils"; +import type { Binding } from "@cloudflare/workers-utils"; + +/** + * Filters a bindings record to only those that should be resolved remotely. + * + * A binding is picked when: + * - it explicitly opts in with `remote: true`, or + * - its type has no local simulator (e.g. ai, media, vpc_service), in which + * case it is always resolved remotely. + */ +export function pickRemoteBindings( + bindings: Record +): Record { + return Object.fromEntries( + Object.entries(bindings ?? {}).filter(([, binding]) => { + if ( + getBindingLocalSupport(binding.type) === + "DO-NOT-USE-this-resource-will-never-have-a-local-simulator" + ) { + return true; + } + return "remote" in binding && binding["remote"]; + }) + ); +} diff --git a/packages/remote-bindings/src/proxy-server.ts b/packages/remote-bindings/src/proxy-server.ts new file mode 100644 index 0000000000..cd91191aef --- /dev/null +++ b/packages/remote-bindings/src/proxy-server.ts @@ -0,0 +1,215 @@ +import http from "node:http"; +import https from "node:https"; +import type { PreviewToken } from "./types"; +import type { Duplex } from "node:stream"; + +/** + * A minimal HTTP/WebSocket proxy that adds the preview token header + * and forwards requests to the Cloudflare edge host. + * + * This replaces the full ProxyController + Miniflare instance that + * wrangler previously used. It handles: + * 1. HTTP request proxying with auth headers + * 2. WebSocket upgrade proxying with auth headers + * 3. Token refresh (caller updates the token via setToken()) + */ +export class ProxyServer { + #server: http.Server; + #token: PreviewToken; + #port: number; + #ready: Promise; + + constructor(token: PreviewToken, port: number) { + this.#token = token; + this.#port = port; + + this.#server = http.createServer(this.#handleRequest.bind(this)); + this.#server.on("upgrade", this.#handleUpgrade.bind(this)); + + this.#ready = new Promise((resolve, reject) => { + const onError = (err: Error) => { + reject(err); + }; + this.#server.on("error", onError); + this.#server.listen(this.#port, "127.0.0.1", () => { + // Remove the startup error listener — runtime errors + // are non-fatal and shouldn't reject the ready promise + this.#server.removeListener("error", onError); + resolve(); + }); + }); + } + + get ready(): Promise { + return this.#ready; + } + + get url(): string { + return `http://127.0.0.1:${this.#port}`; + } + + /** + * Update the preview token (e.g., after token refresh). + */ + setToken(token: PreviewToken): void { + this.#token = token; + } + + async dispose(): Promise { + // Force-close all active connections (including WebSockets) + // so that server.close() can complete. Without this, close() + // waits for all connections to finish, which hangs indefinitely + // when WebSocket connections are active. + this.#server.closeAllConnections(); + + return new Promise((resolve, reject) => { + this.#server.close((err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + /** + * Handle an HTTP request by proxying it to the edge host. + */ + #handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { + const edgeUrl = new URL(req.url ?? "/", `https://${this.#token.host}`); + + const headers: Record = {}; + // Copy incoming headers + for (const [key, value] of Object.entries(req.headers)) { + if (value !== undefined) { + headers[key] = value; + } + } + // Inject auth + headers["cf-workers-preview-token"] = this.#token.value; + // Remove host header — we're changing the target + delete headers["host"]; + + const proxyReq = https.request( + edgeUrl, + { + method: req.method, + headers, + }, + (proxyRes) => { + res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers); + proxyRes.pipe(res, { end: true }); + } + ); + + proxyReq.on("error", () => { + if (!res.headersSent) { + res.writeHead(502); + } + res.end(); + req.destroy(); + }); + + req.on("error", () => { + proxyReq.destroy(); + }); + + req.pipe(proxyReq, { end: true }); + } + + /** + * Handle a WebSocket upgrade by proxying it to the edge host. + * Forwards the full handshake response from the edge (including + * Sec-WebSocket-Accept and other headers required by RFC 6455). + */ + #handleUpgrade( + req: http.IncomingMessage, + clientSocket: Duplex, + head: Buffer + ): void { + const edgeUrl = new URL(req.url ?? "/", `https://${this.#token.host}`); + + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (value !== undefined) { + headers[key] = value; + } + } + headers["cf-workers-preview-token"] = this.#token.value; + delete headers["host"]; + + const options: https.RequestOptions = { + hostname: edgeUrl.hostname, + port: 443, + path: edgeUrl.pathname + edgeUrl.search, + method: "GET", + headers, + }; + + const proxyReq = https.request(options); + + proxyReq.on("response", (proxyRes) => { + let responseHead = `HTTP/1.1 ${proxyRes.statusCode ?? 502} ${proxyRes.statusMessage ?? "Bad Gateway"}\r\n`; + for (const [key, value] of Object.entries(proxyRes.headers)) { + if (value === undefined) { + continue; + } + if (Array.isArray(value)) { + for (const v of value) { + responseHead += `${key}: ${v}\r\n`; + } + } else { + responseHead += `${key}: ${value}\r\n`; + } + } + responseHead += "\r\n"; + clientSocket.write(responseHead); + proxyRes.pipe(clientSocket); + proxyRes.on("end", () => clientSocket.end()); + proxyRes.on("error", () => clientSocket.destroy()); + }); + + proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => { + // Build the 101 response from the edge's actual headers + // (includes Sec-WebSocket-Accept, extensions, protocol, etc.) + let responseHead = `HTTP/1.1 101 Switching Protocols\r\n`; + for (const [key, value] of Object.entries(proxyRes.headers)) { + if (value === undefined) { + continue; + } + if (Array.isArray(value)) { + for (const v of value) { + responseHead += `${key}: ${v}\r\n`; + } + } else { + responseHead += `${key}: ${value}\r\n`; + } + } + responseHead += "\r\n"; + clientSocket.write(responseHead); + + if (proxyHead.length > 0) { + clientSocket.write(proxyHead); + } + if (head.length > 0) { + proxySocket.write(head); + } + + // Relay data bidirectionally + proxySocket.pipe(clientSocket); + clientSocket.pipe(proxySocket); + + proxySocket.on("error", () => clientSocket.destroy()); + clientSocket.on("error", () => proxySocket.destroy()); + proxySocket.on("close", () => clientSocket.destroy()); + clientSocket.on("close", () => proxySocket.destroy()); + }); + + proxyReq.on("error", () => { + clientSocket.destroy(); + }); + + proxyReq.end(); + } +} diff --git a/packages/remote-bindings/src/start-session.ts b/packages/remote-bindings/src/start-session.ts new file mode 100644 index 0000000000..7055c64201 --- /dev/null +++ b/packages/remote-bindings/src/start-session.ts @@ -0,0 +1,137 @@ +import getPort from "get-port"; +import { createPreviewSession } from "./api/preview-session"; +import { createPreviewToken } from "./api/preview-token"; +import { createEnvAuthResolver } from "./auth"; +import { normalizeLogger } from "./logger"; +import { ProxyServer } from "./proxy-server"; +import type { Logger } from "./logger"; +import type { + AuthCredentials, + RemoteProxySession, + StartRemoteProxySessionOptions, +} from "./types"; +import type { Binding } from "@cloudflare/workers-utils"; +import type { RemoteProxyConnectionString } from "miniflare"; + +/** + * Normalise the `auth` option into a resolver that is invoked *fresh on every* + * Cloudflare API call. + * + * This is essential for long-lived sessions (e.g. `vite dev`): when the resolver + * is backed by {@link createEnvAuthResolver} it re-reads (and refreshes, if + * expired) the stored OAuth token on each call, so a token rotated on disk by + * the top-level CLI — or by this resolver itself — is always picked up. A + * resolver that resolved auth only once would send a stale token after the + * original expired. + */ +function toAuthResolver( + auth: StartRemoteProxySessionOptions["auth"], + accountId: string | undefined, + logger: Logger +): () => Promise { + if (auth === undefined) { + return createEnvAuthResolver({ accountId, logger }); + } + if (typeof auth === "function") { + return auth; + } + return async () => auth; +} + +/** + * Start a remote proxy session. + * + * This is the lightweight replacement for the old `startRemoteProxySession` + * that spun up an entire DevEnv (5 controllers, esbuild, a Miniflare instance) + * just to obtain a preview token. Instead, this: + * + * 1. Makes 2 direct API calls to Cloudflare's edge-preview endpoints + * 2. Starts a minimal Node.js HTTP/WS proxy that injects the preview token + * 3. Returns the local proxy URL as the `RemoteProxyConnectionString` + */ +export async function startRemoteProxySession( + bindings: Record, + options: StartRemoteProxySessionOptions = {} +): Promise { + if (Object.keys(bindings).length === 0) { + throw new Error("Cannot start remote proxy session with no bindings"); + } + + const logger: Logger = normalizeLogger(options.logger); + const resolveAuth = toAuthResolver(options.auth, options.accountId, logger); + const workerName = options.workerName ?? "remote-bindings-proxy"; + const complianceRegion = options.complianceRegion; + + // Step 1: Create a preview session (gets a session token, exchanges if needed) + let session; + try { + session = await createPreviewSession( + await resolveAuth(), + workerName, + complianceRegion, + logger + ); + } catch (error) { + throw new Error("Failed to create remote preview session", { + cause: error, + }); + } + + // Step 2: Upload the ProxyServerWorker and get a preview token + let token; + try { + token = await createPreviewToken( + await resolveAuth(), + session, + bindings, + workerName, + complianceRegion, + logger + ); + } catch (error) { + throw new Error("Failed to create remote preview token", { + cause: error, + }); + } + + // Step 3: Start a minimal local proxy that injects the preview token + const port = await getPort(); + const proxy = new ProxyServer(token, port); + await proxy.ready; + + const remoteProxyConnectionString = new URL( + proxy.url + ) as RemoteProxyConnectionString; + + // updateBindings: re-upload the worker with new bindings and refresh the token + const updateBindings = async ( + newBindings: Record + ): Promise => { + try { + const newToken = await createPreviewToken( + await resolveAuth(), + session, + newBindings, + workerName, + complianceRegion, + logger + ); + proxy.setToken(newToken); + } catch (error) { + throw new Error("Failed to update remote proxy bindings", { + cause: error, + }); + } + }; + + const dispose = async (): Promise => { + await proxy.dispose(); + }; + + return { + ready: proxy.ready, + remoteProxyConnectionString, + updateBindings, + dispose, + }; +} diff --git a/packages/remote-bindings/src/types.ts b/packages/remote-bindings/src/types.ts new file mode 100644 index 0000000000..ae62a2445f --- /dev/null +++ b/packages/remote-bindings/src/types.ts @@ -0,0 +1,81 @@ +import type { Logger } from "./logger"; +import type { ApiCredentials, Binding } from "@cloudflare/workers-utils"; +import type { RemoteProxyConnectionString } from "miniflare"; + +export type { ApiCredentials, Binding } from "@cloudflare/workers-utils"; +export type { RemoteProxyConnectionString } from "miniflare"; + +/** + * Credentials for authenticating with the Cloudflare API. + */ +export interface AuthCredentials { + accountId: string; + apiToken: ApiCredentials; +} + +/** + * Options for starting a remote proxy session. + */ +export interface StartRemoteProxySessionOptions { + /** The name of the worker (used as the preview session name). */ + workerName?: string; + /** + * Auth credentials, or a callback that resolves them lazily. + * + * When omitted, the package falls back to the built-in env-var/OAuth + * resolver ({@link import("./auth").createEnvAuthResolver}), which reads + * `CLOUDFLARE_*` credentials or refreshes the stored OAuth token discovered + * via the global config directory. The resolver is invoked on every API call + * so refreshed tokens are picked up mid-run. + */ + auth?: AuthCredentials | (() => Promise); + /** + * Account ID hint for the built-in resolver (used only when `auth` is + * omitted). Falls back to `CLOUDFLARE_ACCOUNT_ID` when unset. + */ + accountId?: string; + /** If running in a non-public compliance region (e.g., "eu"), set this here. */ + complianceRegion?: string; + /** + * Optional logger. Pass wrangler's `logger`, `console`, a partial logger + * (e.g. just `debug`), or omit for silent operation. + */ + logger?: Partial; +} + +/** + * A running remote proxy session that provides a connection string + * for miniflare to use when proxying binding requests to the edge. + */ +export interface RemoteProxySession { + /** Resolves when the session is ready to accept requests. */ + ready: Promise; + /** The connection string URL for miniflare plugins to use. */ + remoteProxyConnectionString: RemoteProxyConnectionString; + /** Update the bindings for this session (e.g., after config change). */ + updateBindings: (bindings: Record) => Promise; + /** Tear down the session and release resources. */ + dispose: () => Promise; +} + +/** + * A Cloudflare edge preview session. + */ +export interface PreviewSession { + /** The session token for creating preview tokens. */ + value: string; + /** The host where the preview is available. */ + host: string; + /** The worker name used when the session was created. */ + name: string | undefined; +} + +/** + * A preview token for authenticating requests to the edge worker. + */ +export interface PreviewToken { + /** The header value for `cf-workers-preview-token`. */ + value: string; + /** The host where the preview is available. */ + host: string; +} diff --git a/packages/remote-bindings/src/virtual.d.ts b/packages/remote-bindings/src/virtual.d.ts new file mode 100644 index 0000000000..ede88c65cd --- /dev/null +++ b/packages/remote-bindings/src/virtual.d.ts @@ -0,0 +1,4 @@ +declare module "virtual:proxy-server-worker" { + const script: string; + export default script; +} diff --git a/packages/wrangler/templates/remoteBindings/ProxyServerWorker.ts b/packages/remote-bindings/templates/ProxyServerWorker.ts similarity index 98% rename from packages/wrangler/templates/remoteBindings/ProxyServerWorker.ts rename to packages/remote-bindings/templates/ProxyServerWorker.ts index 4a43f4518c..f1f22dac95 100644 --- a/packages/wrangler/templates/remoteBindings/ProxyServerWorker.ts +++ b/packages/remote-bindings/templates/ProxyServerWorker.ts @@ -108,7 +108,7 @@ export default { async fetch(request, env) { try { if (isJSRPCBinding(request)) { - return await newWorkersRpcResponse( + return newWorkersRpcResponse( request, getExposedJSRPCBinding(request, env) ); @@ -125,7 +125,7 @@ export default { } } - return await fetcher.fetch( + return fetcher.fetch( request.headers.get("MF-URL") ?? "http://example.com", new Request(request, { redirect: "manual", diff --git a/packages/remote-bindings/test/__stubs__/proxy-server-worker.ts b/packages/remote-bindings/test/__stubs__/proxy-server-worker.ts new file mode 100644 index 0000000000..e83c0a667a --- /dev/null +++ b/packages/remote-bindings/test/__stubs__/proxy-server-worker.ts @@ -0,0 +1,3 @@ +// Stub for the virtual:proxy-server-worker module used in tests. +// The real content is inlined at build time by the tsdown rolldown plugin. +export default "/* stub proxy server worker script */"; diff --git a/packages/remote-bindings/test/auth.test.ts b/packages/remote-bindings/test/auth.test.ts new file mode 100644 index 0000000000..e9cd2da6cb --- /dev/null +++ b/packages/remote-bindings/test/auth.test.ts @@ -0,0 +1,75 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, it, vi } from "vitest"; +import { createEnvAuthResolver } from "../src/auth"; +import type { ConfigFileLocation } from "@cloudflare/workers-auth"; + +/** + * An auth-config location pointing at a throwaway temp file, so tests never read + * the developer's real `~/.wrangler` auth config. Token resolution itself is + * covered by `@cloudflare/workers-auth`; these tests focus on the account-id + * assembly layered on top. + */ +function tempAuthConfig(): ConfigFileLocation { + const dir = mkdtempSync(path.join(tmpdir(), "rb-auth-")); + return { getPath: () => path.join(dir, "default.toml"), format: "toml" }; +} + +describe("createEnvAuthResolver", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("assembles accountId + apiToken from the environment", async ({ + expect, + }) => { + vi.stubEnv("CLOUDFLARE_API_TOKEN", "env-token"); + vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "env-account"); + + const resolve = createEnvAuthResolver({ authConfig: tempAuthConfig() }); + + expect(await resolve()).toEqual({ + accountId: "env-account", + apiToken: { apiToken: "env-token" }, + }); + }); + + it("uses an explicit accountId over the env var", async ({ expect }) => { + vi.stubEnv("CLOUDFLARE_API_TOKEN", "env-token"); + vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "env-account"); + + const resolve = createEnvAuthResolver({ + accountId: "explicit-account", + authConfig: tempAuthConfig(), + }); + + expect((await resolve()).accountId).toBe("explicit-account"); + }); + + it("throws when the account ID cannot be determined", async ({ expect }) => { + vi.stubEnv("CLOUDFLARE_API_TOKEN", "env-token"); + vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", ""); + + const resolve = createEnvAuthResolver({ authConfig: tempAuthConfig() }); + + await expect(resolve()).rejects.toThrow( + "Unable to determine the Cloudflare account ID" + ); + }); + + it("surfaces the not-authenticated error when there are no credentials", async ({ + expect, + }) => { + vi.stubEnv("CLOUDFLARE_API_TOKEN", ""); + vi.stubEnv("CLOUDFLARE_API_KEY", ""); + vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "env-account"); + + const resolve = createEnvAuthResolver({ authConfig: tempAuthConfig() }); + + await expect(resolve()).rejects.toThrow("Not authenticated"); + }); +}); diff --git a/packages/remote-bindings/test/pick-remote-bindings.test.ts b/packages/remote-bindings/test/pick-remote-bindings.test.ts new file mode 100644 index 0000000000..374c9bf075 --- /dev/null +++ b/packages/remote-bindings/test/pick-remote-bindings.test.ts @@ -0,0 +1,45 @@ +import { describe, it } from "vitest"; +import { pickRemoteBindings } from "../src/index"; +import type { Binding } from "../src/index"; + +describe("pickRemoteBindings", () => { + it("returns empty record for empty input", ({ expect }) => { + expect(pickRemoteBindings({})).toEqual({}); + }); + + it("filters out local-only bindings", ({ expect }) => { + const bindings: Record = { + MY_KV: { type: "kv_namespace", id: "abc123" }, + MY_VAR: { type: "plain_text", value: "hello" }, + }; + expect(pickRemoteBindings(bindings)).toEqual({}); + }); + + it("includes bindings with remote: true", ({ expect }) => { + const bindings: Record = { + MY_KV: { type: "kv_namespace", id: "abc123", remote: true }, + LOCAL_KV: { type: "kv_namespace", id: "def456" }, + }; + const result = pickRemoteBindings(bindings); + expect(Object.keys(result)).toEqual(["MY_KV"]); + }); + + it("always includes bindings with no local simulator", ({ expect }) => { + const bindings: Record = { + MY_AI: { type: "ai" }, + }; + const result = pickRemoteBindings(bindings); + expect(Object.keys(result)).toEqual(["MY_AI"]); + }); + + it("handles a mix of remote and local bindings", ({ expect }) => { + const bindings: Record = { + REMOTE_R2: { type: "r2_bucket", bucket_name: "my-bucket", remote: true }, + LOCAL_D1: { type: "d1", database_id: "db-123" }, + ALWAYS_AI: { type: "ai" }, + LOCAL_VAR: { type: "plain_text", value: "test" }, + }; + const result = pickRemoteBindings(bindings); + expect(Object.keys(result).sort()).toEqual(["ALWAYS_AI", "REMOTE_R2"]); + }); +}); diff --git a/packages/remote-bindings/tsconfig.json b/packages/remote-bindings/tsconfig.json new file mode 100644 index 0000000000..17ca1a2950 --- /dev/null +++ b/packages/remote-bindings/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ES2022", + "allowSyntheticDefaultImports": true, + "incremental": false, + "composite": false, + "skipLibCheck": true, + "types": ["node"], + "noUncheckedIndexedAccess": true + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/remote-bindings/tsdown.config.ts b/packages/remote-bindings/tsdown.config.ts new file mode 100644 index 0000000000..c8faf98950 --- /dev/null +++ b/packages/remote-bindings/tsdown.config.ts @@ -0,0 +1,59 @@ +import { buildSync } from "esbuild"; +import { defineConfig } from "tsdown"; +import type { Plugin } from "rolldown"; + +const VIRTUAL_ID = "virtual:proxy-server-worker"; +const RESOLVED_ID = `\0${VIRTUAL_ID}`; + +/** + * Rolldown plugin that bundles templates/ProxyServerWorker.ts at build time + * and exposes the result as a string via `import script from "virtual:proxy-server-worker"`. + * + * This means the proxy worker script is a string literal in the output — + * no readFileSync at runtime, so it works when wrangler bundles this package inline. + */ +function proxyServerWorkerPlugin(): Plugin { + return { + name: "proxy-server-worker", + resolveId(id) { + if (id === VIRTUAL_ID) { + return RESOLVED_ID; + } + }, + load(id) { + if (id === RESOLVED_ID) { + const result = buildSync({ + entryPoints: ["templates/ProxyServerWorker.ts"], + bundle: true, + format: "esm", + write: false, + external: ["cloudflare:email", "cloudflare:workers"], + minify: false, + }); + + const code = result.outputFiles[0]?.text; + if (!code) { + throw new Error("Failed to bundle ProxyServerWorker template"); + } + + return `export default ${JSON.stringify(code)};`; + } + }, + }; +} + +export default defineConfig({ + entry: ["src/index.ts"], + platform: "node", + // ESM-only: this package's `@cloudflare/*` dependencies are ESM-only (no + // `require` export), so a CJS build would fail to load them. Consumers + // (wrangler, the Vite plugin) bundle this package, so ESM is sufficient. + format: ["esm"], + outDir: "dist", + dts: true, + // Keep these external so downstream consumers (wrangler, the Vite plugin) + // share a single copy rather than bundling duplicates. `undici` in + // particular must be deduplicated for cross-boundary `instanceof` checks. + external: ["miniflare", "undici", /^@cloudflare\//], + plugins: [proxyServerWorkerPlugin()], +}); diff --git a/packages/remote-bindings/turbo.json b/packages/remote-bindings/turbo.json new file mode 100644 index 0000000000..6f0aa31c8e --- /dev/null +++ b/packages/remote-bindings/turbo.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "inputs": ["$TURBO_DEFAULT$", "!**/test/**"], + "outputs": ["dist/**"] + }, + "test:ci": { + "dependsOn": ["build"], + "env": ["LC_ALL", "TZ"] + } + } +} diff --git a/packages/remote-bindings/vitest.config.ts b/packages/remote-bindings/vitest.config.ts new file mode 100644 index 0000000000..871b93b6e5 --- /dev/null +++ b/packages/remote-bindings/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineConfig({ + resolve: { + alias: { + // The virtual module is resolved by the tsdown rolldown plugin at build time. + // For vitest (which runs source directly), provide a stub. + "virtual:proxy-server-worker": + "./test/__stubs__/proxy-server-worker.ts", + }, + }, + }) +); diff --git a/packages/vite-plugin-cloudflare/package.json b/packages/vite-plugin-cloudflare/package.json index b07b95d2e7..ca048b58da 100644 --- a/packages/vite-plugin-cloudflare/package.json +++ b/packages/vite-plugin-cloudflare/package.json @@ -53,6 +53,7 @@ "test:watch": "vitest" }, "dependencies": { + "@cloudflare/remote-bindings": "workspace:*", "@cloudflare/unenv-preset": "workspace:*", "miniflare": "workspace:*", "unenv": "2.0.0-rc.24", diff --git a/packages/vite-plugin-cloudflare/src/miniflare-options.ts b/packages/vite-plugin-cloudflare/src/miniflare-options.ts index b174473556..6c555c6de4 100644 --- a/packages/vite-plugin-cloudflare/src/miniflare-options.ts +++ b/packages/vite-plugin-cloudflare/src/miniflare-options.ts @@ -7,6 +7,7 @@ import { generateContainerBuildId, resolveDockerHost, } from "@cloudflare/containers-shared"; +import { maybeStartOrUpdateRemoteProxySession } from "@cloudflare/remote-bindings"; import { getBrowserRenderingHeadfulFromEnv, getLocalExplorerEnabledFromEnv, @@ -46,6 +47,7 @@ import type { } from "./context"; import type { PersistState } from "./plugin-config"; import type { ModuleType } from "@cloudflare/config"; +import type { RemoteProxySession } from "@cloudflare/remote-bindings"; import type { MiniflareOptions, ModuleRuleType, @@ -53,11 +55,7 @@ import type { WorkerOptions, } from "miniflare"; import type * as vite from "vite"; -import type { - Binding, - RemoteProxySession, - SourcelessWorkerOptions, -} from "wrangler"; +import type { Binding, SourcelessWorkerOptions } from "wrangler"; const INTERNAL_WORKERS_COMPATIBILITY_DATE = "2024-10-04"; // Used to mark HTML assets as being in the public directory so that they can be resolved from their root relative paths @@ -270,11 +268,11 @@ export async function getDevMiniflareOptions( !resolvedPluginConfig.remoteBindings ? // if remote bindings are not enabled then the proxy session can simply be null null - : await wrangler.maybeStartOrUpdateRemoteProxySession( + : await maybeStartOrUpdateRemoteProxySession( { name: worker.config.name, bindings: bindings ?? {}, - account_id: worker.config.account_id, + accountId: worker.config.account_id, }, preExistingRemoteProxySession ?? null ); @@ -660,11 +658,11 @@ export async function getPreviewMiniflareOptions( const remoteProxySessionData = !resolvedPluginConfig.remoteBindings ? // if remote bindings are not enabled then the proxy session can simply be null null - : await wrangler.maybeStartOrUpdateRemoteProxySession( + : await maybeStartOrUpdateRemoteProxySession( { name: workerConfig.name, bindings: bindings ?? {}, - account_id: workerConfig.account_id, + accountId: workerConfig.account_id, }, preExistingRemoteProxySessionData ?? null ); diff --git a/packages/wrangler/package.json b/packages/wrangler/package.json index 2fbcaaff9d..3b448acf97 100644 --- a/packages/wrangler/package.json +++ b/packages/wrangler/package.json @@ -96,6 +96,7 @@ "@cloudflare/containers-shared": "workspace:*", "@cloudflare/deploy-helpers": "workspace:*", "@cloudflare/pages-shared": "workspace:^", + "@cloudflare/remote-bindings": "workspace:*", "@cloudflare/types": "6.18.4", "@cloudflare/workers-auth": "workspace:*", "@cloudflare/workers-shared": "workspace:*", diff --git a/packages/wrangler/src/__tests__/dev/remote-bindings-errors.test.ts b/packages/wrangler/src/__tests__/dev/remote-bindings-errors.test.ts index 919391c220..04323a0eb7 100644 --- a/packages/wrangler/src/__tests__/dev/remote-bindings-errors.test.ts +++ b/packages/wrangler/src/__tests__/dev/remote-bindings-errors.test.ts @@ -1,17 +1,9 @@ import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; -import { assert, beforeEach, describe, it, vi } from "vitest"; +import { assert, beforeEach, describe, it } from "vitest"; import { startRemoteProxySession } from "../../api"; -import { - createPreviewSession, - createWorkerPreview, -} from "../../dev/create-worker-preview"; import { mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; import { msw, mswSuccessUserHandlers } from "../helpers/msw"; -vi.mock("../../dev/create-worker-preview", () => ({ - createPreviewSession: vi.fn(), - createWorkerPreview: vi.fn(), -})); mockConsoleMethods(); @@ -23,12 +15,16 @@ describe("errors during dev with remote bindings", () => { msw.use(...mswSuccessUserHandlers); }); - it("errors triggered when creating the remote proxy session are surfaced", async ({ + it("re-throws auth/account-selection UserErrors directly (not wrapped)", async ({ expect, }) => { let thrownError: Error | undefined; try { + // No `auth` provided, so wrangler resolves credentials via its own auth + // system. With multiple accounts available and no interactive prompt, + // account selection fails with a UserError that must be surfaced + // directly rather than wrapped in a generic "Failed to start" envelope. await startRemoteProxySession({ MY_WORKER: { type: "service", @@ -42,34 +38,27 @@ describe("errors during dev with remote bindings", () => { } assert(thrownError); - - // UserErrors (like auth/account selection failures) are re-thrown - // directly without being wrapped in a generic "Failed to start the - // remote proxy session" envelope, so the user sees a single, - // actionable error message. expect(thrownError.message).toContain( "More than one account available but unable to select one in non-interactive mode." ); }); - it("errors triggered when establishing the remote proxy session (after it has been created) are surfaced", async ({ - expect, - }) => { - vi.mocked(createPreviewSession).mockResolvedValue({ - value: "test-session-value", - host: "test.workers.dev", - name: "test", - }); - - vi.mocked(createWorkerPreview).mockImplementation(async () => { - throw new Error("The remote worker preview failed."); - }); - + it("surfaces edge-preview API failures", async ({ expect }) => { let thrownError: Error | undefined; try { + // Explicit auth bypasses account selection, so the session proceeds to + // the edge-preview API call. With no handler registered for that + // endpoint the request fails, and the actionable Cloudflare API error + // is surfaced directly. await startRemoteProxySession( - {}, + { + MY_WORKER: { + type: "service", + service: "my-worker", + remote: true, + }, + }, { auth: { accountId: "test-account-id", @@ -83,19 +72,7 @@ describe("errors during dev with remote bindings", () => { } assert(thrownError); - - expect(thrownError).toMatchInlineSnapshot( - `[Error: Failed to start the remote proxy session. Failed to obtain a preview token: The remote worker preview failed.]` - ); - - expect(thrownError.cause).toMatchInlineSnapshot(` - { - "cause": [Error: The remote worker preview failed.], - "data": undefined, - "reason": "Failed to obtain a preview token", - "source": "RemoteRuntimeController", - "type": "error", - } - `); + expect(thrownError.message).toContain("A request to the Cloudflare API"); + expect(thrownError.message).toContain("workers/subdomain/edge-preview"); }); }); diff --git a/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts b/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts index c0444802e3..85244d86d6 100644 --- a/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts +++ b/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts @@ -1,247 +1,109 @@ -import events from "node:events"; -import path from "node:path"; +import { startRemoteProxySession as startRemoteProxySessionImpl } from "@cloudflare/remote-bindings"; import { UserError } from "@cloudflare/workers-utils"; import chalk from "chalk"; -import { DeferredPromise } from "miniflare"; -import remoteBindingsWorkerPath from "worker:remoteBindings/ProxyServerWorker"; -import { RemoteSessionAuthenticationError } from "../../dev/remote"; import { logger } from "../../logger"; -import { getBasePath } from "../../paths"; -import { startWorker } from "../startDevWorker"; -import type { LoggerLevel } from "../../logger"; -import type { StartDevWorkerInput, Worker } from "../startDevWorker"; -import type { ErrorEvent } from "../startDevWorker/events"; -import type { Config } from "@cloudflare/workers-utils"; -import type { RemoteProxyConnectionString } from "miniflare"; +import { requireApiToken, requireAuth } from "../../user"; +import type { + AuthCredentials, + RemoteProxySession, +} from "@cloudflare/remote-bindings"; +import type { + AsyncHook, + CfAccount, + Config, + StartDevWorkerInput, +} from "@cloudflare/workers-utils"; + +export type { RemoteProxySession } from "@cloudflare/remote-bindings"; export type StartRemoteProxySessionOptions = { workerName?: string; - auth?: NonNullable["auth"]; + auth?: AsyncHook; /** If running in a non-public compliance region, set this here. */ complianceRegion?: Config["compliance_region"]; }; -function isErrorEvent(error: unknown): error is ErrorEvent { - return ( - typeof error === "object" && - error !== null && - "type" in error && - (error as { type?: string }).type === "error" && - "reason" in error && - "cause" in error - ); +/** + * Resolve wrangler's `AsyncHook` auth option into the credential + * resolver shape `@cloudflare/remote-bindings` expects. + * + * When no auth is provided we preserve wrangler's historical behaviour by + * resolving credentials through wrangler's own auth system (`requireAuth` / + * `requireApiToken`), which includes interactive login and account selection. + * The resolver is returned (not pre-resolved) so the package can call it fresh + * on every API request and pick up refreshed tokens. + */ +function resolveAuth( + auth: AsyncHook | undefined +): () => Promise { + if (auth !== undefined) { + if (typeof auth === "function") { + return async () => auth(); + } + return async () => auth; + } + + return async () => ({ + accountId: await requireAuth({}), + apiToken: requireApiToken(), + }); +} + +function findUserError(error: unknown): UserError | undefined { + if (error instanceof UserError) { + return error; + } + if (error instanceof Error && error.cause) { + return findUserError(error.cause); + } + return undefined; } function getErrorMessage(error: unknown): string | undefined { if (error instanceof Error) { return getErrorMessage(error.cause) ?? error.message; } - if (typeof error === "string") { return error; } - - if (typeof error === "object" && error !== null) { - const maybeMessage = (error as { message?: unknown }).message; - if (typeof maybeMessage === "string") { - const maybeCause = (error as { cause?: unknown }).cause; - return getErrorMessage(maybeCause) ?? maybeMessage; - } - } - return undefined; } /** - * Walks the cause chain of an error (including {@link ErrorEvent} wrappers) - * looking for a {@link RemoteSessionAuthenticationError}. + * Start a remote proxy session. * - * @param error - the error or ErrorEvent to inspect - * @returns the first {@link RemoteSessionAuthenticationError} found, or - * `undefined` if none exists in the chain + * Thin wrapper over `@cloudflare/remote-bindings`'s `startRemoteProxySession` + * that wires in wrangler's logger and auth system and preserves wrangler's + * error-surfacing behaviour. */ -function findRemoteSessionAuthError( - error: unknown -): RemoteSessionAuthenticationError | undefined { - if (error instanceof RemoteSessionAuthenticationError) { - return error; - } - - if (isErrorEvent(error) || (error instanceof Error && error.cause)) { - return findRemoteSessionAuthError(error.cause); - } - - return undefined; -} - -function formatRemoteProxySessionError(error: unknown): string | undefined { - if (isErrorEvent(error)) { - const causeMessage = getErrorMessage(error.cause); - return causeMessage ? `${error.reason}: ${causeMessage}` : error.reason; - } - - return getErrorMessage(error); -} - export async function startRemoteProxySession( bindings: StartDevWorkerInput["bindings"], options?: StartRemoteProxySessionOptions ): Promise { logger.log(chalk.dim("⎔ Establishing remote connection...")); - // Transform all bindings to use "raw" mode - const rawBindings = Object.fromEntries( - Object.entries(bindings ?? {}).map(([key, binding]) => [ - key, - { ...binding, raw: true }, - ]) - ); - - const proxyServerWorkerWranglerConfig = path.resolve( - getBasePath(), - "templates/remoteBindings/wrangler.jsonc" - ); - - const worker = await startWorker({ - name: options?.workerName, - entrypoint: remoteBindingsWorkerPath, - config: proxyServerWorkerWranglerConfig, - compatibilityDate: "2025-04-28", - dev: { - remote: "minimal", - auth: options?.auth, - server: { - port: 0, - }, - inspector: false, - logLevel: getStartWorkerLogLevel(logger.loggerLevel), - }, - bindings: rawBindings, - }).catch((startWorkerError) => { - // If the error is already a UserError (e.g. an auth failure from - // ConfigController), re-throw it directly so the top-level error - // handler can display the original, actionable message without - // wrapping it in a generic "Failed to start" envelope. - if (startWorkerError instanceof UserError) { - throw startWorkerError; - } - let errorMessage = startWorkerError; - if (startWorkerError instanceof Error) { - if (startWorkerError.cause instanceof Error) { - errorMessage = startWorkerError.cause.message; - } else { - errorMessage = startWorkerError.message; - } - } - throw new Error( - `Failed to start the remote proxy session, see the error details below:\n\n${errorMessage}` - ); - }); - - const maybeErrorPromise = new DeferredPromise<{ error: unknown }>(); - - worker.raw.addListener("error", (e) => - maybeErrorPromise.resolve({ error: e }) - ); - const maybeError = await Promise.race([ - maybeErrorPromise, - worker.raw.proxy.localServerReady.promise, - ]); - - if (maybeError && maybeError.error) { - const authError = findRemoteSessionAuthError(maybeError.error); - if (authError) { - throw authError; + try { + return await startRemoteProxySessionImpl(bindings ?? {}, { + workerName: options?.workerName, + complianceRegion: options?.complianceRegion, + auth: resolveAuth(options?.auth), + logger, + }); + } catch (error) { + // Surface UserErrors (e.g. auth / account-selection failures) directly so + // the user sees a single, actionable message rather than a generic + // "Failed to start" envelope. + const userError = findUserError(error); + if (userError) { + throw userError; } - const details = formatRemoteProxySessionError(maybeError.error); + const details = getErrorMessage(error); throw new Error( details ? `Failed to start the remote proxy session. ${details}` : "Failed to start the remote proxy session. There is likely additional logging output above.", - { - cause: maybeError.error, - } - ); - } - - const remoteProxyConnectionString = - (await worker.url) as RemoteProxyConnectionString; - - const updateBindings = async ( - newBindings: StartDevWorkerInput["bindings"] - ) => { - // Transform all new bindings to use "raw" mode - const rawNewBindings = Object.fromEntries( - Object.entries(newBindings ?? {}).map(([key, binding]) => [ - key, - { ...binding, raw: true }, - ]) + { cause: error } ); - - // `worker.patchConfig` returns as soon as the config update is dispatched - // — long before the remote worker has actually been re-uploaded with the - // new bindings and the local proxy worker has unpaused. If we returned - // here, callers issuing requests immediately afterwards would race the - // reload window, often surfacing as "WebSocket connection failed" for - // JSRPC bindings. - // - // Subscribe BEFORE patchConfig so we don't miss either event. - // `events.once()` resolves on `reloadComplete` and rejects if `error` - // is emitted first (with the event payload as the rejection value). - const reloadComplete = events.once(worker.raw, "reloadComplete"); - await worker.patchConfig({ bindings: rawNewBindings }); - try { - await reloadComplete; - } catch (errOrEvent) { - throw errOrEvent instanceof Error - ? errOrEvent - : new Error( - `RemoteProxySession.updateBindings failed during reload: ${ - (errOrEvent as { reason?: string })?.reason ?? "unknown" - }`, - { cause: errOrEvent } - ); - } - // The "play" message that resumes the local proxy worker is enqueued on - // this mutex during onReloadComplete. Wait for it to drain so the proxy - // actually unpauses before we return — matches what `worker.fetch` does. - await worker.raw.proxy.runtimeMessageMutex.drained(); - }; - - return { - ready: worker.ready, - remoteProxyConnectionString, - updateBindings, - dispose: worker.dispose, - }; -} - -export type RemoteProxySession = Pick & { - updateBindings: (bindings: StartDevWorkerInput["bindings"]) => Promise; - remoteProxyConnectionString: RemoteProxyConnectionString; -}; - -/** - * Gets the log level to use for the remote worker. - * - * @param wranglerLogLevel The log level set for the Wrangler process. - * @returns The log level to use for the remove worker. - */ -function getStartWorkerLogLevel(wranglerLogLevel: LoggerLevel): LoggerLevel { - switch (wranglerLogLevel) { - case "debug": - // If the `logLevel` is "debug" it means that the user is likely trying to debug some issue, - // so we should respect that here as well for the remote proxy session. - return "debug"; - - case "none": - // If the `logLevel` is "none" it means that the user is trying to silence all output, - // so we should respect that here as well for the remote proxy session. - return "none"; - - default: - // In any other case we want to default to "error" to avoid noisy logs - return "error"; } } diff --git a/packages/wrangler/templates/remoteBindings/wrangler.jsonc b/packages/wrangler/templates/remoteBindings/wrangler.jsonc deleted file mode 100644 index a9a5b5c8af..0000000000 --- a/packages/wrangler/templates/remoteBindings/wrangler.jsonc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "main": "./ProxyServerWorker.ts", - "compatibility_date": "2025-04-28", -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index debf0b5dbf..5dd1f16c69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2544,6 +2544,49 @@ importers: specifier: ^3.5.0 version: 3.5.0(esbuild@0.28.1) + packages/remote-bindings: + dependencies: + '@cloudflare/deploy-helpers': + specifier: workspace:* + version: link:../deploy-helpers + '@cloudflare/workers-auth': + specifier: workspace:* + version: link:../workers-auth + '@cloudflare/workers-utils': + specifier: workspace:* + version: link:../workers-utils + miniflare: + specifier: workspace:* + version: link:../miniflare + undici: + specifier: catalog:default + version: 7.28.0 + devDependencies: + '@cloudflare/workers-tsconfig': + specifier: workspace:* + version: link:../workers-tsconfig + '@types/node': + specifier: 22.15.17 + version: 22.15.17 + capnweb: + specifier: catalog:default + version: 0.5.0 + esbuild: + specifier: catalog:default + version: 0.28.1 + get-port: + specifier: ^7.0.0 + version: 7.1.0 + tsdown: + specifier: 0.16.3 + version: 0.16.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3) + typescript: + specifier: catalog:default + version: 5.8.3 + vitest: + specifier: catalog:default + version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.15.17)(@vitest/ui@4.1.0)(msw@2.12.4(@types/node@22.15.17)(typescript@5.8.3))(vite@8.0.13(@types/node@22.15.17)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.1)) + packages/solarflare-theme: {} packages/turbo-r2-archive: @@ -2586,6 +2629,9 @@ importers: packages/vite-plugin-cloudflare: dependencies: + '@cloudflare/remote-bindings': + specifier: workspace:* + version: link:../remote-bindings '@cloudflare/unenv-preset': specifier: workspace:* version: link:../unenv-preset @@ -4310,6 +4356,9 @@ importers: '@cloudflare/pages-shared': specifier: workspace:^ version: link:../pages-shared + '@cloudflare/remote-bindings': + specifier: workspace:* + version: link:../remote-bindings '@cloudflare/types': specifier: 6.18.4 version: 6.18.4(react@19.2.4)