From 159c673392da99849d3d720c1108b29d8e78d590 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 14 Jul 2026 15:50:25 +0100 Subject: [PATCH 01/34] [remote-bindings] Add empty package --- packages/remote-bindings/package.json | 36 +++++++++++++++++++++++ packages/remote-bindings/src/index.ts | 1 + packages/remote-bindings/tsconfig.json | 4 +++ packages/remote-bindings/tsdown.config.ts | 11 +++++++ packages/remote-bindings/turbo.json | 9 ++++++ pnpm-lock.yaml | 12 ++++++++ 6 files changed, 73 insertions(+) create mode 100644 packages/remote-bindings/package.json create mode 100644 packages/remote-bindings/src/index.ts create mode 100644 packages/remote-bindings/tsconfig.json create mode 100644 packages/remote-bindings/tsdown.config.ts create mode 100644 packages/remote-bindings/turbo.json diff --git a/packages/remote-bindings/package.json b/packages/remote-bindings/package.json new file mode 100644 index 0000000000..1bd92f239e --- /dev/null +++ b/packages/remote-bindings/package.json @@ -0,0 +1,36 @@ +{ + "name": "@cloudflare/remote-bindings", + "version": "0.0.0", + "private": true, + "homepage": "https://github.com/cloudflare/workers-sdk/tree/main/packages/remote-bindings#readme", + "bugs": { + "url": "https://github.com/cloudflare/workers-sdk/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/cloudflare/workers-sdk.git", + "directory": "packages/remote-bindings" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "scripts": { + "build": "tsdown", + "check:type": "tsc", + "dev": "tsdown --watch" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "tsdown": "0.16.3", + "typescript": "catalog:default" + } +} diff --git a/packages/remote-bindings/src/index.ts b/packages/remote-bindings/src/index.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/remote-bindings/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/remote-bindings/tsconfig.json b/packages/remote-bindings/tsconfig.json new file mode 100644 index 0000000000..43c3882de2 --- /dev/null +++ b/packages/remote-bindings/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@cloudflare/workers-tsconfig/base.json", + "include": ["src"] +} diff --git a/packages/remote-bindings/tsdown.config.ts b/packages/remote-bindings/tsdown.config.ts new file mode 100644 index 0000000000..0c9545f089 --- /dev/null +++ b/packages/remote-bindings/tsdown.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + }, + platform: "node", + outDir: "dist", + dts: true, + tsconfig: "tsconfig.json", +}); diff --git a/packages/remote-bindings/turbo.json b/packages/remote-bindings/turbo.json new file mode 100644 index 0000000000..6556dcf3e5 --- /dev/null +++ b/packages/remote-bindings/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"] + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9a05df14f..908323b46b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2563,6 +2563,18 @@ importers: specifier: ^3.5.0 version: 3.5.0(esbuild@0.28.1) + packages/remote-bindings: + devDependencies: + '@cloudflare/workers-tsconfig': + specifier: workspace:* + version: link:../workers-tsconfig + 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 + packages/runtime-types: dependencies: miniflare: From 0c64cf45e673415b89bc23b17f42180aa04ce6a9 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 14 Jul 2026 16:57:15 +0100 Subject: [PATCH 02/34] [remote-bindings] Extract remote binding implementation --- packages/remote-bindings/package.json | 17 +- packages/remote-bindings/src/auth.test.ts | 72 +++++ packages/remote-bindings/src/auth.ts | 57 ++++ packages/remote-bindings/src/index.ts | 16 +- packages/remote-bindings/src/logger.ts | 6 + .../src/maybe-start-or-update-session.ts | 181 +++++++++++++ .../src/start-remote-proxy-session.ts | 180 +++++++++++++ packages/remote-bindings/src/start-worker.ts | 19 ++ .../remoteBindings/ProxyServerWorker.ts | 0 .../templates/remoteBindings/wrangler.jsonc | 0 packages/remote-bindings/tsconfig.json | 5 +- packages/remote-bindings/tsdown.config.ts | 28 +- packages/wrangler/package.json | 1 + .../wrangler/src/api/remoteBindings/index.ts | 218 ++------------- .../start-remote-proxy-session.ts | 248 +----------------- pnpm-lock.yaml | 27 ++ 16 files changed, 617 insertions(+), 458 deletions(-) create mode 100644 packages/remote-bindings/src/auth.test.ts create mode 100644 packages/remote-bindings/src/auth.ts create mode 100644 packages/remote-bindings/src/logger.ts create mode 100644 packages/remote-bindings/src/maybe-start-or-update-session.ts create mode 100644 packages/remote-bindings/src/start-remote-proxy-session.ts create mode 100644 packages/remote-bindings/src/start-worker.ts rename packages/{wrangler => remote-bindings}/templates/remoteBindings/ProxyServerWorker.ts (100%) rename packages/{wrangler => remote-bindings}/templates/remoteBindings/wrangler.jsonc (100%) diff --git a/packages/remote-bindings/package.json b/packages/remote-bindings/package.json index 1bd92f239e..4c404219d9 100644 --- a/packages/remote-bindings/package.json +++ b/packages/remote-bindings/package.json @@ -13,7 +13,8 @@ "directory": "packages/remote-bindings" }, "files": [ - "dist" + "dist", + "templates" ], "type": "module", "sideEffects": false, @@ -26,11 +27,21 @@ "scripts": { "build": "tsdown", "check:type": "tsc", - "dev": "tsdown --watch" + "dev": "tsdown --watch", + "test:ci": "vitest run --passWithNoTests", + "test:watch": "vitest" }, "devDependencies": { + "@cloudflare/cli-shared-helpers": "workspace:*", + "@cloudflare/workers-auth": "workspace:*", "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "@cloudflare/workers-utils": "workspace:*", + "capnweb": "catalog:default", + "chalk": "catalog:default", + "miniflare": "workspace:*", "tsdown": "0.16.3", - "typescript": "catalog:default" + "typescript": "catalog:default", + "vitest": "catalog:default" } } diff --git a/packages/remote-bindings/src/auth.test.ts b/packages/remote-bindings/src/auth.test.ts new file mode 100644 index 0000000000..00f9c05296 --- /dev/null +++ b/packages/remote-bindings/src/auth.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, it, vi } from "vitest"; +import { createRemoteBindingsAuth } from "./auth"; +import type { RemoteBindingsLogger } from "./logger"; + +const mocks = vi.hoisted(() => ({ + cfAuth: { source: "cf" }, + wranglerAuth: { source: "wrangler" }, + createCfAuth: vi.fn(), + createWranglerAuth: vi.fn(), +})); + +vi.mock("@cloudflare/workers-auth/cf", () => ({ + createCfAuth: mocks.createCfAuth.mockReturnValue(mocks.cfAuth), +})); + +vi.mock("@cloudflare/workers-auth/wrangler", () => ({ + createWranglerAuth: mocks.createWranglerAuth.mockReturnValue( + mocks.wranglerAuth + ), +})); + +const originalCfAuth = process.env.CLOUDFLARE_CF_AUTH; + +function createTestLogger(): RemoteBindingsLogger { + return { + loggerLevel: "log", + debug: vi.fn(), + log: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + once: { + info: vi.fn(), + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + }; +} + +describe("createRemoteBindingsAuth", () => { + beforeEach(() => { + delete process.env.CLOUDFLARE_CF_AUTH; + }); + + afterEach(() => { + vi.clearAllMocks(); + if (originalCfAuth === undefined) { + delete process.env.CLOUDFLARE_CF_AUTH; + } else { + process.env.CLOUDFLARE_CF_AUTH = originalCfAuth; + } + }); + + it("uses Wrangler auth by default", ({ expect }) => { + const result = createRemoteBindingsAuth(createTestLogger()); + + expect(result).toEqual({ auth: mocks.wranglerAuth, useCfAuth: false }); + expect(mocks.createWranglerAuth).toHaveBeenCalledOnce(); + expect(mocks.createCfAuth).not.toHaveBeenCalled(); + }); + + it("uses CF auth when CLOUDFLARE_CF_AUTH is present", ({ expect }) => { + process.env.CLOUDFLARE_CF_AUTH = ""; + + const result = createRemoteBindingsAuth(createTestLogger()); + + expect(result).toEqual({ auth: mocks.cfAuth, useCfAuth: true }); + expect(mocks.createCfAuth).toHaveBeenCalledOnce(); + expect(mocks.createWranglerAuth).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/remote-bindings/src/auth.ts b/packages/remote-bindings/src/auth.ts new file mode 100644 index 0000000000..4b4a23ba8a --- /dev/null +++ b/packages/remote-bindings/src/auth.ts @@ -0,0 +1,57 @@ +import { inputPrompt } from "@cloudflare/cli-shared-helpers/interactive"; +import { createCfAuth } from "@cloudflare/workers-auth/cf"; +import { createWranglerAuth } from "@cloudflare/workers-auth/wrangler"; +import { isNonInteractiveOrCI, UserError } from "@cloudflare/workers-utils"; +import { version as packageVersion } from "../package.json"; +import type { RemoteBindingsLogger } from "./logger"; + +class NoDefaultValueProvided extends UserError { + constructor() { + super("This command cannot be run in a non-interactive context", { + telemetryMessage: "remote bindings prompt default missing", + }); + } +} + +export function createRemoteBindingsAuth(logger: RemoteBindingsLogger) { + const context = { + logger, + userAgent: `remote-bindings/${packageVersion}`, + async prompt(question: string) { + if (isNonInteractiveOrCI()) { + throw new NoDefaultValueProvided(); + } + return inputPrompt({ + type: "text", + question, + label: "Answer", + throwOnError: true, + }); + }, + async select( + question: string, + options: { choices: { title: string; value: string }[] } + ) { + if (isNonInteractiveOrCI()) { + throw new NoDefaultValueProvided(); + } + return inputPrompt({ + type: "select", + question, + label: "Account", + options: options.choices.map((choice) => ({ + label: choice.title, + value: choice.value, + })), + throwOnError: true, + }); + }, + isNoDefaultValueProvidedError: (error: unknown) => + error instanceof NoDefaultValueProvided, + }; + const useCfAuth = "CLOUDFLARE_CF_AUTH" in process.env; + return { + auth: useCfAuth ? createCfAuth(context) : createWranglerAuth(context), + useCfAuth, + }; +} diff --git a/packages/remote-bindings/src/index.ts b/packages/remote-bindings/src/index.ts index cb0ff5c3b5..df418124af 100644 --- a/packages/remote-bindings/src/index.ts +++ b/packages/remote-bindings/src/index.ts @@ -1 +1,15 @@ -export {}; +export { + maybeStartOrUpdateRemoteProxySession, + pickRemoteBindings, +} from "./maybe-start-or-update-session"; +export type { + RemoteBindingsContext, + RemoteProxySessionData, + WorkerConfigObject, +} from "./maybe-start-or-update-session"; +export { startRemoteProxySession } from "./start-remote-proxy-session"; +export type { RemoteBindingsLogger } from "./logger"; +export type { + RemoteProxySession, + StartRemoteProxySessionOptions, +} from "./start-remote-proxy-session"; diff --git a/packages/remote-bindings/src/logger.ts b/packages/remote-bindings/src/logger.ts new file mode 100644 index 0000000000..6754385c69 --- /dev/null +++ b/packages/remote-bindings/src/logger.ts @@ -0,0 +1,6 @@ +import type { Logger, LoggerLevel } from "@cloudflare/workers-utils"; + +export type RemoteBindingsLogger = Logger & { + loggerLevel: LoggerLevel; + once: NonNullable; +}; diff --git a/packages/remote-bindings/src/maybe-start-or-update-session.ts b/packages/remote-bindings/src/maybe-start-or-update-session.ts new file mode 100644 index 0000000000..b4d7f46d31 --- /dev/null +++ b/packages/remote-bindings/src/maybe-start-or-update-session.ts @@ -0,0 +1,181 @@ +import assert from "node:assert"; +import { createCfProfileStore } from "@cloudflare/workers-auth/cf"; +import { createWranglerProfileStore } from "@cloudflare/workers-auth/wrangler"; +import { getBindingLocalSupport } from "@cloudflare/workers-utils"; +import { createRemoteBindingsAuth } from "./auth"; +import { startRemoteProxySession } from "./start-remote-proxy-session"; +import type { RemoteBindingsLogger } from "./logger"; +import type { RemoteProxySession } from "./start-remote-proxy-session"; +import type { + AsyncHook, + Binding, + CfAccount, + Config, + StartDevWorkerInput, +} from "@cloudflare/workers-utils"; + +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; + }) + ); +} + +export type WorkerConfigObject = { + /** The name of the worker. */ + name?: string; + /** The Worker's bindings. */ + bindings: NonNullable; + /** If running in a non-public compliance region, set this here. */ + complianceRegion?: Config["compliance_region"]; + /** ID of the account owning the worker. */ + account_id?: Config["account_id"]; + /** Directory used to resolve the auth profile from directory bindings. */ + profileDir?: string; +}; + +export type RemoteProxySessionData = { + session: RemoteProxySession; + remoteBindings: Record; + auth?: AsyncHook; +}; + +export type RemoteBindingsContext = { + logger: RemoteBindingsLogger; +}; + +/** Potentially starts or updates a remote proxy session. */ +export async function maybeStartOrUpdateRemoteProxySession( + workerConfigObject: WorkerConfigObject, + preExistingRemoteProxySessionData?: RemoteProxySessionData | null, + auth?: AsyncHook, + context?: RemoteBindingsContext, + startSession: typeof startRemoteProxySession = startRemoteProxySession +): Promise { + const remoteBindings = pickRemoteBindings(workerConfigObject.bindings); + const authSameAsBefore = deepStrictEqual( + auth, + preExistingRemoteProxySessionData?.auth + ); + let remoteProxySession = preExistingRemoteProxySessionData?.session; + + if (!authSameAsBefore) { + if (preExistingRemoteProxySessionData?.session) { + await preExistingRemoteProxySessionData.session.dispose(); + } + + remoteProxySession = await startSession(remoteBindings, { + workerName: workerConfigObject.name, + complianceRegion: workerConfigObject.complianceRegion, + auth: getAuthHook( + auth, + workerConfigObject.account_id + ? { account_id: workerConfigObject.account_id } + : undefined, + workerConfigObject.profileDir, + context?.logger + ), + logger: context?.logger, + }); + } else { + const remoteBindingsAreSameAsBefore = deepStrictEqual( + remoteBindings, + preExistingRemoteProxySessionData?.remoteBindings + ); + + if (!remoteBindingsAreSameAsBefore) { + if (!remoteProxySession) { + if (Object.keys(remoteBindings).length > 0) { + remoteProxySession = await startSession(remoteBindings, { + workerName: workerConfigObject.name, + complianceRegion: workerConfigObject.complianceRegion, + auth: getAuthHook( + auth, + workerConfigObject.account_id + ? { account_id: workerConfigObject.account_id } + : undefined, + workerConfigObject.profileDir, + context?.logger + ), + logger: context?.logger, + }); + } + } else { + await remoteProxySession.updateBindings(remoteBindings); + } + } + } + + await remoteProxySession?.ready; + if (!remoteProxySession) { + return null; + } + return { + session: remoteProxySession, + remoteBindings, + auth, + }; +} + +/** + * Gets the auth hook to use for the remote proxy session, this is either the user provided auth + * hook if there is one, or an ad-hoc hook created using the account_id from the user's wrangler + * config file otherwise. + * + * @param auth the auth hook provided by the user if any + * @param config the user's wrangler config if any + * @param profileDir working directory used to resolve the auth profile from directory bindings, + * falls back to `process.cwd()` when not provided + * @returns the auth hook to pass to the startRemoteProxy session function if any + */ +function getAuthHook( + auth: AsyncHook | undefined, + config: Pick | undefined, + profileDir: string | undefined, + logger?: RemoteBindingsLogger +): AsyncHook | undefined { + if (!logger) { + throw new Error("A logger is required to resolve remote binding auth"); + } + const { auth: remoteBindingsAuth, useCfAuth } = + createRemoteBindingsAuth(logger); + const profileStore = useCfAuth + ? createCfProfileStore({ logger }) + : createWranglerProfileStore({ logger }); + const profile = profileStore.resolve({ + cwd: profileDir ?? process.cwd(), + }); + remoteBindingsAuth.setProfile(profile); + if (auth) { + return auth; + } + + if (config?.account_id) { + return async () => { + return { + accountId: await remoteBindingsAuth.requireAuth(config), + apiToken: remoteBindingsAuth.requireApiToken(), + }; + }; + } + + return undefined; +} + +function deepStrictEqual(source: unknown, target: unknown): boolean { + try { + assert.deepStrictEqual(source, target); + return true; + } catch { + return false; + } +} diff --git a/packages/remote-bindings/src/start-remote-proxy-session.ts b/packages/remote-bindings/src/start-remote-proxy-session.ts new file mode 100644 index 0000000000..1ade0a2712 --- /dev/null +++ b/packages/remote-bindings/src/start-remote-proxy-session.ts @@ -0,0 +1,180 @@ +import events from "node:events"; +import { fileURLToPath } from "node:url"; +import { UserError } from "@cloudflare/workers-utils"; +import chalk from "chalk"; +import { DeferredPromise } from "miniflare"; +import { startWorker } from "./start-worker"; +import type { RemoteBindingsLogger } from "./logger"; +import type { Worker } from "./start-worker"; +import type { + Config, + LoggerLevel, + StartDevWorkerInput, +} from "@cloudflare/workers-utils"; +import type { RemoteProxyConnectionString } from "miniflare"; + +type ErrorEvent = { + type: "error"; + reason: string; + cause: unknown; +}; + +export type StartRemoteProxySessionOptions = { + workerName?: string; + auth?: NonNullable["auth"]; + /** If running in a non-public compliance region, set this here. */ + complianceRegion?: Config["compliance_region"]; + logger?: RemoteBindingsLogger; +}; + +function isErrorEvent(error: unknown): error is ErrorEvent { + return ( + typeof error === "object" && + error !== null && + "type" in error && + error.type === "error" && + "reason" in error && + "cause" in error + ); +} + +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; +} + +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 { + options?.logger?.log(chalk.dim("⎔ Establishing remote connection...")); + const rawBindings = toRawBindings(bindings); + const proxyServerWorkerWranglerConfig = fileURLToPath( + new URL("../templates/remoteBindings/wrangler.jsonc", import.meta.url) + ); + const remoteBindingsWorkerPath = fileURLToPath( + new URL("./proxy-worker.js", import.meta.url) + ); + + 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(options?.logger?.loggerLevel ?? "error"), + }, + bindings: rawBindings, + }).catch((startWorkerError: unknown) => { + if (startWorkerError instanceof UserError) { + throw startWorkerError; + } + let errorMessage = startWorkerError; + if (startWorkerError instanceof Error) { + errorMessage = + startWorkerError.cause instanceof Error + ? startWorkerError.cause.message + : 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", (error) => { + maybeErrorPromise.resolve({ error }); + }); + const maybeError = await Promise.race([ + maybeErrorPromise, + worker.raw.proxy.localServerReady.promise, + ]); + + if (maybeError && maybeError.error) { + const details = formatRemoteProxySessionError(maybeError.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"] + ) => { + const reloadComplete = events.once(worker.raw, "reloadComplete"); + await worker.patchConfig({ bindings: toRawBindings(newBindings) }); + try { + await reloadComplete; + } catch (errorOrEvent) { + throw errorOrEvent instanceof Error + ? errorOrEvent + : new Error( + `RemoteProxySession.updateBindings failed during reload: ${ + (errorOrEvent as { reason?: string })?.reason ?? "unknown" + }`, + { cause: errorOrEvent } + ); + } + 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; +}; + +function toRawBindings(bindings: StartDevWorkerInput["bindings"]) { + return Object.fromEntries( + Object.entries(bindings ?? {}).map(([key, binding]) => [ + key, + { ...binding, raw: true }, + ]) + ); +} + +function getStartWorkerLogLevel(wranglerLogLevel: LoggerLevel): LoggerLevel { + switch (wranglerLogLevel) { + case "debug": + return "debug"; + case "none": + return "none"; + default: + return "error"; + } +} diff --git a/packages/remote-bindings/src/start-worker.ts b/packages/remote-bindings/src/start-worker.ts new file mode 100644 index 0000000000..7a21cd5ee1 --- /dev/null +++ b/packages/remote-bindings/src/start-worker.ts @@ -0,0 +1,19 @@ +import type { Binding, StartDevWorkerInput } from "@cloudflare/workers-utils"; +import type { EventEmitter } from "node:events"; + +export type Worker = { + ready: Promise; + url: Promise; + dispose(): Promise; + patchConfig(config: { bindings: Record }): Promise; + raw: EventEmitter & { + proxy: { + localServerReady: { promise: Promise }; + runtimeMessageMutex: { drained(): Promise }; + }; + }; +}; + +export function startWorker(_input: StartDevWorkerInput): Promise { + throw new Error("startWorker() is not implemented"); +} diff --git a/packages/wrangler/templates/remoteBindings/ProxyServerWorker.ts b/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts similarity index 100% rename from packages/wrangler/templates/remoteBindings/ProxyServerWorker.ts rename to packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts diff --git a/packages/wrangler/templates/remoteBindings/wrangler.jsonc b/packages/remote-bindings/templates/remoteBindings/wrangler.jsonc similarity index 100% rename from packages/wrangler/templates/remoteBindings/wrangler.jsonc rename to packages/remote-bindings/templates/remoteBindings/wrangler.jsonc diff --git a/packages/remote-bindings/tsconfig.json b/packages/remote-bindings/tsconfig.json index 43c3882de2..9f7ff72405 100644 --- a/packages/remote-bindings/tsconfig.json +++ b/packages/remote-bindings/tsconfig.json @@ -1,4 +1,7 @@ { "extends": "@cloudflare/workers-tsconfig/base.json", - "include": ["src"] + "compilerOptions": { + "types": ["@cloudflare/workers-types/experimental", "@types/node"] + }, + "include": ["src", "templates"] } diff --git a/packages/remote-bindings/tsdown.config.ts b/packages/remote-bindings/tsdown.config.ts index 0c9545f089..1e5e773076 100644 --- a/packages/remote-bindings/tsdown.config.ts +++ b/packages/remote-bindings/tsdown.config.ts @@ -1,11 +1,23 @@ import { defineConfig } from "tsdown"; -export default defineConfig({ - entry: { - index: "src/index.ts", +export default defineConfig([ + { + entry: { + index: "src/index.ts", + }, + platform: "node", + outDir: "dist", + dts: true, + tsconfig: "tsconfig.json", + external: ["miniflare", /^@cloudflare\/workers-utils/], }, - platform: "node", - outDir: "dist", - dts: true, - tsconfig: "tsconfig.json", -}); + { + entry: { + "proxy-worker": "templates/remoteBindings/ProxyServerWorker.ts", + }, + platform: "neutral", + outDir: "dist", + dts: false, + external: ["cloudflare:email", "cloudflare:workers"], + }, +]); diff --git a/packages/wrangler/package.json b/packages/wrangler/package.json index 6d6a9b8b6b..91944f2793 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/runtime-types": "workspace:*", "@cloudflare/types": "6.18.4", "@cloudflare/workers-auth": "workspace:*", diff --git a/packages/wrangler/src/api/remoteBindings/index.ts b/packages/wrangler/src/api/remoteBindings/index.ts index 1322bb88ff..092cec0fd2 100644 --- a/packages/wrangler/src/api/remoteBindings/index.ts +++ b/packages/wrangler/src/api/remoteBindings/index.ts @@ -1,221 +1,43 @@ -import assert from "node:assert"; -import { createWranglerProfileStore } from "@cloudflare/workers-auth/wrangler"; -import { - getBindingLocalSupport, - getCloudflareComplianceRegion, -} from "@cloudflare/workers-utils"; +import { maybeStartOrUpdateRemoteProxySession as maybeStartOrUpdateRemoteProxySessionFromPackage } from "@cloudflare/remote-bindings"; +import { getCloudflareComplianceRegion } from "@cloudflare/workers-utils"; import { readConfig } from "../../config"; import { logger } from "../../logger"; -import { requireApiToken, requireAuth, setProfile } from "../../user"; import { convertConfigBindingsToStartWorkerBindings } from "../startDevWorker"; -import { startRemoteProxySession } from "./start-remote-proxy-session"; -import type { CfAccount } from "../../dev/create-worker-preview"; import type { - AsyncHook, - Binding, - StartDevWorkerInput, -} from "../startDevWorker/types"; -import type { RemoteProxySession } from "./start-remote-proxy-session"; -import type { Config } from "@cloudflare/workers-utils"; + RemoteProxySessionData, + WorkerConfigObject, +} from "@cloudflare/remote-bindings"; +import type { AsyncHook, CfAccount } from "@cloudflare/workers-utils"; -export * from "./start-remote-proxy-session"; - -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"]; - }) - ); -} +export * from "@cloudflare/remote-bindings"; type WranglerConfigObject = { - /** The path to the wrangler config file */ path: string; - /** The target environment */ environment?: string; }; -type WorkerConfigObject = { - /** The name of the worker */ - name?: string; - /** The Worker's bindings */ - bindings: NonNullable; - /** If running in a non-public compliance region, set this here. */ - complianceRegion?: Config["compliance_region"]; - /** Id of the account owning the worker */ - account_id?: Config["account_id"]; - /** - * directory used to resolve the auth profile from directory bindings. - * Falls back to `process.cwd()` when not provided. - */ - profileDir?: string; -}; - -/** - * Utility for potentially starting or updating a remote proxy session. - * - * @param wranglerOrWorkerConfigObject either a file path to a wrangler configuration file or an object containing the name of - * the target worker alongside its bindings. - * @param preExistingRemoteProxySessionData the optional data of a pre-existing remote proxy session if there was one, this - * argument can be omitted or set to null if there is no pre-existing remote proxy session - * @param auth the authentication information for establishing the remote proxy connection - * @returns null if no existing remote proxy session was provided and one should not be created (because the worker is not - * defining any remote bindings), the data associated to the created/updated remote proxy session otherwise. - */ -export async function maybeStartOrUpdateRemoteProxySession( +export function maybeStartOrUpdateRemoteProxySession( wranglerOrWorkerConfigObject: WranglerConfigObject | WorkerConfigObject, - preExistingRemoteProxySessionData?: { - session: RemoteProxySession; - remoteBindings: Record; - auth?: AsyncHook | undefined; - } | null, - auth?: AsyncHook | undefined -): Promise<{ - session: RemoteProxySession; - remoteBindings: Record; - auth?: AsyncHook | undefined; -} | null> { - let config: Config | undefined; + preExistingRemoteProxySessionData?: RemoteProxySessionData | null, + auth?: AsyncHook +) { if ("path" in wranglerOrWorkerConfigObject) { - const wranglerConfigObject = wranglerOrWorkerConfigObject; - config = readConfig({ - config: wranglerConfigObject.path, - env: wranglerConfigObject.environment, + const config = readConfig({ + config: wranglerOrWorkerConfigObject.path, + env: wranglerOrWorkerConfigObject.environment, }); - wranglerOrWorkerConfigObject = { name: config.name ?? "worker", - complianceRegion: getCloudflareComplianceRegion(config), bindings: convertConfigBindingsToStartWorkerBindings(config) ?? {}, + complianceRegion: getCloudflareComplianceRegion(config), + account_id: config.account_id, }; } - const workerConfigObject = wranglerOrWorkerConfigObject; - - const remoteBindings = pickRemoteBindings(workerConfigObject.bindings); - - const authSameAsBefore = deepStrictEqual( + return maybeStartOrUpdateRemoteProxySessionFromPackage( + wranglerOrWorkerConfigObject, + preExistingRemoteProxySessionData, auth, - preExistingRemoteProxySessionData?.auth + { logger } ); - - let remoteProxySession = preExistingRemoteProxySessionData?.session; - - if (!authSameAsBefore) { - // The auth values have changed so we do need to restart a new remote proxy session - - if (preExistingRemoteProxySessionData?.session) { - await preExistingRemoteProxySessionData.session.dispose(); - } - - remoteProxySession = await startRemoteProxySession(remoteBindings, { - workerName: workerConfigObject.name, - complianceRegion: workerConfigObject.complianceRegion, - auth: getAuthHook( - auth, - workerConfigObject.account_id - ? { - account_id: workerConfigObject.account_id, - } - : config, - workerConfigObject.profileDir - ), - }); - } else { - // The auth values haven't changed so we can reuse the pre-existing session - - const remoteBindingsAreSameAsBefore = deepStrictEqual( - remoteBindings, - preExistingRemoteProxySessionData?.remoteBindings - ); - - // We only want to perform updates on the remote proxy session if the session's remote bindings have changed - if (!remoteBindingsAreSameAsBefore) { - if (!remoteProxySession) { - if (Object.keys(remoteBindings).length > 0) { - remoteProxySession = await startRemoteProxySession(remoteBindings, { - workerName: workerConfigObject.name, - complianceRegion: workerConfigObject.complianceRegion, - auth: getAuthHook( - auth, - workerConfigObject.account_id - ? { - account_id: workerConfigObject.account_id, - } - : config, - workerConfigObject.profileDir - ), - }); - } - } else { - // Note: we always call updateBindings even when there are zero remote bindings, in these - // cases we could terminate the remote session if we wanted, that's probably - // something to consider down the line - await remoteProxySession.updateBindings(remoteBindings); - } - } - } - - await remoteProxySession?.ready; - if (!remoteProxySession) { - return null; - } - return { - session: remoteProxySession, - remoteBindings, - auth, - }; -} - -/** - * Gets the auth hook to use for the remote proxy session, this is either the user provided auth - * hook if there is one, or an ad-hoc hook created using the account_id from the user's wrangler - * config file otherwise. - * - * @param auth the auth hook provided by the user if any - * @param config the user's wrangler config if any - * @param profileDir working directory used to resolve the auth profile from directory bindings, - * falls back to `process.cwd()` when not provided - * @returns the auth hook to pass to the startRemoteProxy session function if any - */ -function getAuthHook( - auth: AsyncHook | undefined, - config: Pick | undefined, - profileDir: string | undefined -): AsyncHook | undefined { - const profile = createWranglerProfileStore({ logger }).resolve({ - cwd: profileDir ?? process.cwd(), - }); - setProfile(profile); - if (auth) { - return auth; - } - - if (config?.account_id) { - return async () => { - return { - accountId: await requireAuth(config), - apiToken: requireApiToken(), - }; - }; - } - - return undefined; -} - -function deepStrictEqual(source: unknown, target: unknown): boolean { - try { - assert.deepStrictEqual(source, target); - return true; - } catch { - return false; - } } 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..52ecde205c 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 @@ -import events from "node:events"; -import path from "node:path"; -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"; - -export type StartRemoteProxySessionOptions = { - workerName?: string; - auth?: NonNullable["auth"]; - /** 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 - ); -} - -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}. - * - * @param error - the error or ErrorEvent to inspect - * @returns the first {@link RemoteSessionAuthenticationError} found, or - * `undefined` if none exists in the chain - */ -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; - } - - const details = formatRemoteProxySessionError(maybeError.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 }, - ]) - ); - - // `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"; - } -} +export * from "@cloudflare/remote-bindings"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 908323b46b..acc0790d25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2565,15 +2565,39 @@ importers: packages/remote-bindings: devDependencies: + '@cloudflare/cli-shared-helpers': + specifier: workspace:* + version: link:../cli + '@cloudflare/workers-auth': + specifier: workspace:* + version: link:../workers-auth '@cloudflare/workers-tsconfig': specifier: workspace:* version: link:../workers-tsconfig + '@cloudflare/workers-types': + specifier: catalog:default + version: 5.20260710.1 + '@cloudflare/workers-utils': + specifier: workspace:* + version: link:../workers-utils + capnweb: + specifier: catalog:default + version: 0.5.0 + chalk: + specifier: catalog:default + version: 5.3.0 + miniflare: + specifier: workspace:* + version: link:../miniflare 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/runtime-types: dependencies: @@ -4381,6 +4405,9 @@ importers: '@cloudflare/pages-shared': specifier: workspace:^ version: link:../pages-shared + '@cloudflare/remote-bindings': + specifier: workspace:* + version: link:../remote-bindings '@cloudflare/runtime-types': specifier: workspace:* version: link:../runtime-types From ee6e6c3833b748b0b94385e615cabaf63cdc1643 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Wed, 15 Jul 2026 11:20:00 +0100 Subject: [PATCH 03/34] snapshot copy of DevEnv --- packages/remote-bindings/package.json | 2 + packages/remote-bindings/src/start-worker.ts | 9 +- .../src/startDevWorker/BaseController.ts | 81 ++ .../src/startDevWorker/BundlerController.ts | 479 +++++++++++ .../src/startDevWorker/ConfigController.ts | 775 ++++++++++++++++++ .../src/startDevWorker/DevEnv.ts | 275 +++++++ .../src/startDevWorker/NoOpProxyController.ts | 14 + .../src/startDevWorker/NotImplementedError.ts | 19 + .../src/startDevWorker/ProxyController.ts | 684 ++++++++++++++++ .../startDevWorker/RemoteRuntimeController.ts | 517 ++++++++++++ .../src/startDevWorker/binding-utils.ts | 54 ++ .../startDevWorker/bundle-allowed-paths.ts | 115 +++ .../src/startDevWorker/devtools.ts | 13 + .../src/startDevWorker/events.ts | 160 ++++ .../src/startDevWorker/index.ts | 16 + .../src/startDevWorker/types.ts | 106 +++ .../src/startDevWorker/utils.ts | 391 +++++++++ pnpm-lock.yaml | 6 + 18 files changed, 3714 insertions(+), 2 deletions(-) create mode 100644 packages/remote-bindings/src/startDevWorker/BaseController.ts create mode 100644 packages/remote-bindings/src/startDevWorker/BundlerController.ts create mode 100644 packages/remote-bindings/src/startDevWorker/ConfigController.ts create mode 100644 packages/remote-bindings/src/startDevWorker/DevEnv.ts create mode 100644 packages/remote-bindings/src/startDevWorker/NoOpProxyController.ts create mode 100644 packages/remote-bindings/src/startDevWorker/NotImplementedError.ts create mode 100644 packages/remote-bindings/src/startDevWorker/ProxyController.ts create mode 100644 packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts create mode 100644 packages/remote-bindings/src/startDevWorker/binding-utils.ts create mode 100644 packages/remote-bindings/src/startDevWorker/bundle-allowed-paths.ts create mode 100644 packages/remote-bindings/src/startDevWorker/devtools.ts create mode 100644 packages/remote-bindings/src/startDevWorker/events.ts create mode 100644 packages/remote-bindings/src/startDevWorker/index.ts create mode 100644 packages/remote-bindings/src/startDevWorker/types.ts create mode 100644 packages/remote-bindings/src/startDevWorker/utils.ts diff --git a/packages/remote-bindings/package.json b/packages/remote-bindings/package.json index 4c404219d9..76e328cdb1 100644 --- a/packages/remote-bindings/package.json +++ b/packages/remote-bindings/package.json @@ -33,6 +33,8 @@ }, "devDependencies": { "@cloudflare/cli-shared-helpers": "workspace:*", + "@cloudflare/containers-shared": "workspace:*", + "@cloudflare/deploy-helpers": "workspace:*", "@cloudflare/workers-auth": "workspace:*", "@cloudflare/workers-tsconfig": "workspace:*", "@cloudflare/workers-types": "catalog:default", diff --git a/packages/remote-bindings/src/start-worker.ts b/packages/remote-bindings/src/start-worker.ts index 7a21cd5ee1..c197483fd3 100644 --- a/packages/remote-bindings/src/start-worker.ts +++ b/packages/remote-bindings/src/start-worker.ts @@ -1,3 +1,4 @@ +import { DevEnv } from "./startDevWorker"; import type { Binding, StartDevWorkerInput } from "@cloudflare/workers-utils"; import type { EventEmitter } from "node:events"; @@ -14,6 +15,10 @@ export type Worker = { }; }; -export function startWorker(_input: StartDevWorkerInput): Promise { - throw new Error("startWorker() is not implemented"); +export async function startWorker( + options: StartDevWorkerInput +): Promise { + const devEnv = new DevEnv(); + + return devEnv.startWorker(options); } diff --git a/packages/remote-bindings/src/startDevWorker/BaseController.ts b/packages/remote-bindings/src/startDevWorker/BaseController.ts new file mode 100644 index 0000000000..a0fbae6b63 --- /dev/null +++ b/packages/remote-bindings/src/startDevWorker/BaseController.ts @@ -0,0 +1,81 @@ +import { logger } from "../../logger"; +import type { + BundleCompleteEvent, + BundleStartEvent, + ConfigUpdateEvent, + DevRegistryUpdateEvent, + ErrorEvent, + PreviewTokenExpiredEvent, + ReloadCompleteEvent, + ReloadStartEvent, +} from "./events"; +import type { Miniflare } from "miniflare"; + +export type ControllerEvent = + | ErrorEvent + | ConfigUpdateEvent + | BundleStartEvent + | BundleCompleteEvent + | ReloadStartEvent + | ReloadCompleteEvent + | DevRegistryUpdateEvent + | PreviewTokenExpiredEvent; + +export interface ControllerBus { + dispatch(event: ControllerEvent): void; +} + +export abstract class Controller { + protected bus: ControllerBus; + #tearingDown = false; + + constructor(bus: ControllerBus) { + this.bus = bus; + } + + async teardown(): Promise { + this.#tearingDown = true; + } + + protected emitErrorEvent(event: ErrorEvent) { + if (this.#tearingDown) { + logger.debug("Suppressing error event during teardown"); + logger.debug(`Error in ${event.source}: ${event.reason}\n`, event.cause); + logger.debug("=> Error contextual data:", event.data); + return; + } + + this.bus.dispatch(event); + } +} + +export abstract class RuntimeController extends Controller { + // ****************** + // Event Handlers + // ****************** + + abstract onBundleStart(_: BundleStartEvent): void; + abstract onBundleComplete(_: BundleCompleteEvent): void; + abstract onPreviewTokenExpired(_: PreviewTokenExpiredEvent): void; + + // ********************* + // Runtime Accessors + // ********************* + abstract get mf(): Miniflare | undefined; + + // ********************* + // Event Dispatchers + // ********************* + + protected emitReloadStartEvent(data: ReloadStartEvent): void { + this.bus.dispatch(data); + } + + protected emitReloadCompleteEvent(data: ReloadCompleteEvent): void { + this.bus.dispatch(data); + } + + protected emitDevRegistryUpdateEvent(data: DevRegistryUpdateEvent): void { + this.bus.dispatch(data); + } +} diff --git a/packages/remote-bindings/src/startDevWorker/BundlerController.ts b/packages/remote-bindings/src/startDevWorker/BundlerController.ts new file mode 100644 index 0000000000..448bcd3480 --- /dev/null +++ b/packages/remote-bindings/src/startDevWorker/BundlerController.ts @@ -0,0 +1,479 @@ +import assert from "node:assert"; +import { readFileSync, realpathSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { extractBindingsOfType } from "@cloudflare/deploy-helpers"; +import { getWranglerTmpDir } from "@cloudflare/workers-utils"; +import { watch } from "chokidar"; +import { BuildFailure } from "../../deployment-bundle/build-failures"; +import { bundleWorker, shouldCheckFetch } from "../../deployment-bundle/bundle"; +import { getBundleType } from "../../deployment-bundle/bundle-type"; +import { + createModuleCollector, + getWrangler1xLegacyModuleReferences, +} from "../../deployment-bundle/module-collection"; +import { noBundleWorker } from "../../deployment-bundle/no-bundle-worker"; +import { runCustomBuild } from "../../deployment-bundle/run-custom-build"; +import { getAssetChangeMessage } from "../../dev"; +import { runBuild } from "../../dev/use-esbuild"; +import { logger } from "../../logger"; +import { isNavigatorDefined } from "../../navigator-user-agent"; +import { debounce } from "../../utils/debounce"; +import { isAbortError } from "../../utils/isAbortError"; +import { Controller } from "./BaseController"; +import { castErrorCause } from "./events"; +import type { BundleResult } from "../../deployment-bundle/bundle"; +import type { EsbuildBundle } from "../../dev/use-esbuild"; +import type { ConfigUpdateEvent } from "./events"; +import type { StartDevWorkerOptions } from "./types"; +import type { EphemeralDirectory, Entry } from "@cloudflare/workers-utils"; + +export class BundlerController extends Controller { + #currentBundle?: EsbuildBundle; + + #customBuildWatcher?: ReturnType; + + // Handle aborting in-flight custom builds as new ones come in from the filesystem watcher + #customBuildAborter = new AbortController(); + #activeCustomBuilds = new Set>(); + + #startCustomBuildRun(config: StartDevWorkerOptions, filePath: string) { + const buildPromise = this.#runCustomBuild(config, filePath); + this.#activeCustomBuilds.add(buildPromise); + void buildPromise + .finally(() => this.#activeCustomBuilds.delete(buildPromise)) + .catch(() => {}); + return buildPromise; + } + + async #runCustomBuild(config: StartDevWorkerOptions, filePath: string) { + // If a new custom build comes in, we need to cancel in-flight builds + this.#customBuildAborter.abort(); + this.#customBuildAborter = new AbortController(); + + // Since `this.#customBuildAborter` will change as new builds are scheduled, store the specific AbortController that will be used for this build + const buildAborter = this.#customBuildAborter; + const relativeFile = + path.relative(config.projectRoot, config.entrypoint) || "."; + logger.log(`The file ${filePath} changed, restarting build...`); + this.emitBundleStartEvent(config); + try { + await runCustomBuild( + config.entrypoint, + relativeFile, + { + cwd: config.build?.custom?.workingDirectory, + command: config.build?.custom?.command, + }, + config.config, + { wranglerCommand: "dev", signal: buildAborter.signal } + ); + if (buildAborter.signal.aborted) { + return; + } + assert(this.#tmpDir); + if (!config.build?.bundle) { + // if we're not bundling, let's just copy the entry to the destination directory + const destinationDir = this.#tmpDir.path; + writeFileSync( + path.join(destinationDir, path.basename(config.entrypoint)), + readFileSync(config.entrypoint, "utf-8") + ); + } + + const entry: Entry = { + file: config.entrypoint, + projectRoot: config.projectRoot, + configPath: config.config, + format: config.build.format, + moduleRoot: config.build.moduleRoot, + exports: config.build.exports, + }; + + const entryDirectory = path.dirname(config.entrypoint); + const moduleCollector = createModuleCollector({ + wrangler1xLegacyModuleReferences: getWrangler1xLegacyModuleReferences( + entryDirectory, + config.entrypoint + ), + entry, + // `moduleCollector` doesn't get used when `noBundle` is set, so + // `findAdditionalModules` always defaults to `false` + findAdditionalModules: config.build.findAdditionalModules ?? false, + rules: config.build.moduleRules, + }); + + const doBindings = extractBindingsOfType( + "durable_object_namespace", + config.bindings + ); + const workflowBindings = extractBindingsOfType( + "workflow", + config.bindings + ); + const bundleResult: Omit = !config.build?.bundle + ? await noBundleWorker( + entry, + config.build.moduleRules, + this.#tmpDir.path, + config.pythonModules?.exclude ?? [], + config.build.findAdditionalModules !== false + ) + : await bundleWorker(entry, this.#tmpDir.path, { + bundle: true, + additionalModules: [], + moduleCollector, + doBindings, + workflowBindings, + jsxFactory: config.build.jsxFactory, + jsxFragment: config.build.jsxFactory, + tsconfig: config.build.tsconfig, + minify: config.build.minify, + keepNames: config.build.keepNames ?? true, + nodejsCompatMode: config.build.nodejsCompatMode, + compatibilityDate: config.compatibilityDate, + compatibilityFlags: config.compatibilityFlags, + define: config.build.define, + checkFetch: shouldCheckFetch( + config.compatibilityDate, + config.compatibilityFlags + ), + alias: config.build.alias, + // We want to know if the build is for development or publishing + // This could potentially cause issues as we no longer have identical behaviour between dev and deploy? + targetConsumer: "dev", + local: !config.dev?.remote, + projectRoot: config.projectRoot, + defineNavigatorUserAgent: isNavigatorDefined( + config.compatibilityDate, + config.compatibilityFlags + ), + testScheduled: config.dev.testScheduled, + plugins: undefined, + + // Pages specific options used by wrangler pages commands + entryName: undefined, + inject: undefined, + isOutfile: undefined, + external: undefined, + + // We don't use esbuild watching for custom builds + watch: undefined, + + // sourcemap defaults to true in dev + sourcemap: undefined, + + metafile: undefined, + }); + if (buildAborter.signal.aborted) { + return; + } + const entrypointPath = realpathSync( + bundleResult?.resolvedEntryPointPath ?? config.entrypoint + ); + + this.emitBundleCompleteEvent(config, { + id: 0, + entry, + path: entrypointPath, + type: + bundleResult?.bundleType ?? + getBundleType(config.build.format, config.entrypoint), + modules: bundleResult.modules, + dependencies: bundleResult?.dependencies ?? {}, + sourceMapPath: bundleResult?.sourceMapPath, + sourceMapMetadata: bundleResult?.sourceMapMetadata, + entrypointSource: readFileSync(entrypointPath, "utf8"), + }); + } catch (err) { + if (buildAborter.signal.aborted || isAbortError(err)) { + return; + } + this.emitErrorEvent({ + type: "error", + reason: "Custom build failed", + cause: castErrorCause(err), + source: "BundlerController", + data: { config, filePath }, + }); + } + } + + async #startCustomBuild(config: StartDevWorkerOptions) { + await this.#customBuildWatcher?.close(); + this.#customBuildWatcher = undefined; + this.#customBuildAborter?.abort(); + + if (!config.build?.custom?.command) { + return; + } + + const pathsToWatch = config.build.custom.watch; + + // This is always present if a custom command is provided, defaulting to `./src` + assert(pathsToWatch, "config.build.custom.watch"); + + if (config.dev.watch === false) { + await this.#startCustomBuildRun(config, String(pathsToWatch)); + return; + } + + this.#customBuildWatcher = watch(pathsToWatch, { + persistent: true, + // The initial custom build is always done in getEntry() + ignoreInitial: true, + }); + this.#customBuildWatcher.on("ready", () => { + void this.#startCustomBuildRun(config, String(pathsToWatch)); + }); + + this.#customBuildWatcher.on( + "all", + (_event, filePath) => void this.#startCustomBuildRun(config, filePath) + ); + } + + #bundlerCleanup?: ReturnType; + #bundleBuildAborter = new AbortController(); + + async #startBundle(config: StartDevWorkerOptions) { + await this.#bundlerCleanup?.(); + // If a new bundle build comes in, we need to cancel in-flight builds + this.#bundleBuildAborter.abort(); + this.#bundleBuildAborter = new AbortController(); + + // Since `this.#customBuildAborter` will change as new builds are scheduled, store the specific AbortController that will be used for this build + const buildAborter = this.#bundleBuildAborter; + + if (config.build?.custom?.command) { + return; + } + assert(this.#tmpDir); + const entry: Entry = { + file: config.entrypoint, + projectRoot: config.projectRoot, + configPath: config.config, + format: config.build.format, + moduleRoot: config.build.moduleRoot, + exports: config.build.exports, + name: config.name, + }; + + const durableObjects = { + bindings: extractBindingsOfType( + "durable_object_namespace", + config.bindings + ), + }; + const workflows = extractBindingsOfType("workflow", config.bindings); + + this.#bundlerCleanup = runBuild( + { + entry, + destination: this.#tmpDir.path, + jsxFactory: config.build?.jsxFactory, + jsxFragment: config.build?.jsxFragment, + processEntrypoint: Boolean(config.build?.processEntrypoint), + additionalModules: config.build?.additionalModules ?? [], + rules: config.build.moduleRules, + tsconfig: config.build?.tsconfig, + minify: config.build?.minify, + keepNames: config.build?.keepNames ?? true, + nodejsCompatMode: config.build.nodejsCompatMode, + compatibilityDate: config.compatibilityDate, + compatibilityFlags: config.compatibilityFlags, + define: config.build.define, + alias: config.build.alias, + noBundle: !config.build?.bundle, + findAdditionalModules: config.build?.findAdditionalModules, + durableObjects, + workflows, + local: !config.dev?.remote, + // startDevWorker only applies to "dev" + targetConsumer: "dev", + testScheduled: Boolean(config.dev?.testScheduled), + projectRoot: config.projectRoot, + onStart: () => { + this.emitBundleStartEvent(config); + }, + onRebuildError: (errors, warnings) => { + if (!buildAborter.signal.aborted) { + // Watch-mode rebuild failures route through the same error + // path as initial-build failures, so DevEnv logs them + // (logBuildFailure) and emits `buildFailed` symmetrically. + this.emitErrorEvent({ + type: "error", + reason: "Failed to rebuild the Worker", + cause: new BuildFailure( + `Build failed with ${errors.length} error(s)`, + errors, + warnings + ), + source: "BundlerController", + data: undefined, + }); + } + }, + checkFetch: shouldCheckFetch( + config.compatibilityDate, + config.compatibilityFlags + ), + watch: config.dev.watch ?? true, + defineNavigatorUserAgent: isNavigatorDefined( + config.compatibilityDate, + config.compatibilityFlags + ), + pythonModulesExcludes: config.pythonModules?.exclude ?? [], + }, + (cb) => { + const newBundle = cb(this.#currentBundle); + if (!buildAborter.signal.aborted) { + this.emitBundleCompleteEvent(config, newBundle); + this.#currentBundle = newBundle; + } + }, + (err) => { + if (!buildAborter.signal.aborted) { + this.emitErrorEvent({ + type: "error", + reason: "Failed to construct initial bundle", + cause: castErrorCause(err), + source: "BundlerController", + data: undefined, + }); + } + } + ); + } + + #assetsWatcher?: ReturnType; + async #ensureWatchingAssets(config: StartDevWorkerOptions) { + await this.#assetsWatcher?.close(); + this.#assetsWatcher = undefined; + + const debouncedRefreshBundle = debounce(() => { + if (this.#currentBundle) { + this.emitBundleCompleteEvent(config, this.#currentBundle); + } + }); + + if (config.dev.watch !== false && config.assets?.directory) { + const assetsDir = config.assets.directory; + const watcher = watch(assetsDir, { + persistent: true, + ignoreInitial: true, + }) + .on("all", async (eventName, filePath) => { + const message = getAssetChangeMessage(eventName, filePath); + logger.debug(`🌀 ${message}...`); + debouncedRefreshBundle(); + }) + .on("error", (err) => { + const errnoError = err as NodeJS.ErrnoException; + if (errnoError.code === "EMFILE") { + logger.warn( + `Assets directory watcher hit a platform limit and has been disabled.\n` + + `Hot-reloading will not reflect changes to files in ${assetsDir}.\n` + + `This can occur when watching very large assets directory trees.\n` + + `To work around this, reduce the number of subdirectories under ${assetsDir} by flattening or restructuring the assets directory.` + ); + } else { + logger.warn( + `Assets directory watcher encountered an error and has been disabled.\n` + + `Hot-reloading will not reflect changes to files in ${assetsDir}.\n` + + `Watcher error: ${err.message}` + ); + } + void watcher.close(); + if (this.#assetsWatcher === watcher) { + this.#assetsWatcher = undefined; + } + }); + this.#assetsWatcher = watcher; + } + } + + #tmpDir?: EphemeralDirectory; + + onConfigUpdate(event: ConfigUpdateEvent) { + this.#tmpDir?.remove(); + try { + this.#tmpDir = getWranglerTmpDir(event.config.projectRoot, "dev"); + } catch (e) { + this.emitErrorEvent({ + type: "error", + reason: "Failed to create temporary directory to store built files.", + cause: castErrorCause(e), + source: "BundlerController", + data: undefined, + }); + } + + void this.#startCustomBuild(event.config).catch((err) => { + this.emitErrorEvent({ + type: "error", + reason: "Failed to run custom build", + cause: castErrorCause(err), + source: "BundlerController", + data: { config: event.config }, + }); + }); + void this.#startBundle(event.config).catch((err) => { + this.emitErrorEvent({ + type: "error", + reason: "Failed to start bundler", + cause: castErrorCause(err), + source: "BundlerController", + data: { config: event.config }, + }); + }); + void this.#ensureWatchingAssets(event.config).catch((err) => { + this.emitErrorEvent({ + type: "error", + reason: "Failed to watch assets", + cause: castErrorCause(err), + source: "BundlerController", + data: { config: event.config }, + }); + }); + } + + override async teardown() { + logger.debug("BundlerController teardown beginning..."); + await super.teardown(); + this.#customBuildAborter?.abort(); + const activeCustomBuilds = Array.from(this.#activeCustomBuilds, (build) => + build.catch(() => {}) + ); + // Abort any in-flight esbuild build so that a finishing build doesn't + // emit `bundleComplete`/`bundleStart` into a torn-down event bus. + // `Controller.#tearingDown` already suppresses error events, but not + // the bundler success events, which go straight through `bus.dispatch`. + this.#bundleBuildAborter?.abort(); + await Promise.all([ + // Must run before `#tmpDir.remove()` so that the esbuild watcher + // can dispose cleanly. Removing the directory first would make + // esbuild's watcher fail a rebuild with "Could not resolve + // ...middleware-loader.entry.ts" during teardown. + this.#bundlerCleanup?.(), + this.#customBuildWatcher?.close(), + ...activeCustomBuilds, + this.#assetsWatcher?.close(), + ]); + // Defence-in-depth: `bundle.ts`'s `stop()` normally removes the tmp + // dir on our behalf, but it may have never been assigned (e.g. when + // running a custom build, or when the initial build threw). Remove + // after esbuild cleanup to avoid the race described above. + this.#tmpDir?.remove(); + logger.debug("BundlerController teardown complete"); + } + + emitBundleStartEvent(config: StartDevWorkerOptions) { + this.bus.dispatch({ type: "bundleStart", config }); + } + emitBundleCompleteEvent( + config: StartDevWorkerOptions, + bundle: EsbuildBundle + ) { + this.bus.dispatch({ type: "bundleComplete", config, bundle }); + } +} diff --git a/packages/remote-bindings/src/startDevWorker/ConfigController.ts b/packages/remote-bindings/src/startDevWorker/ConfigController.ts new file mode 100644 index 0000000000..06284ffd5f --- /dev/null +++ b/packages/remote-bindings/src/startDevWorker/ConfigController.ts @@ -0,0 +1,775 @@ +import assert from "node:assert"; +import path from "node:path"; +import { resolveDockerHost } from "@cloudflare/containers-shared"; +import { extractBindingsOfType } from "@cloudflare/deploy-helpers"; +import { + configFileName, + formatConfigSnippet, + getTodaysCompatDate, + getDisableConfigWatching, + getDockerPath, + UserError, +} from "@cloudflare/workers-utils"; +import { watch } from "chokidar"; +import { getWorkerRegistry } from "miniflare"; +import { getAssetsOptions, validateAssetsArgsAndConfig } from "../../assets"; +import { fillOpenAPIConfiguration } from "../../cloudchamber/common"; +import { readConfig, readNewConfig } from "../../config"; +import { containersScope } from "../../containers"; +import { getNormalizedContainerOptions } from "../../containers/config"; +import { getEntry } from "../../deployment-bundle/entry"; +import { getBindings, getHostAndRoutes, getInferredHost } from "../../dev"; +import { getDurableObjectClassNameToUseSQLiteMap } from "../../dev/class-names-sqlite"; +import { getLocalPersistencePath } from "../../dev/get-local-persistence-path"; +import { getFlag } from "../../experimental-flags"; +import { logger, runWithLogLevel } from "../../logger"; +import { checkTypesDiff } from "../../type-generation/helpers"; +import { regenerateNewConfigTypes } from "../../type-generation/new-config"; +import { + loginOrRefreshIfRequired, + requireApiToken, + requireAuth, +} from "../../user"; +import { + DEFAULT_INSPECTOR_PORT, + DEFAULT_LOCAL_PORT, +} from "../../utils/constants"; +import { getRules } from "../../utils/getRules"; +import { getScriptName } from "../../utils/getScriptName"; +import { memoizeGetPort } from "../../utils/memoizeGetPort"; +import { printBindings } from "../../utils/print-bindings"; +import { getZoneIdForPreview } from "../../zones"; +import { Controller } from "./BaseController"; +import { castErrorCause } from "./events"; +import { unwrapHook } from "./utils"; +import type { NewConfig, ReadConfigCommandArgs } from "../../config"; +import type { DevRegistryUpdateEvent } from "./events"; +import type { + StartDevWorkerInput, + StartDevWorkerOptions, + Trigger, + WranglerStartDevWorkerInput, +} from "./types"; +import type { LoginOrRefreshFailureReason } from "@cloudflare/workers-auth"; +import type { CfUnsafe, Config } from "@cloudflare/workers-utils"; +import type { WorkerRegistry } from "miniflare"; + +const getInspectorPort = memoizeGetPort(DEFAULT_INSPECTOR_PORT, "127.0.0.1"); +const getLocalPort = memoizeGetPort(DEFAULT_LOCAL_PORT, "localhost"); + +async function resolveInspectorConfig( + config: Config, + input: WranglerStartDevWorkerInput +): Promise { + if (input.dev?.inspector === false) { + return false; + } + const hostname = + input.dev?.inspector?.hostname ?? config.dev.inspector_ip ?? "127.0.0.1"; + const port = + input.dev?.inspector?.port ?? + config.dev.inspector_port ?? + (await getInspectorPort(hostname)); + return { + hostname, + port, + }; +} + +async function resolveDevConfig( + config: Config, + input: WranglerStartDevWorkerInput +): Promise { + const auth = async () => { + if (input.dev?.remote) { + const result = await loginOrRefreshIfRequired(config); + if (!result.loggedIn) { + const errorMessage = getLoginOrRefreshFailureErrorMessage( + input.dev.remote, + result.reason + ); + throw new UserError(errorMessage, { + telemetryMessage: "api dev remote login required", + }); + } + } + + if (input.dev?.auth) { + return unwrapHook(input.dev.auth, config); + } + + return { + accountId: await requireAuth(config), + apiToken: requireApiToken(), + }; + }; + + const localPersistencePath = getLocalPersistencePath( + input.dev?.persist, + config + ); + + const { host, routes } = await getHostAndRoutes( + { + host: input.dev?.origin?.hostname, + routes: input.triggers?.filter( + (t): t is Extract => t.type === "route" + ), + assets: input?.assets, + }, + config + ); + + // TODO: Remove this hack once the React flow is removed + // This function throws if the zone ID can't be found given the provided host and routes + // However, it's called as part of initialising a preview session, which is nested deep within + // React/Ink and useEffect()s in `--no-x-dev-env` mode which swallow the error and turn it into a logged warning. + // Because it's a non-recoverable user error, we want it to exit the Wrangler process early to allow the user to fix it. + // Calling it here forces the error to be thrown where it will correctly exit the Wrangler process. + if (input.dev?.remote) { + const { accountId } = await auth(); + assert(accountId, "Account ID must be provided for remote dev"); + await getZoneIdForPreview(config, { host, routes, accountId }); + } + + const initialIp = input.dev?.server?.hostname ?? config.dev.ip; + + const initialIpListenCheck = initialIp === "*" ? "0.0.0.0" : initialIp; + + const useContainers = + config.dev.enable_containers && config.containers?.length; + + return { + auth, + remote: input.dev?.remote, + server: { + hostname: input.dev?.server?.hostname || config.dev.ip, + port: + input.dev?.server?.port ?? + config.dev.port ?? + (await getLocalPort(initialIpListenCheck)), + secure: + input.dev?.server?.secure ?? config.dev.local_protocol === "https", + httpsKeyPath: input.dev?.server?.httpsKeyPath, + httpsCertPath: input.dev?.server?.httpsCertPath, + }, + inspector: await resolveInspectorConfig(config, input), + origin: { + secure: + input.dev?.origin?.secure ?? config.dev.upstream_protocol === "https", + hostname: + host ?? + ((input.dev?.inferOriginFromRoutes ?? true) + ? getInferredHost(routes, config.configPath) + : undefined), + }, + watch: input.dev?.watch, + liveReload: input.dev?.liveReload || false, + testScheduled: input.dev?.testScheduled, + outboundService: input.dev?.outboundService, + structuredLogsHandler: input.dev?.structuredLogsHandler, + // absolute resolved path + persist: localPersistencePath, + registry: input.dev?.registry, + multiworkerPrimary: input.dev?.multiworkerPrimary, + inferOriginFromRoutes: input.dev?.inferOriginFromRoutes ?? true, + routeRequestsByRoutes: input.dev?.routeRequestsByRoutes ?? false, + enableContainers: + input.dev?.enableContainers ?? config.dev.enable_containers, + dockerPath: input.dev?.dockerPath ?? getDockerPath(), + containerEngine: useContainers + ? (input.dev?.containerEngine ?? + config.dev.container_engine ?? + resolveDockerHost(input.dev?.dockerPath ?? getDockerPath())) + : undefined, + containerBuildId: input.dev?.containerBuildId, + generateTypes: input.dev?.generateTypes ?? config.dev.generate_types, + tunnel: input.dev?.tunnel, + } satisfies StartDevWorkerOptions["dev"]; +} + +/** + * Maps a {@link LoginOrRefreshFailureReason} to a user-facing error message + * with actionable remediation steps (e.g. re-running `wrangler login`, + * setting `CLOUDFLARE_API_TOKEN`, or falling back to local dev). + * + * @param remoteMode - The remote dev mode that was requested. When + * `"minimal"` (remote-bindings mode), the suggestion to fall back to + * `--local` dev is omitted because local dev is not a useful alternative. + * @param failureReason - The specific {@link LoginOrRefreshFailureReason} + * that describes why login or token refresh could not succeed. + * @returns A formatted error message string prefixed with a generic failure + * summary, followed by reason-specific guidance and a `wrangler whoami` tip. + */ +function getLoginOrRefreshFailureErrorMessage( + remoteMode: boolean | "minimal", + failureReason: LoginOrRefreshFailureReason +) { + const errorMessagePrefix = "Could not start remote dev session."; + const localFallback = + remoteMode === "minimal" + ? "" // Remote bindings mode — local dev is not a useful fallback + : "\n - Or use `wrangler dev --local` to develop locally (remote resources like KV, D1, etc. will use local simulators instead)."; + const whoamiTip = + "\n\nYou can run `wrangler whoami` to check your current authentication status."; + const errorMessageBodies = { + "no-credentials-non-interactive": + " No credentials found, and the environment is non-interactive so browser login cannot be started.\n" + + "Either:\n" + + " - Set a CLOUDFLARE_API_TOKEN environment variable\n" + + ` - Run \`wrangler login\` in an interactive terminal first${localFallback}${whoamiTip}`, + "no-credentials-login-failed": + " No credentials found and the login attempt was unsuccessful.\n" + + "Either:\n" + + ` - Run \`wrangler login\` to try again${localFallback}${whoamiTip}`, + "token-expired-non-interactive": + " Your auth token has expired and could not be refreshed, and the environment is non-interactive so browser login cannot be started.\n" + + "Either:\n" + + " - Run `wrangler login` in an interactive terminal\n" + + ` - Set a CLOUDFLARE_API_TOKEN environment variable${localFallback}${whoamiTip}`, + "token-expired-login-failed": + " Your auth token has expired and could not be refreshed, and the login attempt was unsuccessful.\n" + + "Either:\n" + + ` - Run \`wrangler login\` to try again${localFallback}${whoamiTip}`, + }; + const errorMessageBody = errorMessageBodies[failureReason]; + const errorMessage = errorMessagePrefix + errorMessageBody; + return errorMessage; +} + +async function resolveBindings( + config: Config, + input: StartDevWorkerInput +): Promise<{ + bindings: StartDevWorkerOptions["bindings"]; + unsafe?: CfUnsafe; + printCurrentBindings: (registry: WorkerRegistry | null) => void; +}> { + const bindings = getBindings( + config, + input.env, + input.envFiles, + !input.dev?.remote, + input.bindings, + input.defaultBindings + ); + + // Create a print function that captures the current bindings context + const printCurrentBindings = (registry: WorkerRegistry | null) => { + printBindings( + bindings, + input.tailConsumers ?? config.tail_consumers, + input.streamingTailConsumers ?? config.streaming_tail_consumers, + config.containers, + { + registry, + local: !input.dev?.remote, + isMultiWorker: getFlag("MULTIWORKER"), + remoteBindingsDisabled: input.dev?.remote === false, + name: config.name, + } + ); + }; + + // Print the initial bindings table + printCurrentBindings( + input.dev?.registry ? getWorkerRegistry(input.dev.registry) : null + ); + + return { + bindings: { + ...input.bindings, + ...bindings, + }, + unsafe: { + bindings: config.unsafe.bindings, + metadata: config.unsafe.metadata, + capnp: config.unsafe.capnp, + }, + printCurrentBindings, + }; +} + +async function resolveTriggers( + config: Config, + input: StartDevWorkerInput +): Promise { + const { routes } = await getHostAndRoutes( + { + host: input.dev?.origin?.hostname, + routes: input.triggers?.filter( + (t): t is Extract => t.type === "route" + ), + assets: input?.assets, + }, + config + ); + + const devRoutes = + routes?.map>((r) => + typeof r === "string" + ? { + type: "route", + pattern: r, + } + : { type: "route", ...r } + ) ?? []; + const queueConsumers = + config.queues.consumers?.map>( + (c) => ({ + ...c, + type: "queue-consumer", + }) + ) ?? []; + + const crons = + config.triggers.crons?.map>((c) => ({ + cron: c, + type: "cron", + })) ?? []; + + return [...devRoutes, ...queueConsumers, ...crons]; +} + +async function resolveConfig( + config: Config, + input: StartDevWorkerInput, + // If the worker name was previously autogenerated, keep the same one + previousName: string | undefined, + newConfigEnabled: boolean +): Promise<{ + config: StartDevWorkerOptions; + printCurrentBindings: (registry: WorkerRegistry | null) => void; +}> { + if ( + config.pages_build_output_dir && + input.dev?.multiworkerPrimary === false + ) { + throw new UserError( + `You cannot use a Pages project as a service binding target.\nIf you are trying to develop Pages and Workers together, please use \`wrangler pages dev\`. Note the first config file specified must be for the Pages project`, + { telemetryMessage: "api dev pages service binding target invalid" } + ); + } + const legacySite = unwrapHook(input.legacy?.site, config); + + const entry = await getEntry( + { + script: input.entrypoint, + moduleRoot: input.build?.moduleRoot, + // getEntry only needs to know if assets was specified. + // The actual value is not relevant here, which is why not passing + // the entire Assets object is fine. + assets: input?.assets, + }, + config, + "dev" + ); + + const nodejsCompatMode = unwrapHook(input.build?.nodejsCompatMode, config); + + const { bindings, unsafe, printCurrentBindings } = await resolveBindings( + config, + input + ); + + const assetsOptions = getAssetsOptions({ + args: { + assets: input?.assets, + script: input.entrypoint, + }, + config, + }); + + const resolved = { + name: + getScriptName({ name: input.name, env: input.env }, config) ?? + previousName ?? + crypto.randomUUID(), + config: config.configPath, + compatibilityDate: getDevCompatibilityDate( + entry.projectRoot, + config, + input.compatibilityDate + ), + compatibilityFlags: input.compatibilityFlags ?? config.compatibility_flags, + complianceRegion: input.complianceRegion ?? config.compliance_region, + pythonModules: { + exclude: input.pythonModules?.exclude ?? config.python_modules.exclude, + }, + entrypoint: entry.file, + projectRoot: entry.projectRoot, + bindings, + migrations: input.migrations ?? config.migrations, + exports: input.exports ?? config.exports, + sendMetrics: input.sendMetrics ?? config.send_metrics, + triggers: await resolveTriggers(config, input), + env: input.env, + envFiles: input.envFiles, + build: { + alias: input.build?.alias ?? config.alias, + additionalModules: input.build?.additionalModules ?? [], + processEntrypoint: Boolean(input.build?.processEntrypoint), + bundle: input.build?.bundle ?? !config.no_bundle, + findAdditionalModules: + input.build?.findAdditionalModules ?? config.find_additional_modules, + moduleRoot: entry.moduleRoot, + moduleRules: input.build?.moduleRules ?? getRules(config), + + minify: input.build?.minify ?? config.minify, + keepNames: input.build?.keepNames ?? config.keep_names, + define: { ...config.define, ...input.build?.define }, + custom: { + command: input.build?.custom?.command ?? config.build?.command, + watch: input.build?.custom?.watch ?? config.build?.watch_dir, + workingDirectory: + input.build?.custom?.workingDirectory ?? config.build?.cwd, + }, + format: entry.format, + nodejsCompatMode: nodejsCompatMode ?? null, + jsxFactory: input.build?.jsxFactory || config.jsx_factory, + jsxFragment: input.build?.jsxFragment || config.jsx_fragment, + tsconfig: input.build?.tsconfig ?? config.tsconfig, + exports: entry.exports, + }, + containers: await getNormalizedContainerOptions(config, {}), + dev: await resolveDevConfig(config, input), + legacy: { + site: legacySite, + }, + unsafe: { + capnp: input.unsafe?.capnp ?? unsafe?.capnp, + metadata: input.unsafe?.metadata ?? unsafe?.metadata, + }, + assets: assetsOptions, + tailConsumers: config.tail_consumers ?? [], + experimental: {}, + streamingTailConsumers: config.streaming_tail_consumers ?? [], + } satisfies StartDevWorkerOptions; + + if ( + extractBindingsOfType("analytics_engine", resolved.bindings).length && + !resolved.dev.remote && + resolved.build.format === "service-worker" + ) { + logger.once.warn( + "Analytics Engine is not supported locally when using the service-worker format. Please migrate to the module worker format: https://developers.cloudflare.com/workers/reference/migrate-to-module-workers/" + ); + } + + validateAssetsArgsAndConfig(resolved); + + const services = extractBindingsOfType("service", resolved.bindings); + if (services && services.length > 0 && resolved.dev?.remote) { + logger.once.warn( + `This worker is bound to live services: ${services + .map( + (service) => + `${service.binding} (${service.service}${ + service.environment ? `@${service.environment}` : "" + }${service.entrypoint ? `#${service.entrypoint}` : ""})` + ) + .join(", ")}` + ); + } + + if (!resolved.dev?.origin?.secure && resolved.dev?.remote) { + logger.once.warn( + "Setting upstream-protocol to http is not currently supported for remote mode.\n" + + "If this is required in your project, please add your use case to the following issue:\n" + + "https://github.com/cloudflare/workers-sdk/issues/583" + ); + } + + // for pulling containers, we need to make sure the OpenAPI config for the + // container API client is properly set so that we can get the correct permissions + // from the cloudchamber API to pull from the repository. + const needsPulling = resolved.containers.some( + (c) => "image_uri" in c && c.image_uri + ); + if (needsPulling && !resolved.dev.remote) { + await fillOpenAPIConfiguration(config, containersScope); + } + + // TODO(queues) support remote wrangler dev + const queues = extractBindingsOfType("queue", resolved.bindings); + if ( + resolved.dev.remote && + (queues?.length || + resolved.triggers?.some((t) => t.type === "queue-consumer")) + ) { + logger.once.warn( + "Queues are not yet supported in wrangler dev remote mode." + ); + } + + if (resolved.dev.remote) { + // We're in remote mode (`--remote`) + + if ( + resolved.dev.enableContainers && + resolved.containers && + resolved.containers.length > 0 + ) { + logger.once.warn( + "Containers are only supported in local mode, to suppress this warning set `dev.enable_containers` to `false` or pass `--enable-containers=false` to the `wrangler dev` command" + ); + } + + // TODO(do) support remote wrangler dev + const classNameToUseSQLite = getDurableObjectClassNameToUseSQLiteMap( + resolved.migrations, + resolved.exports + ); + if ( + resolved.dev.remote && + Array.from(classNameToUseSQLite.values()).some((v) => v) + ) { + logger.once.warn( + "SQLite in Durable Objects is only supported in local mode." + ); + } + } + + // Skip the legacy `checkTypesDiff` call when `--experimental-new-config` is on. + // The new-config equivalent (`regenerateNewConfigTypes`) is invoked from + // `#updateConfig` directly using the structured `types` object returned + // by `loadNewConfig`. + if (!newConfigEnabled) { + await checkTypesDiff(config, entry); + } + + return { config: resolved, printCurrentBindings }; +} + +/** + * Returns the compatibility date to use in development. + * + * When no compatibility date is configured, uses today's date. + * + * @param config wrangler configuration + * @param compatibilityDate configured compatibility date + * @returns the compatibility date to use in development + */ +function getDevCompatibilityDate( + projectPath: string, + config: Config | undefined, + compatibilityDate = config?.compatibility_date +): string { + const todaysDate = getTodaysCompatDate(); + + if (config?.configPath && compatibilityDate === undefined) { + logger.warn( + `No compatibility_date was specified. Using today's date: ${todaysDate}.\n` + + `❯❯ Add one to your ${configFileName(config.configPath)} file: ${formatConfigSnippet({ compatibility_date: todaysDate }, config.configPath, false).trim()}, or\n` + + `❯❯ Pass it in your terminal: wrangler dev [ +`; + +/** + * Rewrite references to URLs in request/response headers. + * + * This function is used to map the URLs in headers like Origin and Access-Control-Allow-Origin + * so that this proxy is transparent to the Client Browser and User Worker. + */ +function rewriteUrlRelatedHeaders(headers: Headers, from: URL, to: URL) { + const setCookie = headers.getAll("Set-Cookie"); + headers.delete("Set-Cookie"); + headers.forEach((value, key) => { + if (typeof value === "string" && value.includes(from.host)) { + headers.set(key, rewriteUrlInHeaderValue(value, from, to)); + } + }); + for (const cookie of setCookie) { + headers.append( + "Set-Cookie", + cookie.replace( + new RegExp(`Domain=${from.hostname}($|;|,)`), + `Domain=${to.hostname}$1` + ) + ); + } +} diff --git a/packages/remote-bindings/tsdown.config.ts b/packages/remote-bindings/tsdown.config.ts index 1e5e773076..fb142c5186 100644 --- a/packages/remote-bindings/tsdown.config.ts +++ b/packages/remote-bindings/tsdown.config.ts @@ -20,4 +20,12 @@ export default defineConfig([ dts: false, external: ["cloudflare:email", "cloudflare:workers"], }, + { + entry: { + "dev-proxy-worker": "templates/startDevWorker/ProxyWorker.ts", + }, + platform: "node", + outDir: "dist", + dts: false, + }, ]); diff --git a/packages/wrangler/templates/startDevWorker/ProxyWorker.ts b/packages/wrangler/templates/startDevWorker/ProxyWorker.ts index fc28268c00..6e90b222fe 100644 --- a/packages/wrangler/templates/startDevWorker/ProxyWorker.ts +++ b/packages/wrangler/templates/startDevWorker/ProxyWorker.ts @@ -1,6 +1,6 @@ import { createDeferred, - DeferredPromise, + type DeferredPromise, rewriteUrlInHeaderValue, urlFromParts, } from "../../src/api/startDevWorker/utils"; From 01b325e98171eda898fcd57dc7c71822061e2bf7 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Wed, 15 Jul 2026 14:30:52 +0100 Subject: [PATCH 19/34] make proxycontroller green with minimal helpers --- .../src/startDevWorker/ProxyController.ts | 16 ++------ .../remote-bindings/src/utils/miniflare.ts | 37 +++++++++++++++++++ .../remote-bindings/src/utils/use-esbuild.ts | 12 ++++++ 3 files changed, 52 insertions(+), 13 deletions(-) create mode 100644 packages/remote-bindings/src/utils/miniflare.ts create mode 100644 packages/remote-bindings/src/utils/use-esbuild.ts diff --git a/packages/remote-bindings/src/startDevWorker/ProxyController.ts b/packages/remote-bindings/src/startDevWorker/ProxyController.ts index 66f7fd5fe7..3f87bead45 100644 --- a/packages/remote-bindings/src/startDevWorker/ProxyController.ts +++ b/packages/remote-bindings/src/startDevWorker/ProxyController.ts @@ -4,17 +4,16 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { assertNever } from "@cloudflare/workers-utils"; import { LogLevel, Miniflare, Mutex, Response } from "miniflare"; +import { logger } from "../logger"; import { castLogLevel, handleStructuredLogs, WranglerLog, -} from "../../dev/miniflare"; -import { validateHttpsOptions } from "../../https-options"; -import { logger } from "../logger"; +} from "../utils/miniflare"; import { Controller } from "./BaseController"; import { castErrorCause } from "./events"; import { createDeferred } from "./utils"; -import type { EsbuildBundle } from "../../dev/use-esbuild"; +import type { EsbuildBundle } from "../utils/use-esbuild"; import type { BundleStartEvent, ConfigUpdateEvent, @@ -53,19 +52,10 @@ export class ProxyController extends Controller { } assert(this.latestConfig !== undefined); - const cert = this.latestConfig.dev?.server?.secure - ? validateHttpsOptions( - this.latestConfig.dev.server.httpsKeyPath, - this.latestConfig.dev.server.httpsCertPath - ) - : undefined; - const proxyWorkerOptions: MiniflareOptions = { host: this.latestConfig.dev?.server?.hostname, port: this.latestConfig.dev?.server?.port, https: this.latestConfig.dev?.server?.secure, - httpsCert: cert?.cert, - httpsKey: cert?.key, stripDisablePrettyError: false, unsafeLocalExplorer: false, workers: [ diff --git a/packages/remote-bindings/src/utils/miniflare.ts b/packages/remote-bindings/src/utils/miniflare.ts new file mode 100644 index 0000000000..c155986a92 --- /dev/null +++ b/packages/remote-bindings/src/utils/miniflare.ts @@ -0,0 +1,37 @@ +import { Log, LogLevel } from "miniflare"; +import { logger } from "../logger"; +import type { LoggerLevel } from "@cloudflare/workers-utils"; +import type { WorkerdStructuredLog } from "miniflare"; + +export class WranglerLog extends Log {} + +export function castLogLevel(level: LoggerLevel): LogLevel { + let key = level.toUpperCase() as Uppercase; + if (key === "LOG") { + key = "INFO"; + } + + return LogLevel[key]; +} + +export function handleStructuredLogs({ + level, + message, +}: WorkerdStructuredLog): void { + if (level === "warn") { + logger.warn(message); + return; + } + + if (level === "info" || level === "debug") { + logger.info(message); + return; + } + + if (level === "error") { + logger.error(message); + return; + } + + logger.log(message); +} diff --git a/packages/remote-bindings/src/utils/use-esbuild.ts b/packages/remote-bindings/src/utils/use-esbuild.ts new file mode 100644 index 0000000000..e7c151b617 --- /dev/null +++ b/packages/remote-bindings/src/utils/use-esbuild.ts @@ -0,0 +1,12 @@ +import type { CfModule, CfModuleType, Entry } from "@cloudflare/workers-utils"; +import type { Metafile } from "esbuild"; + +export type EsbuildBundle = { + id: number; + path: string; + entrypointSource: string; + entry: Entry; + type: CfModuleType; + modules: CfModule[]; + dependencies: Metafile["outputs"][string]["inputs"]; +}; From 1e0c276d4ea80f6a82ae51376246107f9226df5a Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Wed, 15 Jul 2026 15:19:21 +0100 Subject: [PATCH 20/34] reduction in more things we don't cara bout --- .../startDevWorker/RemoteRuntimeController.ts | 126 ++----------- .../src/startDevWorker/types.ts | 2 +- .../src/utils/create-worker-preview.ts | 61 +++--- .../remote-bindings/src/utils/isAbortError.ts | 14 ++ packages/remote-bindings/src/utils/remote.ts | 174 +++--------------- 5 files changed, 93 insertions(+), 284 deletions(-) create mode 100644 packages/remote-bindings/src/utils/isAbortError.ts diff --git a/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts b/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts index 8f6412b493..524427166a 100644 --- a/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts +++ b/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts @@ -37,8 +37,8 @@ import type { ReloadCompleteEvent, ReloadStartEvent, } from "./events"; -import type { Bundle, StartDevWorkerOptions, Trigger } from "./types"; -import type { Route } from "@cloudflare/workers-utils"; +import type { Bundle, StartDevWorkerOptions } from "./types"; +import type { ComplianceConfig } from "@cloudflare/workers-utils"; type CreateRemoteWorkerInitProps = Parameters[0]; @@ -54,20 +54,20 @@ export class RemoteRuntimeController extends RuntimeController { #latestConfig?: StartDevWorkerOptions; #latestBundle?: Bundle; - #latestRoutes?: Route[]; #latestProxyData?: ProxyData; // Timer for proactive token refresh before the 1-hour expiry #refreshTimer?: ReturnType; async #previewSession( - props: Parameters[0] & { + props: CfAccount & { + complianceConfig: ComplianceConfig; name: string; } ): Promise { try { const { workerAccount, workerContext } = - await getWorkerAccountAndContext(props); + getWorkerAccountAndContext(props); return await retryOnAPIFailure( () => @@ -96,7 +96,8 @@ export class RemoteRuntimeController extends RuntimeController { async #previewToken( props: CreateRemoteWorkerInitProps & - Parameters[0] & { + CfAccount & { + complianceConfig: ComplianceConfig; bundleId: number; minimal_mode?: boolean; } @@ -109,17 +110,6 @@ export class RemoteRuntimeController extends RuntimeController { const session = this.#session; try { - /* - * Since `getWorkerAccountAndContext`, `createRemoteWorkerInit` and - * `createWorkerPreview` are all async functions, it is technically - * possible that new `bundleComplete` events are trigerred while those - * functions are still executing. In such cases we want to drop the - * current bundle and exit early, to avoid unnecessarily executing any - * further expensive API calls. - * - * For this purpose, we want perform a check before each of these - * functions, to ensure no new `bundleComplete` was triggered. - */ // If we received a new `bundleComplete` event before we were able to // dispatch a `reloadComplete` for this bundle, ignore this bundle. if (props.bundleId !== this.#currentBundleId) { @@ -129,44 +119,18 @@ export class RemoteRuntimeController extends RuntimeController { this.#activeTail?.removeAllListeners("error"); this.#activeTail?.on("error", () => {}); this.#activeTail?.terminate(); - const { workerAccount, workerContext } = await getWorkerAccountAndContext( - { - complianceConfig: props.complianceConfig, - accountId: props.accountId, - env: props.env, - host: props.host, - routes: props.routes, - sendMetrics: props.sendMetrics, - configPath: props.configPath, - } - ); - - // If we received a new `bundleComplete` event before we were able to - // dispatch a `reloadComplete` for this bundle, ignore this bundle. - if (props.bundleId !== this.#currentBundleId) { - return; - } - const init = await createRemoteWorkerInit({ - complianceConfig: props.complianceConfig, - bundle: props.bundle, - modules: props.modules, + const { workerAccount, workerContext } = getWorkerAccountAndContext({ accountId: props.accountId, + apiToken: props.apiToken, + }); + const init = createRemoteWorkerInit({ + bundle: props.bundle, name: props.name, - env: props.env, - isWorkersSite: props.isWorkersSite, - assets: props.assets, - legacyAssetPaths: props.legacyAssetPaths, - format: props.format, bindings: props.bindings, compatibilityDate: props.compatibilityDate, compatibilityFlags: props.compatibilityFlags, }); - // If we received a new `bundleComplete` event before we were able to - // dispatch a `reloadComplete` for this bundle, ignore this bundle. - if (props.bundleId !== this.#currentBundleId) { - return; - } const workerPreviewToken = await retryOnAPIFailure( () => createWorkerPreview( @@ -236,49 +200,19 @@ export class RemoteRuntimeController extends RuntimeController { } } - #getPreviewSession( - config: StartDevWorkerOptions, - auth: CfAccount, - routes: Route[] | undefined - ) { + #getPreviewSession(config: StartDevWorkerOptions, auth: CfAccount) { return this.#previewSession({ complianceConfig: { compliance_region: config.complianceRegion }, accountId: auth.accountId, apiToken: auth.apiToken, - env: config.env, - host: config.dev.origin?.hostname, - routes, - sendMetrics: config.sendMetrics, - configPath: config.config, name: config.name, }); } - #extractRoutes(config: StartDevWorkerOptions): Route[] | undefined { - return config.triggers - ?.filter( - (trigger): trigger is Extract => - trigger.type === "route" - ) - .map((trigger) => { - const { type: _, ...route } = trigger; - if ( - "custom_domain" in route || - "zone_id" in route || - "zone_name" in route - ) { - return route; - } else { - return route.pattern; - } - }); - } - async #updatePreviewToken( config: StartDevWorkerOptions, bundle: Bundle, auth: CfAccount, - routes: Route[] | undefined, bundleId: number ): Promise { // If we received a new `bundleComplete` event before we were able to @@ -289,29 +223,13 @@ export class RemoteRuntimeController extends RuntimeController { const token = await this.#previewToken({ bundle, - modules: bundle.modules, accountId: auth.accountId, + apiToken: auth.apiToken, complianceConfig: { compliance_region: config.complianceRegion }, name: config.name, - env: config.env, - isWorkersSite: config.legacy?.site !== undefined, - assets: config.assets, - legacyAssetPaths: config.legacy?.site?.bucket - ? { - baseDirectory: config.legacy?.site?.bucket, - assetDirectory: "", - excludePatterns: config.legacy?.site?.exclude ?? [], - includePatterns: config.legacy?.site?.include ?? [], - } - : undefined, - format: bundle.entry.format, bindings: config.bindings, compatibilityDate: config.compatibilityDate, compatibilityFlags: config.compatibilityFlags, - routes, - host: config.dev.origin?.hostname, - sendMetrics: config.sendMetrics, - configPath: config.config, bundleId, minimal_mode: config.dev.remote === "minimal", }); @@ -322,7 +240,7 @@ export class RemoteRuntimeController extends RuntimeController { return false; } - const accessHeaders = getAccessHeaders(token.host, { + const accessHeaders = await getAccessHeaders(token.host, { logger, }); @@ -375,8 +293,6 @@ export class RemoteRuntimeController extends RuntimeController { logger.log(chalk.dim("⎔ Starting remote preview...")); try { - const routes = this.#extractRoutes(config); - if (!config.dev?.auth) { throw new MissingConfigError("config.dev.auth"); } @@ -386,7 +302,6 @@ export class RemoteRuntimeController extends RuntimeController { this.#latestConfig = config; this.#latestBundle = bundle; - this.#latestRoutes = routes; if (this.#session) { logger.log(chalk.dim("⎔ Detected changes, restarted server.")); @@ -398,8 +313,8 @@ export class RemoteRuntimeController extends RuntimeController { this.#session = undefined; } - this.#session ??= await this.#getPreviewSession(config, auth, routes); - await this.#updatePreviewToken(config, bundle, auth, routes, id); + this.#session ??= await this.#getPreviewSession(config, auth); + await this.#updatePreviewToken(config, bundle, auth, id); } catch (error) { if (error instanceof Error && error.name == "AbortError") { return; @@ -427,17 +342,12 @@ export class RemoteRuntimeController extends RuntimeController { assert(this.#latestConfig.dev.auth); const auth = await unwrapHook(this.#latestConfig.dev.auth); - this.#session = await this.#getPreviewSession( - this.#latestConfig, - auth, - this.#latestRoutes - ); + this.#session = await this.#getPreviewSession(this.#latestConfig, auth); const refreshed = await this.#updatePreviewToken( this.#latestConfig, this.#latestBundle, auth, - this.#latestRoutes, this.#currentBundleId ); diff --git a/packages/remote-bindings/src/startDevWorker/types.ts b/packages/remote-bindings/src/startDevWorker/types.ts index 0f6af34c1d..9c5544413d 100644 --- a/packages/remote-bindings/src/startDevWorker/types.ts +++ b/packages/remote-bindings/src/startDevWorker/types.ts @@ -1,4 +1,4 @@ -import type { EsbuildBundle } from "../../dev/use-esbuild"; +import type { EsbuildBundle } from "../utils/use-esbuild"; import type { ConfigController } from "./ConfigController"; import type { DevEnv } from "./DevEnv"; import type { ContainerNormalizedConfig } from "@cloudflare/containers-shared"; diff --git a/packages/remote-bindings/src/utils/create-worker-preview.ts b/packages/remote-bindings/src/utils/create-worker-preview.ts index 12d7a0e588..93236cb099 100644 --- a/packages/remote-bindings/src/utils/create-worker-preview.ts +++ b/packages/remote-bindings/src/utils/create-worker-preview.ts @@ -1,19 +1,26 @@ import crypto from "node:crypto"; import { URL } from "node:url"; -import { getWorkersDevSubdomain } from "@cloudflare/deploy-helpers"; -import { ParseError, parseJSON, UserError } from "@cloudflare/workers-utils"; +import { + createWorkerUploadForm, + getWorkersDevSubdomain, +} from "@cloudflare/deploy-helpers"; +import { getAccessHeaders } from "@cloudflare/workers-auth"; +import { + fetchResultBase, + ParseError, + parseJSON, + UserError, +} from "@cloudflare/workers-utils"; import { fetch } from "undici"; -import { fetchResult } from "../cfetch"; -import { createWorkerUploadForm } from "../deployment-bundle/create-worker-upload-form"; +import { version as packageVersion } from "../../package.json"; import { logger } from "../logger"; -import { getAccessHeaders } from "../user/access"; import type { CfWorkerInitWithName } from "./remote"; import type { ApiCredentials, CfWorkerContext, ComplianceConfig, } from "@cloudflare/workers-utils"; -import type { HeadersInit } from "undici"; +import type { HeadersInit, RequestInit } from "undici"; /** * Maximum time (ms) to wait for an individual preview API request before @@ -22,6 +29,25 @@ import type { HeadersInit } from "undici"; */ const PREVIEW_API_TIMEOUT_MS = 30_000; +function fetchResult( + complianceConfig: ComplianceConfig, + account: CfAccount, + resource: string, + init: RequestInit = {}, + abortSignal?: AbortSignal +): Promise { + return fetchResultBase( + complianceConfig, + resource, + init, + `remote-bindings/${packageVersion}`, + logger, + undefined, + abortSignal, + account.apiToken + ); +} + /** * Combine the caller's abort signal with a per-request timeout so that a * hung Cloudflare API response doesn't block forever. @@ -138,13 +164,12 @@ async function tryExpandToken( try { const switchedExchangeUrl = switchHost(exchangeUrl, ctx.host, !!ctx.zone); - const accessHeaders = await getAccessHeaders(switchedExchangeUrl.hostname); + const accessHeaders = await getAccessHeaders(switchedExchangeUrl.hostname, { + logger, + }); const headers: HeadersInit = { ...accessHeaders }; - logger.debugWithSanitization( - "-- START EXCHANGE API REQUEST:", - ` GET ${switchedExchangeUrl.href}` - ); + logger.debug("-- START EXCHANGE API REQUEST:"); logger.debug("-- END EXCHANGE API REQUEST"); const exchangeResponse = await fetch(switchedExchangeUrl, { @@ -158,7 +183,6 @@ async function tryExpandToken( exchangeResponse.status ); logger.debug("HEADERS:", JSON.stringify(exchangeResponse.headers, null, 2)); - logger.debugWithSanitization("RESPONSE:", bodyText); logger.debug("-- END EXCHANGE API RESPONSE"); @@ -190,7 +214,7 @@ export async function createPreviewSession( abortSignal: AbortSignal, name: string | undefined ): Promise { - const { accountId, apiToken } = account; + const { accountId } = account; const initUrl = ctx.zone ? `/zones/${ctx.zone}/workers/edge-preview` : `/accounts/${accountId}/workers/subdomain/edge-preview`; @@ -198,14 +222,7 @@ export async function createPreviewSession( const { token, exchange_url } = await fetchResult<{ token: string; exchange_url?: string; - }>( - complianceConfig, - initUrl, - undefined, - undefined, - withTimeout(abortSignal), - apiToken - ); + }>(complianceConfig, account, initUrl, undefined, withTimeout(abortSignal)); const previewSessionToken = exchange_url ? ((await tryExpandToken(exchange_url, ctx, withTimeout(abortSignal))) ?? @@ -289,6 +306,7 @@ async function createPreviewToken( tail_url: string; }>( complianceConfig, + account, url, { method: "POST", @@ -297,7 +315,6 @@ async function createPreviewToken( "cf-preview-upload-config-token": value, }, }, - undefined, withTimeout(abortSignal) ); diff --git a/packages/remote-bindings/src/utils/isAbortError.ts b/packages/remote-bindings/src/utils/isAbortError.ts new file mode 100644 index 0000000000..73c89857a6 --- /dev/null +++ b/packages/remote-bindings/src/utils/isAbortError.ts @@ -0,0 +1,14 @@ +/** + * Checking whether an error is an AbortError has changed. + * There is a legacy use of `.code` + * and a (mdn status: experimental) use of `.name` + * + * See MDN for more information: + * https://developer.mozilla.org/en-US/docs/Web/API/DOMException#aborterror + */ +export function isAbortError(err: unknown) { + const legacyAbortErroCheck = (err as { code: string }).code == "ABORT_ERR"; + const abortErrorCheck = err instanceof Error && err.name == "AbortError"; + + return legacyAbortErroCheck || abortErrorCheck; +} diff --git a/packages/remote-bindings/src/utils/remote.ts b/packages/remote-bindings/src/utils/remote.ts index 53db48c110..d8f1e88e15 100644 --- a/packages/remote-bindings/src/utils/remote.ts +++ b/packages/remote-bindings/src/utils/remote.ts @@ -1,31 +1,14 @@ import assert from "node:assert"; import path from "node:path"; +import { getAuthFromEnv } from "@cloudflare/workers-auth"; import { APIError, UserError } from "@cloudflare/workers-utils"; -import { syncAssets } from "../assets"; -import { isAuthenticationError } from "../core/handle-errors"; -import { printBundleSize } from "../deployment-bundle/bundle-reporter"; -import { getBundleType } from "../deployment-bundle/bundle-type"; -import { withSourceURLs } from "../deployment-bundle/source-url"; -import { getInferredHost } from "../dev"; import { logger } from "../logger"; -import { syncWorkersSite } from "../sites"; -import { getAuthFromEnv, requireApiToken } from "../user"; -import { isAbortError } from "../utils/isAbortError"; -import { getZoneIdForPreview } from "../zones"; -import type { StartDevWorkerInput } from "../api"; +import { isAbortError } from "./isAbortError"; +import type { StartDevWorkerInput } from "../startDevWorker/types"; import type { CfAccount } from "./create-worker-preview"; import type { EsbuildBundle } from "./use-esbuild"; import type { ApiCredentials } from "@cloudflare/workers-utils"; -import type { - AssetsOptions, - CfModule, - CfScriptFormat, - CfWorkerContext, - CfWorkerInit, - ComplianceConfig, - LegacyAssetPaths, - Route, -} from "@cloudflare/workers-utils"; +import type { CfWorkerContext, CfWorkerInit } from "@cloudflare/workers-utils"; /** * Error thrown when a remote dev session fails due to an authentication @@ -105,17 +88,10 @@ export function handlePreviewSessionCreationError( accountId: string ) { assert(err && typeof err === "object"); - // instead of logging the raw API error to the user, - // give them friendly instructions - if (isAuthenticationError(err)) { - throw new RemoteSessionAuthenticationError(err); + if (handleUserFriendlyError(err, accountId)) { + return; } - // for error 10063 (workers.dev subdomain required) - else if ("code" in err && err.code === 10063) { - logger.error( - `You need to register a workers.dev subdomain before running the dev command in remote mode. You can either enable local mode by pressing l, or register a workers.dev subdomain here: https://dash.cloudflare.com/${accountId}/workers/onboarding` - ); - } else if ( + if ( "cause" in err && (err.cause as { code: string; hostname: string })?.code === "ENOTFOUND" ) { @@ -141,91 +117,24 @@ export type CfWorkerInitWithName = Required> & * Create remote worker init from StartDevWorkerInput["bindings"] format * (flat Record). */ -export async function createRemoteWorkerInit(props: { +export function createRemoteWorkerInit(props: { bundle: EsbuildBundle; - modules: CfModule[]; - complianceConfig: ComplianceConfig; - accountId: string; name: string; - env: string | undefined; - isWorkersSite: boolean; - assets: AssetsOptions | undefined; - legacyAssetPaths: LegacyAssetPaths | undefined; - format: CfScriptFormat; bindings: StartDevWorkerInput["bindings"]; compatibilityDate: string | undefined; compatibilityFlags: string[] | undefined; - minimal_mode?: boolean; }) { - const { entrypointSource: content, modules } = withSourceURLs( - props.bundle.path, - props.bundle.entrypointSource, - props.modules - ); - - // TODO: For Dev we could show the reporter message in the interactive box. - void printBundleSize( - { - name: path.basename(props.bundle.path), - content, - }, - props.modules - ); - - const workersSitesAssets = await syncWorkersSite( - props.complianceConfig, - props.accountId, - props.name, - props.isWorkersSite ? props.legacyAssetPaths : undefined, - true, - false, - undefined - ); // TODO: cancellable? - - if (workersSitesAssets.manifest) { - modules.push({ - name: "__STATIC_CONTENT_MANIFEST", - filePath: undefined, - content: JSON.stringify(workersSitesAssets.manifest), - type: "text", - }); - } - - const assetsUploadResult = props.assets - ? await syncAssets( - props.complianceConfig, - props.accountId, - props.assets.directory, - props.name - ) - : undefined; - const assetsJwt = assetsUploadResult?.jwt; - const bindings = { ...props.bindings }; - if (workersSitesAssets.namespace) { - bindings["__STATIC_CONTENT"] = { - type: "kv_namespace", - id: workersSitesAssets.namespace, - }; - } - - if (workersSitesAssets.manifest && props.format === "service-worker") { - bindings["__STATIC_CONTENT_MANIFEST"] = { - type: "text_blob", - source: { contents: "__STATIC_CONTENT_MANIFEST" }, - }; - } - const init: CfWorkerInitWithName = { name: props.name, main: { name: path.basename(props.bundle.path), filePath: props.bundle.path, - type: getBundleType(props.format, path.basename(props.bundle.path)), - content, + type: props.bundle.type, + content: props.bundle.entrypointSource, }, - modules, + modules: props.bundle.modules, bindings, migrations: undefined, // no migrations in dev exports: undefined, @@ -236,17 +145,7 @@ export async function createRemoteWorkerInit(props: { logpush: false, sourceMaps: undefined, containers: undefined, // Containers are not supported in remote dev mode - assets: - props.assets && assetsJwt - ? { - jwt: assetsJwt, - routerConfig: props.assets.routerConfig, - assetConfig: props.assets.assetConfig, - _redirects: props.assets._redirects, - _headers: props.assets._headers, - run_worker_first: props.assets.run_worker_first, - } - : undefined, + assets: undefined, placement: undefined, // no placement in dev tail_consumers: undefined, streaming_tail_consumers: undefined, @@ -258,34 +157,21 @@ export async function createRemoteWorkerInit(props: { return init; } -export async function getWorkerAccountAndContext(props: { - complianceConfig: ComplianceConfig; +export function getWorkerAccountAndContext(props: { accountId: string; - apiToken?: ApiCredentials | undefined; - env: string | undefined; - host: string | undefined; - routes: Route[] | undefined; - sendMetrics: boolean | undefined; - configPath: string | undefined; -}): Promise<{ workerAccount: CfAccount; workerContext: CfWorkerContext }> { + apiToken: ApiCredentials; +}): { workerAccount: CfAccount; workerContext: CfWorkerContext } { const workerAccount: CfAccount = { accountId: props.accountId, - apiToken: props.apiToken ?? requireApiToken(), + apiToken: props.apiToken, }; - // What zone should the realish preview for this Worker run on? - const zoneId = await getZoneIdForPreview(props.complianceConfig, { - host: props.host, - routes: props.routes, - accountId: props.accountId, - }); - const workerContext: CfWorkerContext = { - env: props.env, - zone: zoneId, - host: props.host ?? getInferredHost(props.routes, props.configPath), - routes: props.routes, - sendMetrics: props.sendMetrics, + env: undefined, + zone: undefined, + host: undefined, + routes: undefined, + sendMetrics: undefined, }; return { workerAccount, workerContext }; @@ -305,24 +191,6 @@ function handleUserFriendlyError(error: unknown, accountId?: string) { throw new RemoteSessionAuthenticationError(error); } - // code 10021 is a validation error - case 10021: { - // if it is the following message, give a more user friendly - // error, otherwise do not handle this error in this function - if ( - error.notes[0].text === - "binding DB of type d1 must have a valid `id` specified [code: 10021]" - ) { - logger.error( - `You must use a real database in the preview_database_id configuration. You can find your databases using 'wrangler d1 list', or read how to develop locally with D1 here: https://developers.cloudflare.com/d1/configuration/local-development` - ); - - return true; - } - - return false; - } - // for error 10063 (workers.dev subdomain required) case 10063: { const onboardingLink = accountId From d2dcd18ca6c647ea8b7750d7da33e957a9e0064d Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 16 Jul 2026 14:59:38 +0100 Subject: [PATCH 21/34] make remote bindings package functional --- .changeset/fix-remote-bindings-preview.md | 7 ++ packages/deploy-helpers/package.json | 4 + packages/deploy-helpers/tsup.config.ts | 2 + packages/remote-bindings/src/auth.test.ts | 1 + .../src/maybe-start-or-update-session.test.ts | 79 +++++++++++++++++++ .../src/maybe-start-or-update-session.ts | 25 +++--- .../src/start-remote-proxy-session.ts | 64 ++++++++------- packages/remote-bindings/src/start-worker.ts | 18 +---- .../src/startDevWorker/BundlerController.ts | 2 - .../src/startDevWorker/DevEnv.ts | 33 +------- .../src/startDevWorker/ProxyController.ts | 16 ++-- .../startDevWorker/RemoteRuntimeController.ts | 4 +- .../src/startDevWorker/types.ts | 13 +-- .../src/utils/create-worker-preview.ts | 52 +++++++++--- .../templates/startDevWorker/ProxyWorker.ts | 54 ------------- packages/remote-bindings/tsdown.config.ts | 5 +- .../src/__tests__/dev/remote-bindings.test.ts | 4 +- .../wrangler/src/api/remoteBindings/index.ts | 6 +- .../start-remote-proxy-session.ts | 20 ++++- 19 files changed, 235 insertions(+), 174 deletions(-) create mode 100644 .changeset/fix-remote-bindings-preview.md create mode 100644 packages/remote-bindings/src/maybe-start-or-update-session.test.ts diff --git a/.changeset/fix-remote-bindings-preview.md b/.changeset/fix-remote-bindings-preview.md new file mode 100644 index 0000000000..0f27f1f836 --- /dev/null +++ b/.changeset/fix-remote-bindings-preview.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +Fix remote binding previews for accounts without a workers.dev subdomain + +Wrangler now automatically registers a workers.dev subdomain when one is required to start a remote binding preview. diff --git a/packages/deploy-helpers/package.json b/packages/deploy-helpers/package.json index f15a529ec8..d500326b0e 100644 --- a/packages/deploy-helpers/package.json +++ b/packages/deploy-helpers/package.json @@ -25,6 +25,10 @@ "./context": { "import": "./dist/context.mjs", "types": "./dist/context.d.mts" + }, + "./create-worker-upload-form": { + "import": "./dist/create-worker-upload-form.mjs", + "types": "./dist/create-worker-upload-form.d.mts" } }, "scripts": { diff --git a/packages/deploy-helpers/tsup.config.ts b/packages/deploy-helpers/tsup.config.ts index 62c07fef9e..59c6ec376a 100644 --- a/packages/deploy-helpers/tsup.config.ts +++ b/packages/deploy-helpers/tsup.config.ts @@ -11,6 +11,8 @@ export default defineConfig(() => [ entry: { index: "src/index.ts", context: "src/shared/context.ts", + "create-worker-upload-form": + "src/deploy/helpers/create-worker-upload-form.ts", }, platform: "node", format: "esm", diff --git a/packages/remote-bindings/src/auth.test.ts b/packages/remote-bindings/src/auth.test.ts index 00f9c05296..b57b3857ca 100644 --- a/packages/remote-bindings/src/auth.test.ts +++ b/packages/remote-bindings/src/auth.test.ts @@ -29,6 +29,7 @@ function createTestLogger(): RemoteBindingsLogger { info: vi.fn(), warn: vi.fn(), error: vi.fn(), + console: vi.fn(), once: { info: vi.fn(), log: vi.fn(), diff --git a/packages/remote-bindings/src/maybe-start-or-update-session.test.ts b/packages/remote-bindings/src/maybe-start-or-update-session.test.ts new file mode 100644 index 0000000000..3396388baf --- /dev/null +++ b/packages/remote-bindings/src/maybe-start-or-update-session.test.ts @@ -0,0 +1,79 @@ +import { describe, it, vi } from "vitest"; +import { maybeStartOrUpdateRemoteProxySession } from "./maybe-start-or-update-session"; +import type { RemoteBindingsLogger } from "./logger"; +import type { RemoteProxySessionData } from "./maybe-start-or-update-session"; +import type { startRemoteProxySession } from "./start-remote-proxy-session"; +import type { RemoteProxyConnectionString } from "miniflare"; + +function createTestLogger(): RemoteBindingsLogger { + return { + loggerLevel: "none", + debug: vi.fn(), + log: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + console: vi.fn(), + once: { + info: vi.fn(), + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + }; +} + +describe("maybeStartOrUpdateRemoteProxySession", () => { + it("updates an existing session when all remote bindings are removed", async ({ + expect, + }) => { + const dispose = vi.fn(); + const updateBindings = vi.fn(); + const startSession = vi.fn(); + const existingSession: RemoteProxySessionData = { + session: { + ready: Promise.resolve(), + dispose, + updateBindings, + remoteProxyConnectionString: new URL( + "http://localhost:8787" + ) as RemoteProxyConnectionString, + }, + remoteBindings: { + SERVICE: { + type: "service", + service: "worker", + remote: true, + }, + }, + }; + + const result = await maybeStartOrUpdateRemoteProxySession( + { bindings: {} }, + existingSession, + undefined, + { logger: createTestLogger() }, + startSession + ); + + expect(result?.session).toBe(existingSession.session); + expect(updateBindings).toHaveBeenCalledWith({}); + expect(dispose).not.toHaveBeenCalled(); + expect(startSession).not.toHaveBeenCalled(); + }); + + it("does not start a session without remote bindings", async ({ expect }) => { + const startSession = vi.fn(); + + const result = await maybeStartOrUpdateRemoteProxySession( + { bindings: {} }, + undefined, + undefined, + { logger: createTestLogger() }, + startSession + ); + + expect(result).toBeNull(); + expect(startSession).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/remote-bindings/src/maybe-start-or-update-session.ts b/packages/remote-bindings/src/maybe-start-or-update-session.ts index b4d7f46d31..5b7e8afa98 100644 --- a/packages/remote-bindings/src/maybe-start-or-update-session.ts +++ b/packages/remote-bindings/src/maybe-start-or-update-session.ts @@ -56,12 +56,18 @@ export type RemoteBindingsContext = { /** Potentially starts or updates a remote proxy session. */ export async function maybeStartOrUpdateRemoteProxySession( workerConfigObject: WorkerConfigObject, - preExistingRemoteProxySessionData?: RemoteProxySessionData | null, - auth?: AsyncHook, - context?: RemoteBindingsContext, + preExistingRemoteProxySessionData: RemoteProxySessionData | null | undefined, + auth: AsyncHook | undefined, + context: RemoteBindingsContext, startSession: typeof startRemoteProxySession = startRemoteProxySession ): Promise { const remoteBindings = pickRemoteBindings(workerConfigObject.bindings); + if ( + Object.keys(remoteBindings).length === 0 && + !preExistingRemoteProxySessionData?.session + ) { + return null; + } const authSameAsBefore = deepStrictEqual( auth, preExistingRemoteProxySessionData?.auth @@ -82,9 +88,9 @@ export async function maybeStartOrUpdateRemoteProxySession( ? { account_id: workerConfigObject.account_id } : undefined, workerConfigObject.profileDir, - context?.logger + context.logger ), - logger: context?.logger, + logger: context.logger, }); } else { const remoteBindingsAreSameAsBefore = deepStrictEqual( @@ -104,9 +110,9 @@ export async function maybeStartOrUpdateRemoteProxySession( ? { account_id: workerConfigObject.account_id } : undefined, workerConfigObject.profileDir, - context?.logger + context.logger ), - logger: context?.logger, + logger: context.logger, }); } } else { @@ -141,11 +147,8 @@ function getAuthHook( auth: AsyncHook | undefined, config: Pick | undefined, profileDir: string | undefined, - logger?: RemoteBindingsLogger + logger: RemoteBindingsLogger ): AsyncHook | undefined { - if (!logger) { - throw new Error("A logger is required to resolve remote binding auth"); - } const { auth: remoteBindingsAuth, useCfAuth } = createRemoteBindingsAuth(logger); const profileStore = useCfAuth diff --git a/packages/remote-bindings/src/start-remote-proxy-session.ts b/packages/remote-bindings/src/start-remote-proxy-session.ts index 49fc0daa0c..03cd2f01e2 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.ts @@ -8,8 +8,9 @@ import { DeferredPromise } from "miniflare"; import { initLogger } from "./logger"; import { startWorker } from "./start-worker"; import type { RemoteBindingsLogger } from "./logger"; -import type { Worker } from "./start-worker"; import type { + AsyncHook, + CfAccount, Config, LoggerLevel, StartDevWorkerInput, @@ -24,10 +25,10 @@ type ErrorEvent = { 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"]; - logger?: RemoteBindingsLogger; + logger: RemoteBindingsLogger; }; function isErrorEvent(error: unknown): error is ErrorEvent { @@ -68,12 +69,10 @@ function formatRemoteProxySessionError(error: unknown): string | undefined { export async function startRemoteProxySession( bindings: StartDevWorkerInput["bindings"], - options?: StartRemoteProxySessionOptions + options: StartRemoteProxySessionOptions ): Promise { - if (options?.logger) { - initLogger(options.logger); - } - options?.logger?.log(chalk.dim("⎔ Establishing remote connection...")); + initLogger(options.logger); + options.logger.log(chalk.dim("⎔ Establishing remote connection...")); const rawBindings = toRawBindings(bindings); const remoteBindingsWorkerPath = fileURLToPath( new URL("./proxy-worker.js", import.meta.url) @@ -106,7 +105,7 @@ export async function startRemoteProxySession( auth: options?.auth, server: { port: 0, secure: false }, inspector: false as const, - logLevel: getStartWorkerLogLevel(options?.logger?.loggerLevel ?? "error"), + logLevel: getStartWorkerLogLevel(options.logger.loggerLevel), persist: false as const, origin: {}, liveReload: false, @@ -132,26 +131,35 @@ export async function startRemoteProxySession( ); const maybeErrorPromise = new DeferredPromise<{ error: unknown }>(); - worker.raw.addListener("error", (error) => { + const onStartupError = (error: unknown) => { maybeErrorPromise.resolve({ error }); - }); - const maybeError = await Promise.race([ - maybeErrorPromise, - worker.raw.proxy.localServerReady.promise, - ]); + }; + worker.raw.addListener("error", onStartupError); + let remoteProxyConnectionString: RemoteProxyConnectionString; + try { + const maybeError = await Promise.race([ + maybeErrorPromise, + worker.raw.proxy.localServerReady.promise, + ]); - if (maybeError && maybeError.error) { - const details = formatRemoteProxySessionError(maybeError.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 } - ); - } + if (maybeError && maybeError.error) { + const details = formatRemoteProxySessionError(maybeError.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; + remoteProxyConnectionString = + (await worker.url) as RemoteProxyConnectionString; + } catch (error) { + await worker.dispose(); + throw error; + } finally { + worker.raw.removeListener("error", onStartupError); + } const updateBindings = async ( newBindings: StartDevWorkerInput["bindings"] ) => { @@ -183,7 +191,9 @@ export async function startRemoteProxySession( }; } -export type RemoteProxySession = Pick & { +export type RemoteProxySession = { + ready: Promise; + dispose(): Promise; updateBindings: (bindings: StartDevWorkerInput["bindings"]) => Promise; remoteProxyConnectionString: RemoteProxyConnectionString; }; diff --git a/packages/remote-bindings/src/start-worker.ts b/packages/remote-bindings/src/start-worker.ts index 6b3b5ca1d0..bc28c64a85 100644 --- a/packages/remote-bindings/src/start-worker.ts +++ b/packages/remote-bindings/src/start-worker.ts @@ -1,22 +1,10 @@ import { DevEnv } from "./startDevWorker/DevEnv"; -import type { Binding, StartDevWorkerInput } from "@cloudflare/workers-utils"; -import type { EventEmitter } from "node:events"; +import type { StartDevWorkerOptions, Worker } from "./startDevWorker/types"; -export type Worker = { - ready: Promise; - url: Promise; - dispose(): Promise; - patchConfig(config: { bindings: Record }): Promise; - raw: EventEmitter & { - proxy: { - localServerReady: { promise: Promise }; - runtimeMessageMutex: { drained(): Promise }; - }; - }; -}; +export type { Worker }; export async function startWorker( - options: StartDevWorkerInput + options: StartDevWorkerOptions ): Promise { const devEnv = new DevEnv(); diff --git a/packages/remote-bindings/src/startDevWorker/BundlerController.ts b/packages/remote-bindings/src/startDevWorker/BundlerController.ts index 7c7f93328e..16685b574c 100644 --- a/packages/remote-bindings/src/startDevWorker/BundlerController.ts +++ b/packages/remote-bindings/src/startDevWorker/BundlerController.ts @@ -29,8 +29,6 @@ export class BundlerController extends Controller { type: "esm", modules: [], dependencies: {}, - sourceMapPath: undefined, - sourceMapMetadata: undefined, }, }); } diff --git a/packages/remote-bindings/src/startDevWorker/DevEnv.ts b/packages/remote-bindings/src/startDevWorker/DevEnv.ts index 657ba49c09..6d62690321 100644 --- a/packages/remote-bindings/src/startDevWorker/DevEnv.ts +++ b/packages/remote-bindings/src/startDevWorker/DevEnv.ts @@ -1,4 +1,3 @@ -import assert from "node:assert"; import { EventEmitter } from "node:events"; import { UserError } from "@cloudflare/workers-utils"; import { MiniflareCoreError } from "miniflare"; @@ -174,41 +173,11 @@ function createWorkerObject(devEnv: DevEnv): Worker { get url() { return devEnv.proxy.ready.promise.then((ev) => ev.url); }, - get inspectorUrl() { - return devEnv.proxy.ready.promise.then((ev) => ev.inspectorUrl); - }, - async setConfig(config) { - return devEnv.config.set(config); - }, patchConfig(config) { return devEnv.config.patch(config); }, - async fetch(...args) { - const { proxyWorker } = await devEnv.proxy.ready.promise; - await devEnv.proxy.runtimeMessageMutex.drained(); - - return proxyWorker.dispatchFetch(...args); - }, - async queue(...args) { - assert( - this.config.name, - "Worker name must be defined to use `Worker.queue()`" - ); - const { proxyWorker } = await devEnv.proxy.ready.promise; - const w = await proxyWorker.getWorker(this.config.name); - return w.queue(...args); - }, - async scheduled(...args) { - assert( - this.config.name, - "Worker name must be defined to use `Worker.scheduled()`" - ); - const { proxyWorker } = await devEnv.proxy.ready.promise; - const w = await proxyWorker.getWorker(this.config.name); - return w.scheduled(...args); - }, async dispose() { - await devEnv.proxy.ready.promise.finally(() => devEnv.teardown()); + await devEnv.teardown(); }, raw: devEnv, }; diff --git a/packages/remote-bindings/src/startDevWorker/ProxyController.ts b/packages/remote-bindings/src/startDevWorker/ProxyController.ts index 3f87bead45..c549b9df03 100644 --- a/packages/remote-bindings/src/startDevWorker/ProxyController.ts +++ b/packages/remote-bindings/src/startDevWorker/ProxyController.ts @@ -297,9 +297,15 @@ export class ProxyController extends Controller { }); } - emitErrorEvent(data: ErrorEvent): void; - emitErrorEvent(reason: string, cause?: Error | SerializedError): void; - emitErrorEvent(data: string | ErrorEvent, cause?: Error | SerializedError) { + override emitErrorEvent(data: ErrorEvent): void; + override emitErrorEvent( + reason: string, + cause?: Error | SerializedError + ): void; + override emitErrorEvent( + data: string | ErrorEvent, + cause?: Error | SerializedError + ) { if (typeof data === "string") { data = { type: "error", @@ -325,11 +331,11 @@ class ProxyControllerLogger extends WranglerLog { super(level, opts); } - logReady(message: string): void { + override logReady(message: string): void { this.localServerReady.then(() => super.logReady(message)).catch(() => {}); } - log(message: string) { + override log(message: string) { // filter out request logs being handled by the ProxyWorker // the requests log remaining are handled by the UserWorker // keep the ProxyWorker request logs if we're in debug mode diff --git a/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts b/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts index 524427166a..8813684eb2 100644 --- a/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts +++ b/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts @@ -424,10 +424,10 @@ export class RemoteRuntimeController extends RuntimeController { // Event Dispatchers // ********************* - emitReloadStartEvent(data: ReloadStartEvent) { + override emitReloadStartEvent(data: ReloadStartEvent) { this.bus.dispatch(data); } - emitReloadCompleteEvent(data: ReloadCompleteEvent) { + override emitReloadCompleteEvent(data: ReloadCompleteEvent) { this.bus.dispatch(data); } } diff --git a/packages/remote-bindings/src/startDevWorker/types.ts b/packages/remote-bindings/src/startDevWorker/types.ts index 9c5544413d..4bb102ae8d 100644 --- a/packages/remote-bindings/src/startDevWorker/types.ts +++ b/packages/remote-bindings/src/startDevWorker/types.ts @@ -1,5 +1,4 @@ import type { EsbuildBundle } from "../utils/use-esbuild"; -import type { ConfigController } from "./ConfigController"; import type { DevEnv } from "./DevEnv"; import type { ContainerNormalizedConfig } from "@cloudflare/containers-shared"; import type { @@ -21,11 +20,9 @@ import type { StartDevWorkerInput, Trigger, } from "@cloudflare/workers-utils"; -import type { DispatchFetch, Miniflare, WorkerdStructuredLog } from "miniflare"; +import type { WorkerdStructuredLog } from "miniflare"; import type * as undici from "undici"; -type MiniflareWorker = Awaited>; - /** * Extended StartDevWorkerInput with wrangler-specific fields that depend on miniflare types. * The base StartDevWorkerInput in workers-utils is kept dependency-free. @@ -42,13 +39,7 @@ export type WranglerStartDevWorkerInput = Omit & { export interface Worker { ready: Promise; url: Promise; - inspectorUrl: Promise; - config: StartDevWorkerOptions; - setConfig: ConfigController["set"]; - patchConfig: ConfigController["patch"]; - fetch: DispatchFetch; - scheduled: MiniflareWorker["scheduled"]; - queue: MiniflareWorker["queue"]; + patchConfig(config: StartDevWorkerOptions): void; dispose(): Promise; raw: DevEnv; } diff --git a/packages/remote-bindings/src/utils/create-worker-preview.ts b/packages/remote-bindings/src/utils/create-worker-preview.ts index 93236cb099..abe9bbbba5 100644 --- a/packages/remote-bindings/src/utils/create-worker-preview.ts +++ b/packages/remote-bindings/src/utils/create-worker-preview.ts @@ -1,12 +1,11 @@ import crypto from "node:crypto"; import { URL } from "node:url"; -import { - createWorkerUploadForm, - getWorkersDevSubdomain, -} from "@cloudflare/deploy-helpers"; +import { createWorkerUploadForm } from "@cloudflare/deploy-helpers/create-worker-upload-form"; import { getAccessHeaders } from "@cloudflare/workers-auth"; import { + APIError, fetchResultBase, + getComplianceRegionSubdomain, ParseError, parseJSON, UserError, @@ -56,6 +55,41 @@ function withTimeout(signal: AbortSignal): AbortSignal { return AbortSignal.any([signal, AbortSignal.timeout(PREVIEW_API_TIMEOUT_MS)]); } +async function getOrRegisterWorkersDevSubdomain( + complianceConfig: ComplianceConfig, + account: CfAccount, + abortSignal: AbortSignal +): Promise { + const resource = `/accounts/${account.accountId}/workers/subdomain`; + try { + const { subdomain } = await fetchResult<{ subdomain: string }>( + complianceConfig, + account, + resource, + undefined, + abortSignal + ); + return subdomain; + } catch (error) { + if (!(error instanceof APIError) || error.code !== 10007) { + throw error; + } + } + + const subdomain = crypto.randomBytes(4).toString("hex"); + const result = await fetchResult<{ subdomain: string }>( + complianceConfig, + account, + resource, + { + method: "PUT", + body: JSON.stringify({ subdomain }), + }, + abortSignal + ); + return result.subdomain; +} + /** * A Cloudflare account. */ @@ -232,14 +266,12 @@ export async function createPreviewSession( try { let host = ctx.host; if (!host) { - const subdomain = await getWorkersDevSubdomain( + const subdomain = await getOrRegisterWorkersDevSubdomain( complianceConfig, - account.accountId, - { - abortSignal: withTimeout(abortSignal), - } + account, + withTimeout(abortSignal) ); - host = `${name ?? crypto.randomUUID()}.${subdomain}`; + host = `${name ?? crypto.randomUUID()}.${subdomain}${getComplianceRegionSubdomain(complianceConfig)}.workers.dev`; } return { value: previewSessionToken, diff --git a/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts b/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts index 4af3923769..b08850ad7c 100644 --- a/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts +++ b/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts @@ -163,10 +163,6 @@ export class ProxyWorker implements DurableObject { await checkForPreviewTokenError(res, this.env, proxyData); - if (isHtmlResponse(res)) { - res = insertLiveReloadScript(request, res, this.env, proxyData); - } - if (isSseResponse(res)) { void sendMessageToProxyController(this.env, { type: "sseResponseDetected", @@ -236,9 +232,6 @@ export class ProxyWorker implements DurableObject { function isRequestFromProxyController(req: Request, env: Env): boolean { return req.headers.get("Authorization") === env.PROXY_CONTROLLER_AUTH_SECRET; } -function isHtmlResponse(res: Response): boolean { - return res.headers.get("content-type")?.startsWith("text/html") ?? false; -} function isSseResponse(res: Response): boolean { return ( res.headers.get("content-type")?.startsWith("text/event-stream") ?? false @@ -293,53 +286,6 @@ async function checkForPreviewTokenError( }); } } - -function insertLiveReloadScript( - request: Request, - response: Response, - env: Env, - proxyData: ProxyData -) { - const htmlRewriter = new HTMLRewriter(); - - htmlRewriter.onDocument({ - end(end) { - // if liveReload enabled, append a script tag - // TODO: compare to existing nodejs implementation - if (proxyData.liveReload) { - const websocketUrl = new URL(request.url); - websocketUrl.protocol = - websocketUrl.protocol === "http:" ? "ws:" : "wss:"; - - end.append(liveReloadScript, { html: true }); - } - }, - }); - - return htmlRewriter.transform(response); -} - -const liveReloadScript = ` - -`; - /** * Rewrite references to URLs in request/response headers. * diff --git a/packages/remote-bindings/tsdown.config.ts b/packages/remote-bindings/tsdown.config.ts index fb142c5186..6e730d98bc 100644 --- a/packages/remote-bindings/tsdown.config.ts +++ b/packages/remote-bindings/tsdown.config.ts @@ -9,7 +9,10 @@ export default defineConfig([ outDir: "dist", dts: true, tsconfig: "tsconfig.json", - external: ["miniflare", /^@cloudflare\/workers-utils/], + define: { + __filename: "import.meta.filename", + }, + external: ["miniflare"], }, { entry: { diff --git a/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts b/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts index 1b19e833d3..2acc90c966 100644 --- a/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts +++ b/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts @@ -783,7 +783,7 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => { }); expect(sessionOptions).toBeDefined(); assert(sessionOptions); - const { auth, ...rest1 } = sessionOptions; + const { auth, logger: _logger, ...rest1 } = sessionOptions; expect(rest1).toEqual({ complianceRegion: undefined, workerName: "worker", @@ -827,7 +827,7 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => { expect(sessionOptions).toBeDefined(); assert(sessionOptions); - const { auth: auth2, ...rest2 } = sessionOptions; + const { auth: auth2, logger: _logger, ...rest2 } = sessionOptions; expect(rest2).toEqual({ complianceRegion: undefined, workerName: "worker", diff --git a/packages/wrangler/src/api/remoteBindings/index.ts b/packages/wrangler/src/api/remoteBindings/index.ts index 092cec0fd2..68abb047c0 100644 --- a/packages/wrangler/src/api/remoteBindings/index.ts +++ b/packages/wrangler/src/api/remoteBindings/index.ts @@ -3,6 +3,7 @@ import { getCloudflareComplianceRegion } from "@cloudflare/workers-utils"; import { readConfig } from "../../config"; import { logger } from "../../logger"; import { convertConfigBindingsToStartWorkerBindings } from "../startDevWorker"; +import { startRemoteProxySession } from "./start-remote-proxy-session"; import type { RemoteProxySessionData, WorkerConfigObject, @@ -10,6 +11,8 @@ import type { import type { AsyncHook, CfAccount } from "@cloudflare/workers-utils"; export * from "@cloudflare/remote-bindings"; +export { startRemoteProxySession } from "./start-remote-proxy-session"; +export type { StartRemoteProxySessionOptions } from "./start-remote-proxy-session"; type WranglerConfigObject = { path: string; @@ -38,6 +41,7 @@ export function maybeStartOrUpdateRemoteProxySession( wranglerOrWorkerConfigObject, preExistingRemoteProxySessionData, auth, - { logger } + { logger }, + startRemoteProxySession ); } 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 52ecde205c..cca2750567 100644 --- a/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts +++ b/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts @@ -1 +1,19 @@ -export * from "@cloudflare/remote-bindings"; +import { startRemoteProxySession as startRemoteProxySessionFromPackage } from "@cloudflare/remote-bindings"; +import { logger } from "../../logger"; +import type { + StartRemoteProxySessionOptions as PackageStartRemoteProxySessionOptions, + RemoteProxySession, +} from "@cloudflare/remote-bindings"; +import type { StartDevWorkerInput } from "@cloudflare/workers-utils"; + +export type StartRemoteProxySessionOptions = Omit< + PackageStartRemoteProxySessionOptions, + "logger" +>; + +export function startRemoteProxySession( + bindings: StartDevWorkerInput["bindings"], + options: StartRemoteProxySessionOptions = {} +): Promise { + return startRemoteProxySessionFromPackage(bindings, { ...options, logger }); +} From 3806b52f3e4031facb4c55e68227ae472ffe4b93 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 16 Jul 2026 15:14:55 +0100 Subject: [PATCH 22/34] [remote-bindings] Remove dead dev runtime code --- .../src/start-remote-proxy-session.ts | 1 - .../src/startDevWorker/BaseController.ts | 12 - .../src/startDevWorker/DevEnv.ts | 1 - .../src/startDevWorker/ProxyController.ts | 17 - .../startDevWorker/RemoteRuntimeController.ts | 8 +- .../src/startDevWorker/events.ts | 27 +- .../src/startDevWorker/types.ts | 34 -- .../src/startDevWorker/utils.ts | 292 +----------------- packages/remote-bindings/src/utils/remote.ts | 9 +- .../templates/startDevWorker/ProxyWorker.ts | 49 +-- 10 files changed, 11 insertions(+), 439 deletions(-) diff --git a/packages/remote-bindings/src/start-remote-proxy-session.ts b/packages/remote-bindings/src/start-remote-proxy-session.ts index 03cd2f01e2..70126511cb 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.ts @@ -108,7 +108,6 @@ export async function startRemoteProxySession( logLevel: getStartWorkerLogLevel(options.logger.loggerLevel), persist: false as const, origin: {}, - liveReload: false, }, }; diff --git a/packages/remote-bindings/src/startDevWorker/BaseController.ts b/packages/remote-bindings/src/startDevWorker/BaseController.ts index e3c591145a..0913848e65 100644 --- a/packages/remote-bindings/src/startDevWorker/BaseController.ts +++ b/packages/remote-bindings/src/startDevWorker/BaseController.ts @@ -3,13 +3,11 @@ import type { BundleCompleteEvent, BundleStartEvent, ConfigUpdateEvent, - DevRegistryUpdateEvent, ErrorEvent, PreviewTokenExpiredEvent, ReloadCompleteEvent, ReloadStartEvent, } from "./events"; -import type { Miniflare } from "miniflare"; export type ControllerEvent = | ErrorEvent @@ -18,7 +16,6 @@ export type ControllerEvent = | BundleCompleteEvent | ReloadStartEvent | ReloadCompleteEvent - | DevRegistryUpdateEvent | PreviewTokenExpiredEvent; export interface ControllerBus { @@ -58,11 +55,6 @@ export abstract class RuntimeController extends Controller { abstract onBundleComplete(_: BundleCompleteEvent): void; abstract onPreviewTokenExpired(_: PreviewTokenExpiredEvent): void; - // ********************* - // Runtime Accessors - // ********************* - abstract get mf(): Miniflare | undefined; - // ********************* // Event Dispatchers // ********************* @@ -74,8 +66,4 @@ export abstract class RuntimeController extends Controller { protected emitReloadCompleteEvent(data: ReloadCompleteEvent): void { this.bus.dispatch(data); } - - protected emitDevRegistryUpdateEvent(data: DevRegistryUpdateEvent): void { - this.bus.dispatch(data); - } } diff --git a/packages/remote-bindings/src/startDevWorker/DevEnv.ts b/packages/remote-bindings/src/startDevWorker/DevEnv.ts index 6d62690321..f56f45e285 100644 --- a/packages/remote-bindings/src/startDevWorker/DevEnv.ts +++ b/packages/remote-bindings/src/startDevWorker/DevEnv.ts @@ -74,7 +74,6 @@ export class DevEnv extends EventEmitter implements ControllerBus { * - BundlerController emits bundleComplete → RuntimeControllers * - RuntimeController emits reloadStart → ProxyController * - RuntimeController emits reloadComplete → ProxyController - * - RuntimeController emits devRegistryUpdate → ConfigController * - ProxyController emits previewTokenExpired → RuntimeControllers * - Any controller emits error → DevEnv error handler * diff --git a/packages/remote-bindings/src/startDevWorker/ProxyController.ts b/packages/remote-bindings/src/startDevWorker/ProxyController.ts index c549b9df03..ccd86865d7 100644 --- a/packages/remote-bindings/src/startDevWorker/ProxyController.ts +++ b/packages/remote-bindings/src/startDevWorker/ProxyController.ts @@ -109,7 +109,6 @@ export class ProxyController extends Controller { this.localServerReady.promise ), handleStructuredLogs, - liveReload: false, }; const proxyWorkerOptionsChanged = didMiniflareOptionsChange( @@ -237,22 +236,6 @@ export class ProxyController extends Controller { case "error": this.emitErrorEvent("Error inside ProxyWorker", message.error); - break; - case "debug-log": - logger.debug("[ProxyWorker]", ...message.args); - - break; - case "sseResponseDetected": - // Only warn about SSE if a quick tunnel is active - 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." - ); - } - break; default: assertNever(message); diff --git a/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts b/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts index 8813684eb2..baa438abea 100644 --- a/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts +++ b/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts @@ -5,7 +5,7 @@ import { retryOnAPIFailure, } from "@cloudflare/workers-utils"; import chalk from "chalk"; -import { Mutex, type Miniflare } from "miniflare"; +import { Mutex } from "miniflare"; import { WebSocket } from "ws"; import { version as packageVersion } from "../../package.json"; import { logger } from "../logger"; @@ -255,8 +255,6 @@ export class RemoteRuntimeController extends RuntimeController { ...accessHeaders, "cf-connecting-ip": "", }, - liveReload: config.dev.liveReload, - proxyLogsToController: true, }; this.#latestProxyData = proxyData; @@ -400,10 +398,6 @@ export class RemoteRuntimeController extends RuntimeController { void this.#mutex.runWith(() => this.#refreshPreviewToken()); } - override get mf(): Miniflare | undefined { - return undefined; - } - override async teardown() { await super.teardown(); if (this.#session) { diff --git a/packages/remote-bindings/src/startDevWorker/events.ts b/packages/remote-bindings/src/startDevWorker/events.ts index a41e778155..2027f6e4c4 100644 --- a/packages/remote-bindings/src/startDevWorker/events.ts +++ b/packages/remote-bindings/src/startDevWorker/events.ts @@ -1,5 +1,5 @@ import type { Bundle, StartDevWorkerOptions } from "./types"; -import type { Miniflare, WorkerRegistry } from "miniflare"; +import type { Miniflare } from "miniflare"; export type ErrorEvent = | BaseErrorEvent< @@ -72,12 +72,6 @@ export type ReloadCompleteEvent = { bundle: Bundle; proxyData: ProxyData; }; -export type DevRegistryUpdateEvent = { - type: "devRegistryUpdate"; - - registry: WorkerRegistry; -}; - // ProxyController export type PreviewTokenExpiredEvent = { type: "previewTokenExpired"; @@ -98,9 +92,7 @@ export type ProxyWorkerIncomingRequestBody = | { type: "pause" }; export type ProxyWorkerOutgoingRequestBody = | { type: "error"; error: SerializedError } - | { type: "sseResponseDetected" } - | { type: "previewTokenExpired"; proxyData: ProxyData } - | { type: "debug-log"; args: Parameters }; + | { type: "previewTokenExpired"; proxyData: ProxyData }; export type SerializedError = { message: string; @@ -108,19 +100,6 @@ export type SerializedError = { stack?: string | undefined; cause?: unknown; }; -export function serialiseError(e: unknown): SerializedError { - if (e instanceof Error) { - return { - message: e.message, - name: e.name, - stack: e.stack, - cause: e.cause && serialiseError(e.cause), - }; - } else { - return { message: String(e) }; - } -} - export type UrlOriginParts = Pick; export type UrlOriginAndPathnameParts = Pick< URL, @@ -132,6 +111,4 @@ export type ProxyData = { userWorkerInspectorUrl?: UrlOriginAndPathnameParts; userWorkerInnerUrlOverrides?: Partial; headers: Record; - liveReload?: boolean; - proxyLogsToController?: boolean; }; diff --git a/packages/remote-bindings/src/startDevWorker/types.ts b/packages/remote-bindings/src/startDevWorker/types.ts index 4bb102ae8d..d4c6d90104 100644 --- a/packages/remote-bindings/src/startDevWorker/types.ts +++ b/packages/remote-bindings/src/startDevWorker/types.ts @@ -4,38 +4,17 @@ import type { ContainerNormalizedConfig } from "@cloudflare/containers-shared"; import type { AsyncHook, AssetsOptions, - BinaryFile, - Binding, CfAccount, CfModule, CfScriptFormat, Config, - File, - Hook, - HookValues, - LogLevel, NodeJSCompatMode, Rule, - ServiceFetch, StartDevWorkerInput, - Trigger, } from "@cloudflare/workers-utils"; import type { WorkerdStructuredLog } from "miniflare"; import type * as undici from "undici"; -/** - * Extended StartDevWorkerInput with wrangler-specific fields that depend on miniflare types. - * The base StartDevWorkerInput in workers-utils is kept dependency-free. - */ -export type WranglerStartDevWorkerInput = Omit & { - dev?: StartDevWorkerInput["dev"] & { - /** Handles structured runtime logs. */ - structuredLogsHandler?: (log: WorkerdStructuredLog) => void; - /** An undici MockAgent to declaratively mock fetch calls to particular resources. */ - mockFetch?: undici.MockAgent; - }; -}; - export interface Worker { ready: Promise; url: Promise; @@ -82,16 +61,3 @@ export type StartDevWorkerOptions = Omit< }; export type Bundle = EsbuildBundle; - -export type { - StartDevWorkerInput, - Trigger, - Binding, - File, - BinaryFile, - ServiceFetch, - HookValues, - Hook, - AsyncHook, - LogLevel, -}; diff --git a/packages/remote-bindings/src/startDevWorker/utils.ts b/packages/remote-bindings/src/startDevWorker/utils.ts index 9be8f5c684..1bc9704c3d 100644 --- a/packages/remote-bindings/src/startDevWorker/utils.ts +++ b/packages/remote-bindings/src/startDevWorker/utils.ts @@ -1,15 +1,5 @@ import assert from "node:assert"; -import { readFile } from "node:fs/promises"; -import type { - Binding, - File, - Hook, - HookValues, - StartDevWorkerOptions, -} from "./types"; -import type { WorkerMetadataBinding } from "@cloudflare/workers-utils"; - -export function assertNever(_value: never) {} +import type { Hook, HookValues } from "@cloudflare/workers-utils"; /** * When to proactively refresh the preview token. @@ -109,283 +99,3 @@ export function unwrapHook< >(hook: UnwrapHook, ...args: Args): T { return typeof hook === "function" ? hook(...args) : hook; } - -export async function getBinaryFileContents(file: File) { - if ("contents" in file) { - if (file.contents instanceof Buffer) { - return file.contents; - } - return Buffer.from(file.contents); - } - return readFile(file.path); -} - -/** - * Convert WorkerMetadataBinding[] (API format) to flat bindings format (Record) - * - * WorkerMetadataBinding uses different field names than Binding: - * - KV: namespace_id -> id - * - D1: id -> database_id - * - plain_text/json: text/json -> value - * - dispatch_namespace: outbound.worker.service -> outbound.service - */ -export function convertWorkerMetadataBindingsToFlatBindings( - bindings: WorkerMetadataBinding[] -): StartDevWorkerOptions["bindings"] { - const output: StartDevWorkerOptions["bindings"] = {}; - - for (const binding of bindings) { - const { name, type } = binding; - - switch (type) { - case "plain_text": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "plain_text" } - >; - output[name] = { type: "plain_text", value: b.text }; - break; - } - case "secret_text": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "secret_text" } - >; - output[name] = { type: "secret_text", value: b.text }; - break; - } - case "json": { - const b = binding as Extract; - output[name] = { type: "json", value: b.json }; - break; - } - case "kv_namespace": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "kv_namespace" } - >; - output[name] = { type: "kv_namespace", id: b.namespace_id, raw: b.raw }; - break; - } - case "d1": { - const b = binding as Extract; - output[name] = { - type: "d1", - database_id: b.id, - database_internal_env: b.internalEnv, - raw: b.raw, - }; - break; - } - case "dispatch_namespace": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "dispatch_namespace" } - >; - output[name] = { - type: "dispatch_namespace", - namespace: b.namespace, - outbound: b.outbound - ? { - service: b.outbound.worker.service, - environment: b.outbound.worker.environment, - parameters: b.outbound.params?.map((p) => p.name), - } - : undefined, - }; - break; - } - case "durable_object_namespace": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "durable_object_namespace" } - >; - output[name] = { - type: "durable_object_namespace", - class_name: b.class_name, - script_name: b.script_name, - environment: b.environment, - }; - break; - } - case "workflow": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "workflow" } - >; - output[name] = { - type: "workflow", - name: b.workflow_name, - class_name: b.class_name, - script_name: b.script_name, - raw: b.raw, - }; - break; - } - case "queue": { - const b = binding as Extract; - output[name] = { - type: "queue", - queue_name: b.queue_name, - delivery_delay: b.delivery_delay, - raw: b.raw, - }; - break; - } - case "r2_bucket": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "r2_bucket" } - >; - output[name] = { - type: "r2_bucket", - bucket_name: b.bucket_name, - jurisdiction: b.jurisdiction, - raw: b.raw, - }; - break; - } - case "service": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "service" } - >; - output[name] = { - type: "service", - service: b.service, - environment: b.environment, - entrypoint: b.entrypoint, - cross_account_grant: b.cross_account_grant, - }; - break; - } - case "analytics_engine": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "analytics_engine" } - >; - output[name] = { type: "analytics_engine", dataset: b.dataset }; - break; - } - case "vectorize": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "vectorize" } - >; - output[name] = { - type: "vectorize", - index_name: b.index_name, - raw: b.raw, - }; - break; - } - case "ai_search_namespace": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "ai_search_namespace" } - >; - output[name] = { - type: "ai_search_namespace", - namespace: b.namespace, - }; - break; - } - case "ai_search": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "ai_search" } - >; - output[name] = { - type: "ai_search", - instance_name: b.instance_name, - }; - break; - } - case "agent_memory": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "agent_memory" } - >; - output[name] = { - type: "agent_memory", - namespace: b.namespace, - }; - break; - } - case "hyperdrive": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "hyperdrive" } - >; - output[name] = { type: "hyperdrive", id: b.id }; - break; - } - case "send_email": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "send_email" } - >; - // CfSendEmailBindings uses a discriminated union, pass through the relevant fields - const emailBinding: Record = { type: "send_email" }; - if ("destination_address" in b && b.destination_address) { - emailBinding.destination_address = b.destination_address; - } - if ( - "allowed_destination_addresses" in b && - b.allowed_destination_addresses - ) { - emailBinding.allowed_destination_addresses = - b.allowed_destination_addresses; - } - if ("allowed_sender_addresses" in b && b.allowed_sender_addresses) { - emailBinding.allowed_sender_addresses = b.allowed_sender_addresses; - } - output[name] = emailBinding as Binding; - break; - } - case "mtls_certificate": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "mtls_certificate" } - >; - output[name] = { - type: "mtls_certificate", - certificate_id: b.certificate_id, - }; - break; - } - case "pipelines": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "pipelines" } - >; - output[name] = { - type: "pipeline", - stream: b.stream, - pipeline: b.pipeline, - }; - break; - } - case "browser": - case "ai": - case "images": - case "stream": - case "version_metadata": - case "media": - case "websearch": - case "inherit": { - // These have the same structure (just type and possibly some flags) - const { name: _name, ...rest } = binding; - output[name] = rest as Binding; - break; - } - default: { - // For any other binding types, pass through as-is - const { name: _name, ...rest } = binding; - output[name] = rest as Binding; - } - } - } - - return output; -} diff --git a/packages/remote-bindings/src/utils/remote.ts b/packages/remote-bindings/src/utils/remote.ts index d8f1e88e15..bbc68e96cd 100644 --- a/packages/remote-bindings/src/utils/remote.ts +++ b/packages/remote-bindings/src/utils/remote.ts @@ -4,11 +4,14 @@ import { getAuthFromEnv } from "@cloudflare/workers-auth"; import { APIError, UserError } from "@cloudflare/workers-utils"; import { logger } from "../logger"; import { isAbortError } from "./isAbortError"; -import type { StartDevWorkerInput } from "../startDevWorker/types"; import type { CfAccount } from "./create-worker-preview"; import type { EsbuildBundle } from "./use-esbuild"; -import type { ApiCredentials } from "@cloudflare/workers-utils"; -import type { CfWorkerContext, CfWorkerInit } from "@cloudflare/workers-utils"; +import type { + ApiCredentials, + CfWorkerContext, + CfWorkerInit, + StartDevWorkerInput, +} from "@cloudflare/workers-utils"; /** * Error thrown when a remote dev session fails due to an authentication diff --git a/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts b/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts index b08850ad7c..70beec0cce 100644 --- a/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts +++ b/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts @@ -23,8 +23,6 @@ type Request = Parameters< > >[0]; -const LIVE_RELOAD_PROTOCOL = "WRANGLER_PROXYWORKER_LIVE_RELOAD_PROTOCOL"; -const LIVE_RELOAD_PATHNAME = "/cdn-cgi/live-reload"; export default { fetch(req, env) { const singleton = env.DURABLE_OBJECT.idFromName(""); @@ -36,7 +34,7 @@ export default { export class ProxyWorker implements DurableObject { constructor( - readonly state: DurableObjectState, + _state: DurableObjectState, readonly env: Env ) {} @@ -45,12 +43,6 @@ export class ProxyWorker implements DurableObject { requestRetryQueue = new Map>(); fetch(request: Request) { - if (isRequestForLiveReloadWebsocket(request)) { - // requests for live-reload websocket - - return this.handleLiveReloadWebSocket(request); - } - if (isRequestFromProxyController(request, this.env)) { // requests from ProxyController @@ -66,20 +58,6 @@ export class ProxyWorker implements DurableObject { return deferred.promise; } - handleLiveReloadWebSocket(request: Request) { - const { 0: response, 1: liveReload } = new WebSocketPair(); - const websocketProtocol = - request.headers.get("Sec-WebSocket-Protocol") ?? ""; - - this.state.acceptWebSocket(liveReload, ["live-reload"]); - - return new Response(null, { - status: 101, - webSocket: response, - headers: { "Sec-WebSocket-Protocol": websocketProtocol }, - }); - } - processProxyControllerRequest(request: Request) { const event = request.cf?.hostMetadata; switch (event?.type) { @@ -90,9 +68,6 @@ export class ProxyWorker implements DurableObject { case "play": this.proxyData = event.proxyData; this.processQueue(); - this.state - .getWebSockets("live-reload") - .forEach((ws) => ws.send("reload")); break; } @@ -163,12 +138,6 @@ export class ProxyWorker implements DurableObject { await checkForPreviewTokenError(res, this.env, proxyData); - if (isSseResponse(res)) { - void sendMessageToProxyController(this.env, { - type: "sseResponseDetected", - }); - } - deferredResponse.resolve(res); }) .catch((error: Error) => { @@ -232,22 +201,6 @@ export class ProxyWorker implements DurableObject { function isRequestFromProxyController(req: Request, env: Env): boolean { return req.headers.get("Authorization") === env.PROXY_CONTROLLER_AUTH_SECRET; } -function isSseResponse(res: Response): boolean { - return ( - res.headers.get("content-type")?.startsWith("text/event-stream") ?? false - ); -} -function isRequestForLiveReloadWebsocket(req: Request): boolean { - if (new URL(req.url).pathname !== LIVE_RELOAD_PATHNAME) { - return false; - } - - const websocketProtocol = req.headers.get("Sec-WebSocket-Protocol"); - const isWebSocketUpgrade = req.headers.get("Upgrade") === "websocket"; - - return isWebSocketUpgrade && websocketProtocol === LIVE_RELOAD_PROTOCOL; -} - function sendMessageToProxyController( env: Env, message: ProxyWorkerOutgoingRequestBody From 920555bf5ef6fd6b8c82a1c4305142556046bd59 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 16 Jul 2026 15:32:33 +0100 Subject: [PATCH 23/34] [remote-bindings] Narrow remote runtime internals --- packages/remote-bindings/package.json | 1 - .../src/start-remote-proxy-session.ts | 39 +--------- .../src/startDevWorker/BundlerController.ts | 13 ---- .../src/startDevWorker/DevEnv.ts | 50 +++--------- .../src/startDevWorker/ProxyController.ts | 78 ++++--------------- .../src/startDevWorker/events.ts | 20 +---- .../src/startDevWorker/types.ts | 63 +++++---------- packages/remote-bindings/src/utils/remote.ts | 4 +- .../remote-bindings/src/utils/use-esbuild.ts | 12 --- pnpm-lock.yaml | 3 - 10 files changed, 54 insertions(+), 229 deletions(-) delete mode 100644 packages/remote-bindings/src/utils/use-esbuild.ts diff --git a/packages/remote-bindings/package.json b/packages/remote-bindings/package.json index bbf50aac8c..c5c7cc3474 100644 --- a/packages/remote-bindings/package.json +++ b/packages/remote-bindings/package.json @@ -33,7 +33,6 @@ }, "devDependencies": { "@cloudflare/cli-shared-helpers": "workspace:*", - "@cloudflare/containers-shared": "workspace:*", "@cloudflare/deploy-helpers": "workspace:*", "@cloudflare/workers-auth": "workspace:*", "@cloudflare/workers-tsconfig": "workspace:*", diff --git a/packages/remote-bindings/src/start-remote-proxy-session.ts b/packages/remote-bindings/src/start-remote-proxy-session.ts index 70126511cb..8fcee76388 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.ts @@ -1,6 +1,5 @@ import { randomUUID } from "node:crypto"; import events from "node:events"; -import path from "node:path"; import { fileURLToPath } from "node:url"; import { UserError } from "@cloudflare/workers-utils"; import chalk from "chalk"; @@ -12,7 +11,6 @@ import type { AsyncHook, CfAccount, Config, - LoggerLevel, StartDevWorkerInput, } from "@cloudflare/workers-utils"; import type { RemoteProxyConnectionString } from "miniflare"; @@ -77,37 +75,17 @@ export async function startRemoteProxySession( const remoteBindingsWorkerPath = fileURLToPath( new URL("./proxy-worker.js", import.meta.url) ); - const moduleRoot = path.dirname(remoteBindingsWorkerPath); const workerConfig = { - name: options?.workerName ?? randomUUID(), + name: options.workerName ?? randomUUID(), entrypoint: remoteBindingsWorkerPath, - projectRoot: moduleRoot, compatibilityDate: "2025-04-28", compatibilityFlags: [], - complianceRegion: options?.complianceRegion, + complianceRegion: options.complianceRegion, bindings: rawBindings, - triggers: [], - build: { - bundle: false, - additionalModules: [], - processEntrypoint: false, - findAdditionalModules: false, - moduleRoot, - moduleRules: [], - define: {}, - format: "modules" as const, - nodejsCompatMode: null, - exports: [], - }, - legacy: {}, dev: { remote: "minimal" as const, - auth: options?.auth, + auth: options.auth, server: { port: 0, secure: false }, - inspector: false as const, - logLevel: getStartWorkerLogLevel(options.logger.loggerLevel), - persist: false as const, - origin: {}, }, }; @@ -205,14 +183,3 @@ function toRawBindings(bindings: StartDevWorkerInput["bindings"]) { ]) ); } - -function getStartWorkerLogLevel(wranglerLogLevel: LoggerLevel): LoggerLevel { - switch (wranglerLogLevel) { - case "debug": - return "debug"; - case "none": - return "none"; - default: - return "error"; - } -} diff --git a/packages/remote-bindings/src/startDevWorker/BundlerController.ts b/packages/remote-bindings/src/startDevWorker/BundlerController.ts index 16685b574c..f6f286f600 100644 --- a/packages/remote-bindings/src/startDevWorker/BundlerController.ts +++ b/packages/remote-bindings/src/startDevWorker/BundlerController.ts @@ -1,5 +1,4 @@ import { readFileSync } from "node:fs"; -import path from "node:path"; import { Controller } from "./BaseController"; import type { ConfigUpdateEvent } from "./events"; @@ -8,27 +7,15 @@ export class BundlerController extends Controller { this.bus.dispatch({ type: "bundleStart", config }); const entrypointSource = readFileSync(config.entrypoint, "utf8"); - const moduleRoot = path.dirname(config.entrypoint); this.bus.dispatch({ type: "bundleComplete", config, bundle: { - id: 0, path: config.entrypoint, entrypointSource, - entry: { - file: config.entrypoint, - projectRoot: moduleRoot, - configPath: undefined, - format: "modules", - moduleRoot, - name: config.name, - exports: [], - }, type: "esm", modules: [], - dependencies: {}, }, }); } diff --git a/packages/remote-bindings/src/startDevWorker/DevEnv.ts b/packages/remote-bindings/src/startDevWorker/DevEnv.ts index f56f45e285..425076ae08 100644 --- a/packages/remote-bindings/src/startDevWorker/DevEnv.ts +++ b/packages/remote-bindings/src/startDevWorker/DevEnv.ts @@ -6,21 +6,14 @@ import { BundlerController } from "./BundlerController"; import { ConfigController } from "./ConfigController"; import { ProxyController } from "./ProxyController"; import { RemoteRuntimeController } from "./RemoteRuntimeController"; -import type { - Controller, - ControllerBus, - ControllerEvent, - RuntimeController, -} from "./BaseController"; +import type { ControllerBus, ControllerEvent } from "./BaseController"; import type { ErrorEvent } from "./events"; import type { StartDevWorkerOptions, Worker } from "./types"; -type ControllerFactory = (devEnv: DevEnv) => C; - export class DevEnv extends EventEmitter implements ControllerBus { config: ConfigController; bundler: BundlerController; - runtimes: RuntimeController[]; + runtime: RemoteRuntimeController; proxy: ProxyController; async startWorker(options: StartDevWorkerOptions): Promise { @@ -40,23 +33,13 @@ export class DevEnv extends EventEmitter implements ControllerBus { return worker; } - constructor({ - configFactory = (devEnv) => new ConfigController(devEnv), - bundlerFactory = (devEnv) => new BundlerController(devEnv), - runtimeFactories = [(devEnv) => new RemoteRuntimeController(devEnv)], - proxyFactory = (devEnv) => new ProxyController(devEnv), - }: { - configFactory?: ControllerFactory; - bundlerFactory?: ControllerFactory; - runtimeFactories?: ControllerFactory[]; - proxyFactory?: ControllerFactory; - } = {}) { + constructor() { super(); - this.config = configFactory(this); - this.bundler = bundlerFactory(this); - this.runtimes = runtimeFactories.map((factory) => factory(this)); - this.proxy = proxyFactory(this); + this.config = new ConfigController(this); + this.bundler = new BundlerController(this); + this.runtime = new RemoteRuntimeController(this); + this.proxy = new ProxyController(this); this.on("error", (event: ErrorEvent) => { logger.debug(`Error in ${event.source}: ${event.reason}\n`, event.cause); @@ -94,15 +77,11 @@ export class DevEnv extends EventEmitter implements ControllerBus { case "bundleStart": this.proxy.onBundleStart(event); - this.runtimes.forEach((runtime) => { - runtime.onBundleStart(event); - }); + this.runtime.onBundleStart(event); break; case "bundleComplete": - this.runtimes.forEach((runtime) => { - runtime.onBundleComplete(event); - }); + this.runtime.onBundleComplete(event); break; case "reloadStart": @@ -115,9 +94,7 @@ export class DevEnv extends EventEmitter implements ControllerBus { break; case "previewTokenExpired": - this.runtimes.forEach((runtime) => { - runtime.onPreviewTokenExpired(event); - }); + this.runtime.onPreviewTokenExpired(event); break; } } @@ -135,8 +112,7 @@ export class DevEnv extends EventEmitter implements ControllerBus { ); } else if ( event.source === "ProxyController" && - (event.reason.startsWith("Failed to send message to") || - event.reason.startsWith("Could not connect to InspectorProxyWorker")) + event.reason.startsWith("Failed to send message to") ) { logger.debug(`Error in ${event.source}: ${event.reason}\n`, event.cause); logger.debug("=> Error contextual data:", event.data); @@ -154,12 +130,10 @@ export class DevEnv extends EventEmitter implements ControllerBus { await Promise.all([ this.config.teardown(), this.bundler.teardown(), - ...this.runtimes.map((runtime) => runtime.teardown()), + this.runtime.teardown(), this.proxy.teardown(), ]); - this.emit("teardown"); - logger.debug("DevEnv teardown complete"); } } diff --git a/packages/remote-bindings/src/startDevWorker/ProxyController.ts b/packages/remote-bindings/src/startDevWorker/ProxyController.ts index ccd86865d7..9a256ab189 100644 --- a/packages/remote-bindings/src/startDevWorker/ProxyController.ts +++ b/packages/remote-bindings/src/startDevWorker/ProxyController.ts @@ -13,7 +13,6 @@ import { import { Controller } from "./BaseController"; import { castErrorCause } from "./events"; import { createDeferred } from "./utils"; -import type { EsbuildBundle } from "../utils/use-esbuild"; import type { BundleStartEvent, ConfigUpdateEvent, @@ -26,7 +25,7 @@ import type { ReloadStartEvent, SerializedError, } from "./events"; -import type { StartDevWorkerOptions } from "./types"; +import type { Bundle, StartDevWorkerOptions } from "./types"; import type { LogOptions, MiniflareOptions } from "miniflare"; const proxyWorkerPath = fileURLToPath( @@ -39,15 +38,14 @@ export class ProxyController extends Controller { public localServerReady = createDeferred(); public proxyWorker?: Miniflare; - proxyWorkerOptions?: MiniflareOptions; protected latestConfig?: StartDevWorkerOptions; - protected latestBundle?: EsbuildBundle; + protected latestBundle?: Bundle; secret = randomUUID(); protected createProxyWorker() { - if (this._torndown) { + if (this._torndown || this.proxyWorker) { return; } assert(this.latestConfig !== undefined); @@ -111,44 +109,20 @@ export class ProxyController extends Controller { handleStructuredLogs, }; - const proxyWorkerOptionsChanged = didMiniflareOptionsChange( - this.proxyWorkerOptions, - proxyWorkerOptions - ); - - const willInstantiateMiniflareInstance = - !this.proxyWorker || proxyWorkerOptionsChanged; - this.proxyWorker ??= new Miniflare(proxyWorkerOptions); - this.proxyWorkerOptions = proxyWorkerOptions; - - if (proxyWorkerOptionsChanged) { - logger.debug("ProxyWorker miniflare options changed, reinstantiating..."); + const proxyWorker = new Miniflare(proxyWorkerOptions); + this.proxyWorker = proxyWorker; - void this.proxyWorker.setOptions(proxyWorkerOptions).catch((error) => { + void proxyWorker.ready + .then((url) => { + assert(url); + this.emitReadyEvent(proxyWorker, url); + }) + .catch((error) => { + if (this._torndown) { + return; + } this.emitErrorEvent("Failed to start ProxyWorker", error); }); - - // this creates a new .ready promise that will be resolved when both ProxyWorkers are ready - // it also respects any await-ers of the existing .ready promise - this.ready = createDeferred(this.ready); - } - - // store the non-null versions for callbacks - const { proxyWorker } = this; - - if (willInstantiateMiniflareInstance) { - void proxyWorker.ready - .then((url) => { - assert(url); - this.emitReadyEvent(proxyWorker, url, undefined); - }) - .catch((error) => { - if (this._torndown) { - return; - } - this.emitErrorEvent("Failed to start ProxyWorker", error); - }); - } } runtimeMessageMutex = new Mutex(); @@ -259,16 +233,11 @@ export class ProxyController extends Controller { // Event Dispatchers // ********************* - emitReadyEvent( - proxyWorker: Miniflare, - url: URL, - inspectorUrl: URL | undefined - ) { + emitReadyEvent(proxyWorker: Miniflare, url: URL) { const data: ReadyEvent = { type: "ready", proxyWorker, url, - inspectorUrl, }; this.ready.resolve(data); @@ -328,20 +297,3 @@ class ProxyControllerLogger extends WranglerLog { super.log(message); } } - -function deepEquality(a: unknown, b: unknown): boolean { - // could be more efficient, but this is fine for now - return JSON.stringify(a) === JSON.stringify(b); -} - -function didMiniflareOptionsChange( - prev: MiniflareOptions | undefined, - next: MiniflareOptions -) { - if (prev === undefined) { - return false; - } // first time, so 'no change' - - // otherwise, if they're not deeply equal, they've changed - return !deepEquality(prev, next); -} diff --git a/packages/remote-bindings/src/startDevWorker/events.ts b/packages/remote-bindings/src/startDevWorker/events.ts index 2027f6e4c4..2edb8f0a28 100644 --- a/packages/remote-bindings/src/startDevWorker/events.ts +++ b/packages/remote-bindings/src/startDevWorker/events.ts @@ -2,22 +2,10 @@ import type { Bundle, StartDevWorkerOptions } from "./types"; import type { Miniflare } from "miniflare"; export type ErrorEvent = - | BaseErrorEvent< - | "ConfigController" - | "BundlerController" - | "LocalRuntimeController" - | "RemoteRuntimeController" - | "ProxyWorker" - | "InspectorProxyWorker" - | "MultiworkerRuntimeController" - > + | BaseErrorEvent<"RemoteRuntimeController"> | BaseErrorEvent< "ProxyController", { config?: StartDevWorkerOptions; bundle?: Bundle } - > - | BaseErrorEvent< - "BundlerController", - { config?: StartDevWorkerOptions; filePath?: string } >; type BaseErrorEvent = { type: "error"; @@ -83,7 +71,6 @@ export type ReadyEvent = { type: "ready"; proxyWorker: Miniflare; url: URL; - inspectorUrl: URL | undefined; }; // ProxyWorker @@ -101,14 +88,9 @@ export type SerializedError = { cause?: unknown; }; export type UrlOriginParts = Pick; -export type UrlOriginAndPathnameParts = Pick< - URL, - "protocol" | "hostname" | "port" | "pathname" ->; export type ProxyData = { userWorkerUrl: UrlOriginParts; - userWorkerInspectorUrl?: UrlOriginAndPathnameParts; userWorkerInnerUrlOverrides?: Partial; headers: Record; }; diff --git a/packages/remote-bindings/src/startDevWorker/types.ts b/packages/remote-bindings/src/startDevWorker/types.ts index d4c6d90104..7d43da937f 100644 --- a/packages/remote-bindings/src/startDevWorker/types.ts +++ b/packages/remote-bindings/src/startDevWorker/types.ts @@ -1,19 +1,12 @@ -import type { EsbuildBundle } from "../utils/use-esbuild"; import type { DevEnv } from "./DevEnv"; -import type { ContainerNormalizedConfig } from "@cloudflare/containers-shared"; import type { AsyncHook, - AssetsOptions, CfAccount, CfModule, - CfScriptFormat, + CfModuleType, Config, - NodeJSCompatMode, - Rule, StartDevWorkerInput, } from "@cloudflare/workers-utils"; -import type { WorkerdStructuredLog } from "miniflare"; -import type * as undici from "undici"; export interface Worker { ready: Promise; @@ -23,41 +16,27 @@ export interface Worker { raw: DevEnv; } -export type StartDevWorkerOptions = Omit< - StartDevWorkerInput, - "assets" | "config" | "containers" | "dev" -> & { - /** The configuration path of the worker */ - config?: string; - /** A worker's directory. Usually where the Wrangler configuration file is located */ - projectRoot: string; - build: StartDevWorkerInput["build"] & { - nodejsCompatMode: NodeJSCompatMode; - format: CfScriptFormat; - moduleRoot: string; - moduleRules: Rule[]; - define: Record; - additionalModules: CfModule[]; - exports: string[]; - - processEntrypoint: boolean; - }; - legacy: StartDevWorkerInput["legacy"] & { - site?: Config["site"]; - }; - dev: StartDevWorkerInput["dev"] & { - persist: string | false; - auth?: AsyncHook; // redefine without config.account_id hook param (can only be provided by ConfigController with access to the Wrangler configuration file, not by other controllers eg RemoteRuntimeContoller) - /** Handles structured runtime logs. */ - structuredLogsHandler?: (log: WorkerdStructuredLog) => void; - /** An undici MockAgent to declaratively mock fetch calls to particular resources. */ - mockFetch?: undici.MockAgent; - }; - entrypoint: string; - assets?: AssetsOptions; - containers?: ContainerNormalizedConfig[]; +export type StartDevWorkerOptions = { name: string; + entrypoint: string; + bindings: NonNullable; + compatibilityDate: StartDevWorkerInput["compatibilityDate"]; + compatibilityFlags: StartDevWorkerInput["compatibilityFlags"]; complianceRegion: Config["compliance_region"]; + dev: { + remote: "minimal"; + auth?: AsyncHook; + server: { + hostname?: string; + port: number; + secure: boolean; + }; + }; }; -export type Bundle = EsbuildBundle; +export type Bundle = { + path: string; + entrypointSource: string; + type: CfModuleType; + modules: CfModule[]; +}; diff --git a/packages/remote-bindings/src/utils/remote.ts b/packages/remote-bindings/src/utils/remote.ts index bbc68e96cd..cf6158c296 100644 --- a/packages/remote-bindings/src/utils/remote.ts +++ b/packages/remote-bindings/src/utils/remote.ts @@ -4,8 +4,8 @@ import { getAuthFromEnv } from "@cloudflare/workers-auth"; import { APIError, UserError } from "@cloudflare/workers-utils"; import { logger } from "../logger"; import { isAbortError } from "./isAbortError"; +import type { Bundle } from "../startDevWorker/types"; import type { CfAccount } from "./create-worker-preview"; -import type { EsbuildBundle } from "./use-esbuild"; import type { ApiCredentials, CfWorkerContext, @@ -121,7 +121,7 @@ export type CfWorkerInitWithName = Required> & * (flat Record). */ export function createRemoteWorkerInit(props: { - bundle: EsbuildBundle; + bundle: Bundle; name: string; bindings: StartDevWorkerInput["bindings"]; compatibilityDate: string | undefined; diff --git a/packages/remote-bindings/src/utils/use-esbuild.ts b/packages/remote-bindings/src/utils/use-esbuild.ts deleted file mode 100644 index e7c151b617..0000000000 --- a/packages/remote-bindings/src/utils/use-esbuild.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { CfModule, CfModuleType, Entry } from "@cloudflare/workers-utils"; -import type { Metafile } from "esbuild"; - -export type EsbuildBundle = { - id: number; - path: string; - entrypointSource: string; - entry: Entry; - type: CfModuleType; - modules: CfModule[]; - dependencies: Metafile["outputs"][string]["inputs"]; -}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff583478f3..a09a8b8a92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2568,9 +2568,6 @@ importers: '@cloudflare/cli-shared-helpers': specifier: workspace:* version: link:../cli - '@cloudflare/containers-shared': - specifier: workspace:* - version: link:../containers-shared '@cloudflare/deploy-helpers': specifier: workspace:* version: link:../deploy-helpers From b845d3a3aea8b8f5d9b1440651b9a687260f5378 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 16 Jul 2026 15:32:55 +0100 Subject: [PATCH 24/34] [remote-bindings] Restore default account selection --- packages/remote-bindings/src/auth.test.ts | 106 +++++++++++++++--- packages/remote-bindings/src/auth.ts | 39 ++++++- .../src/maybe-start-or-update-session.ts | 58 +--------- .../src/start-remote-proxy-session.ts | 8 +- 4 files changed, 140 insertions(+), 71 deletions(-) diff --git a/packages/remote-bindings/src/auth.test.ts b/packages/remote-bindings/src/auth.test.ts index b57b3857ca..89950b3617 100644 --- a/packages/remote-bindings/src/auth.test.ts +++ b/packages/remote-bindings/src/auth.test.ts @@ -1,22 +1,46 @@ +import assert from "node:assert"; import { afterEach, beforeEach, describe, it, vi } from "vitest"; -import { createRemoteBindingsAuth } from "./auth"; +import { createRemoteBindingsAuth, getRemoteBindingsAuthHook } from "./auth"; import type { RemoteBindingsLogger } from "./logger"; +import type { CfAccount } from "@cloudflare/workers-utils"; const mocks = vi.hoisted(() => ({ - cfAuth: { source: "cf" }, - wranglerAuth: { source: "wrangler" }, + cfAuth: { + source: "cf", + setProfile: vi.fn(), + requireAuth: vi.fn().mockResolvedValue("selected-account-id"), + requireApiToken: vi.fn().mockReturnValue({ apiToken: "test-token" }), + }, + wranglerAuth: { + source: "wrangler", + setProfile: vi.fn(), + requireAuth: vi.fn().mockResolvedValue("selected-account-id"), + requireApiToken: vi.fn().mockReturnValue({ apiToken: "test-token" }), + }, createCfAuth: vi.fn(), createWranglerAuth: vi.fn(), + cfProfileStore: { resolve: vi.fn().mockReturnValue({ name: "cf-profile" }) }, + wranglerProfileStore: { + resolve: vi.fn().mockReturnValue({ name: "wrangler-profile" }), + }, + createCfProfileStore: vi.fn(), + createWranglerProfileStore: vi.fn(), })); vi.mock("@cloudflare/workers-auth/cf", () => ({ createCfAuth: mocks.createCfAuth.mockReturnValue(mocks.cfAuth), + createCfProfileStore: mocks.createCfProfileStore.mockReturnValue( + mocks.cfProfileStore + ), })); vi.mock("@cloudflare/workers-auth/wrangler", () => ({ createWranglerAuth: mocks.createWranglerAuth.mockReturnValue( mocks.wranglerAuth ), + createWranglerProfileStore: mocks.createWranglerProfileStore.mockReturnValue( + mocks.wranglerProfileStore + ), })); const originalCfAuth = process.env.CLOUDFLARE_CF_AUTH; @@ -39,20 +63,20 @@ function createTestLogger(): RemoteBindingsLogger { }; } -describe("createRemoteBindingsAuth", () => { - beforeEach(() => { - delete process.env.CLOUDFLARE_CF_AUTH; - }); +beforeEach(() => { + delete process.env.CLOUDFLARE_CF_AUTH; +}); - afterEach(() => { - vi.clearAllMocks(); - if (originalCfAuth === undefined) { - delete process.env.CLOUDFLARE_CF_AUTH; - } else { - process.env.CLOUDFLARE_CF_AUTH = originalCfAuth; - } - }); +afterEach(() => { + vi.clearAllMocks(); + if (originalCfAuth === undefined) { + delete process.env.CLOUDFLARE_CF_AUTH; + } else { + process.env.CLOUDFLARE_CF_AUTH = originalCfAuth; + } +}); +describe("createRemoteBindingsAuth", () => { it("uses Wrangler auth by default", ({ expect }) => { const result = createRemoteBindingsAuth(createTestLogger()); @@ -71,3 +95,55 @@ describe("createRemoteBindingsAuth", () => { expect(mocks.createWranglerAuth).not.toHaveBeenCalled(); }); }); + +describe("getRemoteBindingsAuthHook", () => { + it("uses provided auth without resolving a profile", ({ expect }) => { + const auth: CfAccount = { + accountId: "provided-account-id", + apiToken: { apiToken: "provided-token" }, + }; + + const result = getRemoteBindingsAuthHook( + auth, + undefined, + undefined, + createTestLogger() + ); + + expect(result).toBe(auth); + expect(mocks.createWranglerProfileStore).not.toHaveBeenCalled(); + }); + + it("allows auth to select an account when none is configured", async ({ + expect, + }) => { + const hook = getRemoteBindingsAuthHook( + undefined, + undefined, + undefined, + createTestLogger() + ); + assert(typeof hook === "function"); + + await expect(hook()).resolves.toEqual({ + accountId: "selected-account-id", + apiToken: { apiToken: "test-token" }, + }); + expect(mocks.wranglerAuth.requireAuth).toHaveBeenCalledWith({}); + }); + + it("uses the configured account when provided", async ({ expect }) => { + const hook = getRemoteBindingsAuthHook( + undefined, + "configured-account-id", + undefined, + createTestLogger() + ); + assert(typeof hook === "function"); + + await hook(); + expect(mocks.wranglerAuth.requireAuth).toHaveBeenCalledWith({ + account_id: "configured-account-id", + }); + }); +}); diff --git a/packages/remote-bindings/src/auth.ts b/packages/remote-bindings/src/auth.ts index 4b4a23ba8a..8e0cb85538 100644 --- a/packages/remote-bindings/src/auth.ts +++ b/packages/remote-bindings/src/auth.ts @@ -1,9 +1,16 @@ import { inputPrompt } from "@cloudflare/cli-shared-helpers/interactive"; -import { createCfAuth } from "@cloudflare/workers-auth/cf"; -import { createWranglerAuth } from "@cloudflare/workers-auth/wrangler"; +import { + createCfAuth, + createCfProfileStore, +} from "@cloudflare/workers-auth/cf"; +import { + createWranglerAuth, + createWranglerProfileStore, +} from "@cloudflare/workers-auth/wrangler"; import { isNonInteractiveOrCI, UserError } from "@cloudflare/workers-utils"; import { version as packageVersion } from "../package.json"; import type { RemoteBindingsLogger } from "./logger"; +import type { AsyncHook, CfAccount, Config } from "@cloudflare/workers-utils"; class NoDefaultValueProvided extends UserError { constructor() { @@ -55,3 +62,31 @@ export function createRemoteBindingsAuth(logger: RemoteBindingsLogger) { useCfAuth, }; } + +export function getRemoteBindingsAuthHook( + auth: AsyncHook | undefined, + accountId: Config["account_id"] | undefined, + profileDir: string | undefined, + logger: RemoteBindingsLogger +): AsyncHook { + if (auth) { + return auth; + } + + const { auth: remoteBindingsAuth, useCfAuth } = + createRemoteBindingsAuth(logger); + const profileStore = useCfAuth + ? createCfProfileStore({ logger }) + : createWranglerProfileStore({ logger }); + const profile = profileStore.resolve({ + cwd: profileDir ?? process.cwd(), + }); + remoteBindingsAuth.setProfile(profile); + + return async () => ({ + accountId: await remoteBindingsAuth.requireAuth( + accountId ? { account_id: accountId } : {} + ), + apiToken: remoteBindingsAuth.requireApiToken(), + }); +} diff --git a/packages/remote-bindings/src/maybe-start-or-update-session.ts b/packages/remote-bindings/src/maybe-start-or-update-session.ts index 5b7e8afa98..a06232f492 100644 --- a/packages/remote-bindings/src/maybe-start-or-update-session.ts +++ b/packages/remote-bindings/src/maybe-start-or-update-session.ts @@ -1,8 +1,6 @@ import assert from "node:assert"; -import { createCfProfileStore } from "@cloudflare/workers-auth/cf"; -import { createWranglerProfileStore } from "@cloudflare/workers-auth/wrangler"; import { getBindingLocalSupport } from "@cloudflare/workers-utils"; -import { createRemoteBindingsAuth } from "./auth"; +import { getRemoteBindingsAuthHook } from "./auth"; import { startRemoteProxySession } from "./start-remote-proxy-session"; import type { RemoteBindingsLogger } from "./logger"; import type { RemoteProxySession } from "./start-remote-proxy-session"; @@ -82,11 +80,9 @@ export async function maybeStartOrUpdateRemoteProxySession( remoteProxySession = await startSession(remoteBindings, { workerName: workerConfigObject.name, complianceRegion: workerConfigObject.complianceRegion, - auth: getAuthHook( + auth: getRemoteBindingsAuthHook( auth, - workerConfigObject.account_id - ? { account_id: workerConfigObject.account_id } - : undefined, + workerConfigObject.account_id, workerConfigObject.profileDir, context.logger ), @@ -104,11 +100,9 @@ export async function maybeStartOrUpdateRemoteProxySession( remoteProxySession = await startSession(remoteBindings, { workerName: workerConfigObject.name, complianceRegion: workerConfigObject.complianceRegion, - auth: getAuthHook( + auth: getRemoteBindingsAuthHook( auth, - workerConfigObject.account_id - ? { account_id: workerConfigObject.account_id } - : undefined, + workerConfigObject.account_id, workerConfigObject.profileDir, context.logger ), @@ -132,48 +126,6 @@ export async function maybeStartOrUpdateRemoteProxySession( }; } -/** - * Gets the auth hook to use for the remote proxy session, this is either the user provided auth - * hook if there is one, or an ad-hoc hook created using the account_id from the user's wrangler - * config file otherwise. - * - * @param auth the auth hook provided by the user if any - * @param config the user's wrangler config if any - * @param profileDir working directory used to resolve the auth profile from directory bindings, - * falls back to `process.cwd()` when not provided - * @returns the auth hook to pass to the startRemoteProxy session function if any - */ -function getAuthHook( - auth: AsyncHook | undefined, - config: Pick | undefined, - profileDir: string | undefined, - logger: RemoteBindingsLogger -): AsyncHook | undefined { - const { auth: remoteBindingsAuth, useCfAuth } = - createRemoteBindingsAuth(logger); - const profileStore = useCfAuth - ? createCfProfileStore({ logger }) - : createWranglerProfileStore({ logger }); - const profile = profileStore.resolve({ - cwd: profileDir ?? process.cwd(), - }); - remoteBindingsAuth.setProfile(profile); - if (auth) { - return auth; - } - - if (config?.account_id) { - return async () => { - return { - accountId: await remoteBindingsAuth.requireAuth(config), - apiToken: remoteBindingsAuth.requireApiToken(), - }; - }; - } - - return undefined; -} - function deepStrictEqual(source: unknown, target: unknown): boolean { try { assert.deepStrictEqual(source, target); diff --git a/packages/remote-bindings/src/start-remote-proxy-session.ts b/packages/remote-bindings/src/start-remote-proxy-session.ts index 8fcee76388..71ce4fd613 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import { UserError } from "@cloudflare/workers-utils"; import chalk from "chalk"; import { DeferredPromise } from "miniflare"; +import { getRemoteBindingsAuthHook } from "./auth"; import { initLogger } from "./logger"; import { startWorker } from "./start-worker"; import type { RemoteBindingsLogger } from "./logger"; @@ -84,7 +85,12 @@ export async function startRemoteProxySession( bindings: rawBindings, dev: { remote: "minimal" as const, - auth: options.auth, + auth: getRemoteBindingsAuthHook( + options.auth, + undefined, + undefined, + options.logger + ), server: { port: 0, secure: false }, }, }; From 464b4b64bc50ce697d370df5cbb02a3c37f2c611 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 16 Jul 2026 15:43:40 +0100 Subject: [PATCH 25/34] chore: fix remote bindings lockfile --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a09a8b8a92..594d9988e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2579,7 +2579,7 @@ importers: version: link:../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260710.1 + version: 5.20260714.1 '@cloudflare/workers-utils': specifier: workspace:* version: link:../workers-utils From 448c24abc5633f8c8bd682ab86fb90144c01a1e7 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 16 Jul 2026 16:22:36 +0100 Subject: [PATCH 26/34] [remote-bindings] Collapse remote runtime controllers --- .../src/start-remote-proxy-session.ts | 76 +++++----- packages/remote-bindings/src/start-worker.ts | 12 -- .../src/startDevWorker/BaseController.ts | 69 --------- .../src/startDevWorker/BundlerController.ts | 22 --- .../src/startDevWorker/ConfigController.ts | 16 -- .../src/startDevWorker/DevEnv.ts | 140 +++++------------- .../src/startDevWorker/ProxyController.ts | 72 ++++----- .../startDevWorker/RemoteRuntimeController.ts | 99 ++++--------- .../src/startDevWorker/events.ts | 27 ---- .../src/startDevWorker/types.ts | 22 +-- .../src/utils/create-worker-preview.ts | 139 +++-------------- packages/remote-bindings/src/utils/remote.ts | 23 --- 12 files changed, 156 insertions(+), 561 deletions(-) delete mode 100644 packages/remote-bindings/src/start-worker.ts delete mode 100644 packages/remote-bindings/src/startDevWorker/BaseController.ts delete mode 100644 packages/remote-bindings/src/startDevWorker/BundlerController.ts delete mode 100644 packages/remote-bindings/src/startDevWorker/ConfigController.ts diff --git a/packages/remote-bindings/src/start-remote-proxy-session.ts b/packages/remote-bindings/src/start-remote-proxy-session.ts index 71ce4fd613..30f772b6c8 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.ts @@ -6,7 +6,7 @@ import chalk from "chalk"; import { DeferredPromise } from "miniflare"; import { getRemoteBindingsAuthHook } from "./auth"; import { initLogger } from "./logger"; -import { startWorker } from "./start-worker"; +import { DevEnv } from "./startDevWorker/DevEnv"; import type { RemoteBindingsLogger } from "./logger"; import type { AsyncHook, @@ -83,46 +83,46 @@ export async function startRemoteProxySession( compatibilityFlags: [], complianceRegion: options.complianceRegion, bindings: rawBindings, - dev: { - remote: "minimal" as const, - auth: getRemoteBindingsAuthHook( - options.auth, - undefined, - undefined, - options.logger - ), - server: { port: 0, secure: false }, - }, + auth: getRemoteBindingsAuthHook( + options.auth, + undefined, + undefined, + options.logger + ), + server: { port: 0, secure: false }, }; - const worker = await startWorker(workerConfig).catch( - (startWorkerError: unknown) => { - if (startWorkerError instanceof UserError) { - throw startWorkerError; - } - let errorMessage = startWorkerError; - if (startWorkerError instanceof Error) { - errorMessage = - startWorkerError.cause instanceof Error - ? startWorkerError.cause.message - : startWorkerError.message; - } - throw new Error( - `Failed to start the remote proxy session, see the error details below:\n\n${errorMessage}` - ); + let devEnv: DevEnv | undefined; + try { + devEnv = new DevEnv(workerConfig); + devEnv.start(); + } catch (startWorkerError: unknown) { + await devEnv?.teardown(); + if (startWorkerError instanceof UserError) { + throw startWorkerError; } - ); + let errorMessage = startWorkerError; + if (startWorkerError instanceof Error) { + errorMessage = + startWorkerError.cause instanceof Error + ? startWorkerError.cause.message + : 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 }>(); const onStartupError = (error: unknown) => { maybeErrorPromise.resolve({ error }); }; - worker.raw.addListener("error", onStartupError); + devEnv.addListener("error", onStartupError); let remoteProxyConnectionString: RemoteProxyConnectionString; try { const maybeError = await Promise.race([ maybeErrorPromise, - worker.raw.proxy.localServerReady.promise, + devEnv.proxy.localServerReady.promise, ]); if (maybeError && maybeError.error) { @@ -135,19 +135,19 @@ export async function startRemoteProxySession( ); } - remoteProxyConnectionString = - (await worker.url) as RemoteProxyConnectionString; + remoteProxyConnectionString = (await devEnv.proxy.ready.promise) + .url as RemoteProxyConnectionString; } catch (error) { - await worker.dispose(); + await devEnv.teardown(); throw error; } finally { - worker.raw.removeListener("error", onStartupError); + devEnv.removeListener("error", onStartupError); } const updateBindings = async ( newBindings: StartDevWorkerInput["bindings"] ) => { - const reloadComplete = events.once(worker.raw, "reloadComplete"); - await worker.patchConfig({ + const reloadComplete = events.once(devEnv, "reloadComplete"); + devEnv.update({ ...workerConfig, bindings: toRawBindings(newBindings), }); @@ -163,14 +163,14 @@ export async function startRemoteProxySession( { cause: errorOrEvent } ); } - await worker.raw.proxy.runtimeMessageMutex.drained(); + await devEnv.proxy.runtimeMessageMutex.drained(); }; return { - ready: worker.ready, + ready: Promise.resolve(), remoteProxyConnectionString, updateBindings, - dispose: worker.dispose, + dispose: () => devEnv.teardown(), }; } diff --git a/packages/remote-bindings/src/start-worker.ts b/packages/remote-bindings/src/start-worker.ts deleted file mode 100644 index bc28c64a85..0000000000 --- a/packages/remote-bindings/src/start-worker.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { DevEnv } from "./startDevWorker/DevEnv"; -import type { StartDevWorkerOptions, Worker } from "./startDevWorker/types"; - -export type { Worker }; - -export async function startWorker( - options: StartDevWorkerOptions -): Promise { - const devEnv = new DevEnv(); - - return devEnv.startWorker(options); -} diff --git a/packages/remote-bindings/src/startDevWorker/BaseController.ts b/packages/remote-bindings/src/startDevWorker/BaseController.ts deleted file mode 100644 index 0913848e65..0000000000 --- a/packages/remote-bindings/src/startDevWorker/BaseController.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { logger } from "../logger"; -import type { - BundleCompleteEvent, - BundleStartEvent, - ConfigUpdateEvent, - ErrorEvent, - PreviewTokenExpiredEvent, - ReloadCompleteEvent, - ReloadStartEvent, -} from "./events"; - -export type ControllerEvent = - | ErrorEvent - | ConfigUpdateEvent - | BundleStartEvent - | BundleCompleteEvent - | ReloadStartEvent - | ReloadCompleteEvent - | PreviewTokenExpiredEvent; - -export interface ControllerBus { - dispatch(event: ControllerEvent): void; -} - -export abstract class Controller { - protected bus: ControllerBus; - #tearingDown = false; - - constructor(bus: ControllerBus) { - this.bus = bus; - } - - async teardown(): Promise { - this.#tearingDown = true; - } - - protected emitErrorEvent(event: ErrorEvent) { - if (this.#tearingDown) { - logger.debug("Suppressing error event during teardown"); - logger.debug(`Error in ${event.source}: ${event.reason}\n`, event.cause); - logger.debug("=> Error contextual data:", event.data); - return; - } - - this.bus.dispatch(event); - } -} - -export abstract class RuntimeController extends Controller { - // ****************** - // Event Handlers - // ****************** - - abstract onBundleStart(_: BundleStartEvent): void; - abstract onBundleComplete(_: BundleCompleteEvent): void; - abstract onPreviewTokenExpired(_: PreviewTokenExpiredEvent): void; - - // ********************* - // Event Dispatchers - // ********************* - - protected emitReloadStartEvent(data: ReloadStartEvent): void { - this.bus.dispatch(data); - } - - protected emitReloadCompleteEvent(data: ReloadCompleteEvent): void { - this.bus.dispatch(data); - } -} diff --git a/packages/remote-bindings/src/startDevWorker/BundlerController.ts b/packages/remote-bindings/src/startDevWorker/BundlerController.ts deleted file mode 100644 index f6f286f600..0000000000 --- a/packages/remote-bindings/src/startDevWorker/BundlerController.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { readFileSync } from "node:fs"; -import { Controller } from "./BaseController"; -import type { ConfigUpdateEvent } from "./events"; - -export class BundlerController extends Controller { - onConfigUpdate({ config }: ConfigUpdateEvent) { - this.bus.dispatch({ type: "bundleStart", config }); - - const entrypointSource = readFileSync(config.entrypoint, "utf8"); - - this.bus.dispatch({ - type: "bundleComplete", - config, - bundle: { - path: config.entrypoint, - entrypointSource, - type: "esm", - modules: [], - }, - }); - } -} diff --git a/packages/remote-bindings/src/startDevWorker/ConfigController.ts b/packages/remote-bindings/src/startDevWorker/ConfigController.ts deleted file mode 100644 index c36cdc9cf8..0000000000 --- a/packages/remote-bindings/src/startDevWorker/ConfigController.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Controller } from "./BaseController"; -import type { StartDevWorkerOptions } from "./types"; - -export class ConfigController extends Controller { - public set(options: StartDevWorkerOptions) { - this.emitConfigUpdateEvent(options); - } - - public patch(options: StartDevWorkerOptions) { - this.emitConfigUpdateEvent(options); - } - - emitConfigUpdateEvent(config: StartDevWorkerOptions) { - this.bus.dispatch({ type: "configUpdate", config }); - } -} diff --git a/packages/remote-bindings/src/startDevWorker/DevEnv.ts b/packages/remote-bindings/src/startDevWorker/DevEnv.ts index 425076ae08..b61c635199 100644 --- a/packages/remote-bindings/src/startDevWorker/DevEnv.ts +++ b/packages/remote-bindings/src/startDevWorker/DevEnv.ts @@ -1,45 +1,53 @@ import { EventEmitter } from "node:events"; +import { readFileSync } from "node:fs"; import { UserError } from "@cloudflare/workers-utils"; import { MiniflareCoreError } from "miniflare"; import { logger } from "../logger"; -import { BundlerController } from "./BundlerController"; -import { ConfigController } from "./ConfigController"; import { ProxyController } from "./ProxyController"; import { RemoteRuntimeController } from "./RemoteRuntimeController"; -import type { ControllerBus, ControllerEvent } from "./BaseController"; -import type { ErrorEvent } from "./events"; -import type { StartDevWorkerOptions, Worker } from "./types"; +import type { ErrorEvent, ReloadCompleteEvent } from "./events"; +import type { Bundle, StartDevWorkerOptions } from "./types"; -export class DevEnv extends EventEmitter implements ControllerBus { - config: ConfigController; - bundler: BundlerController; +export class DevEnv extends EventEmitter { runtime: RemoteRuntimeController; proxy: ProxyController; + #bundle: Bundle; + #config: StartDevWorkerOptions; - async startWorker(options: StartDevWorkerOptions): Promise { - const worker = createWorkerObject(this); - - try { - await this.config.set(options); - } catch (e) { - const error = new Error("An error occurred when starting the server", { - cause: e, - }); - this.proxy.ready.reject(error); - await worker.dispose(); - throw e; - } + start() { + this.proxy.start(this.#config); + this.update(this.#config); + } - return worker; + update(config: StartDevWorkerOptions) { + this.#config = config; + this.proxy.pause(config); + this.runtime.onUpdateStart(); + this.runtime.onBundleComplete({ + type: "bundleComplete", + config, + bundle: this.#bundle, + }); } - constructor() { + constructor(config: StartDevWorkerOptions) { super(); - this.config = new ConfigController(this); - this.bundler = new BundlerController(this); - this.runtime = new RemoteRuntimeController(this); - this.proxy = new ProxyController(this); + this.#config = config; + this.#bundle = { + path: config.entrypoint, + entrypointSource: readFileSync(config.entrypoint, "utf8"), + type: "esm", + modules: [], + }; + this.proxy = new ProxyController( + (event) => this.handleErrorEvent(event), + () => this.runtime.onPreviewTokenExpired() + ); + this.runtime = new RemoteRuntimeController( + (event) => this.handleErrorEvent(event), + (event) => this.handleReloadComplete(event) + ); this.on("error", (event: ErrorEvent) => { logger.debug(`Error in ${event.source}: ${event.reason}\n`, event.cause); @@ -47,56 +55,9 @@ export class DevEnv extends EventEmitter implements ControllerBus { }); } - /** - * Central message bus dispatch method. - * All events from controllers flow through here, making the event routing explicit and traceable. - * - * Event flow: - * - ConfigController emits configUpdate → BundlerController, ProxyController - * - BundlerController emits bundleStart → ProxyController, RuntimeControllers - * - BundlerController emits bundleComplete → RuntimeControllers - * - RuntimeController emits reloadStart → ProxyController - * - RuntimeController emits reloadComplete → ProxyController - * - ProxyController emits previewTokenExpired → RuntimeControllers - * - Any controller emits error → DevEnv error handler - * - * `reloadComplete` is also re-emitted as an external EventEmitter event - * (`devEnv.on("reloadComplete", ...)`) so callers like - * `RemoteProxySession.updateBindings` can wait for the reload to finish. - */ - dispatch(event: ControllerEvent): void { - switch (event.type) { - case "error": - this.handleErrorEvent(event); - break; - - case "configUpdate": - this.bundler.onConfigUpdate(event); - this.proxy.onConfigUpdate(event); - break; - - case "bundleStart": - this.proxy.onBundleStart(event); - this.runtime.onBundleStart(event); - break; - - case "bundleComplete": - this.runtime.onBundleComplete(event); - break; - - case "reloadStart": - this.proxy.onReloadStart(event); - break; - - case "reloadComplete": - this.proxy.onReloadComplete(event); - this.emit("reloadComplete", event); - break; - - case "previewTokenExpired": - this.runtime.onPreviewTokenExpired(event); - break; - } + private handleReloadComplete(event: ReloadCompleteEvent) { + this.proxy.play(event); + this.emit("reloadComplete", event); } private handleErrorEvent(event: ErrorEvent): void { @@ -127,31 +88,8 @@ export class DevEnv extends EventEmitter implements ControllerBus { async teardown() { logger.debug("DevEnv teardown beginning..."); - await Promise.all([ - this.config.teardown(), - this.bundler.teardown(), - this.runtime.teardown(), - this.proxy.teardown(), - ]); + await Promise.all([this.runtime.teardown(), this.proxy.teardown()]); logger.debug("DevEnv teardown complete"); } } - -function createWorkerObject(devEnv: DevEnv): Worker { - return { - get ready() { - return devEnv.proxy.ready.promise.then(() => undefined); - }, - get url() { - return devEnv.proxy.ready.promise.then((ev) => ev.url); - }, - patchConfig(config) { - return devEnv.config.patch(config); - }, - async dispose() { - await devEnv.teardown(); - }, - raw: devEnv, - }; -} diff --git a/packages/remote-bindings/src/startDevWorker/ProxyController.ts b/packages/remote-bindings/src/startDevWorker/ProxyController.ts index 9a256ab189..5ecc62f937 100644 --- a/packages/remote-bindings/src/startDevWorker/ProxyController.ts +++ b/packages/remote-bindings/src/startDevWorker/ProxyController.ts @@ -10,19 +10,14 @@ import { handleStructuredLogs, WranglerLog, } from "../utils/miniflare"; -import { Controller } from "./BaseController"; import { castErrorCause } from "./events"; import { createDeferred } from "./utils"; import type { - BundleStartEvent, - ConfigUpdateEvent, ErrorEvent, - ProxyData, ProxyWorkerIncomingRequestBody, ProxyWorkerOutgoingRequestBody, ReadyEvent, ReloadCompleteEvent, - ReloadStartEvent, SerializedError, } from "./events"; import type { Bundle, StartDevWorkerOptions } from "./types"; @@ -32,7 +27,7 @@ const proxyWorkerPath = fileURLToPath( new URL("./dev-proxy-worker.mjs", import.meta.url) ); -export class ProxyController extends Controller { +export class ProxyController { public ready = createDeferred(); public localServerReady = createDeferred(); @@ -44,6 +39,11 @@ export class ProxyController extends Controller { secret = randomUUID(); + constructor( + private onError: (event: ErrorEvent) => void, + private onPreviewTokenExpired: () => void + ) {} + protected createProxyWorker() { if (this._torndown || this.proxyWorker) { return; @@ -51,9 +51,9 @@ export class ProxyController extends Controller { assert(this.latestConfig !== undefined); const proxyWorkerOptions: MiniflareOptions = { - host: this.latestConfig.dev?.server?.hostname, - port: this.latestConfig.dev?.server?.port, - https: this.latestConfig.dev?.server?.secure, + host: this.latestConfig.server.hostname, + port: this.latestConfig.server.port, + https: this.latestConfig.server.secure, stripDisablePrettyError: false, unsafeLocalExplorer: false, workers: [ @@ -170,27 +170,15 @@ export class ProxyController extends Controller { ); } } - // ****************** - // Event Handlers - // ****************** - - onConfigUpdate(data: ConfigUpdateEvent) { - this.latestConfig = data.config; + start(config: StartDevWorkerOptions) { + this.latestConfig = config; this.createProxyWorker(); - - void this.sendMessageToProxyWorker({ type: "pause" }); } - onBundleStart(data: BundleStartEvent) { - this.latestConfig = data.config; - + pause(config: StartDevWorkerOptions) { + this.latestConfig = config; void this.sendMessageToProxyWorker({ type: "pause" }); } - onReloadStart(data: ReloadStartEvent) { - this.latestConfig = data.config; - - void this.sendMessageToProxyWorker({ type: "pause" }); - } - onReloadComplete(data: ReloadCompleteEvent) { + play(data: ReloadCompleteEvent) { this.localServerReady.resolve(); this.latestConfig = data.config; @@ -204,7 +192,7 @@ export class ProxyController extends Controller { onProxyWorkerMessage(message: ProxyWorkerOutgoingRequestBody) { switch (message.type) { case "previewTokenExpired": - this.emitPreviewTokenExpiredEvent(message.proxyData); + this.onPreviewTokenExpired(); break; case "error": @@ -216,8 +204,7 @@ export class ProxyController extends Controller { } } _torndown = false; - override async teardown() { - await super.teardown(); + async teardown() { logger.debug("ProxyController teardown beginning..."); this._torndown = true; @@ -242,22 +229,9 @@ export class ProxyController extends Controller { this.ready.resolve(data); } - emitPreviewTokenExpiredEvent(proxyData: ProxyData) { - this.bus.dispatch({ - type: "previewTokenExpired", - proxyData, - }); - } - - override emitErrorEvent(data: ErrorEvent): void; - override emitErrorEvent( - reason: string, - cause?: Error | SerializedError - ): void; - override emitErrorEvent( - data: string | ErrorEvent, - cause?: Error | SerializedError - ) { + emitErrorEvent(data: ErrorEvent): void; + emitErrorEvent(reason: string, cause?: Error | SerializedError): void; + emitErrorEvent(data: string | ErrorEvent, cause?: Error | SerializedError) { if (typeof data === "string") { data = { type: "error", @@ -270,7 +244,13 @@ export class ProxyController extends Controller { }, }; } - super.emitErrorEvent(data); + if (this._torndown) { + logger.debug("Suppressing error event during teardown"); + logger.debug(`Error in ${data.source}: ${data.reason}\n`, data.cause); + logger.debug("=> Error contextual data:", data.data); + return; + } + this.onError(data); } } diff --git a/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts b/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts index baa438abea..461e8ae1d0 100644 --- a/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts +++ b/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts @@ -1,9 +1,5 @@ -import assert from "node:assert"; import { getAccessHeaders } from "@cloudflare/workers-auth"; -import { - MissingConfigError, - retryOnAPIFailure, -} from "@cloudflare/workers-utils"; +import { retryOnAPIFailure } from "@cloudflare/workers-utils"; import chalk from "chalk"; import { Mutex } from "miniflare"; import { WebSocket } from "ws"; @@ -17,11 +13,9 @@ import { import { realishPrintLogs } from "../utils/printing"; import { createRemoteWorkerInit, - getWorkerAccountAndContext, handlePreviewSessionCreationError, handlePreviewSessionUploadError, } from "../utils/remote"; -import { RuntimeController } from "./BaseController"; import { castErrorCause } from "./events"; import { PREVIEW_TOKEN_REFRESH_INTERVAL, unwrapHook } from "./utils"; import type { @@ -31,18 +25,16 @@ import type { } from "../utils/create-worker-preview"; import type { BundleCompleteEvent, - BundleStartEvent, - PreviewTokenExpiredEvent, + ErrorEvent, ProxyData, ReloadCompleteEvent, - ReloadStartEvent, } from "./events"; import type { Bundle, StartDevWorkerOptions } from "./types"; import type { ComplianceConfig } from "@cloudflare/workers-utils"; type CreateRemoteWorkerInitProps = Parameters[0]; -export class RemoteRuntimeController extends RuntimeController { +export class RemoteRuntimeController { #abortController = new AbortController(); #currentBundleId = 0; @@ -58,6 +50,12 @@ export class RemoteRuntimeController extends RuntimeController { // Timer for proactive token refresh before the 1-hour expiry #refreshTimer?: ReturnType; + #tearingDown = false; + + constructor( + private onError: (event: ErrorEvent) => void, + private onReloadComplete: (event: ReloadCompleteEvent) => void + ) {} async #previewSession( props: CfAccount & { @@ -66,15 +64,11 @@ export class RemoteRuntimeController extends RuntimeController { } ): Promise { try { - const { workerAccount, workerContext } = - getWorkerAccountAndContext(props); - return await retryOnAPIFailure( () => createPreviewSession( props.complianceConfig, - workerAccount, - workerContext, + props, this.#abortController.signal, props.name ), @@ -99,7 +93,6 @@ export class RemoteRuntimeController extends RuntimeController { CfAccount & { complianceConfig: ComplianceConfig; bundleId: number; - minimal_mode?: boolean; } ): Promise { if (!this.#session) { @@ -119,10 +112,6 @@ export class RemoteRuntimeController extends RuntimeController { this.#activeTail?.removeAllListeners("error"); this.#activeTail?.on("error", () => {}); this.#activeTail?.terminate(); - const { workerAccount, workerContext } = getWorkerAccountAndContext({ - accountId: props.accountId, - apiToken: props.apiToken, - }); const init = createRemoteWorkerInit({ bundle: props.bundle, name: props.name, @@ -136,11 +125,9 @@ export class RemoteRuntimeController extends RuntimeController { createWorkerPreview( props.complianceConfig, init, - workerAccount, - workerContext, + props, session, - this.#abortController.signal, - props.minimal_mode + this.#abortController.signal ), logger, undefined, @@ -164,7 +151,7 @@ export class RemoteRuntimeController extends RuntimeController { this.#activeTail.on("message", realishPrintLogs); // Best-effort log streaming: ignore errors instead of letting them // propagate as unhandled exceptions. The signal we pass to the `ws` - // constructor is shared with `onBundleStart`'s abort, which destroys + // constructor is shared with update cancellation, which destroys // the underlying upgrade request with `AbortError` every time a new // bundle starts. The existing `terminate` paths in `#previewToken` // and `teardown()` re-install no-op listeners before shutting the @@ -231,7 +218,6 @@ export class RemoteRuntimeController extends RuntimeController { compatibilityDate: config.compatibilityDate, compatibilityFlags: config.compatibilityFlags, bundleId, - minimal_mode: config.dev.remote === "minimal", }); // If we received a new `bundleComplete` event before we were able to // dispatch a `reloadComplete` for this bundle, ignore this bundle. @@ -259,7 +245,7 @@ export class RemoteRuntimeController extends RuntimeController { this.#latestProxyData = proxyData; - this.emitReloadCompleteEvent({ + this.onReloadComplete({ type: "reloadComplete", bundle, config, @@ -274,10 +260,7 @@ export class RemoteRuntimeController extends RuntimeController { clearTimeout(this.#refreshTimer); this.#refreshTimer = setTimeout(() => { if (this.#latestProxyData) { - this.onPreviewTokenExpired({ - type: "previewTokenExpired", - proxyData: this.#latestProxyData, - }); + this.onPreviewTokenExpired(); } }, interval); } @@ -291,12 +274,7 @@ export class RemoteRuntimeController extends RuntimeController { logger.log(chalk.dim("⎔ Starting remote preview...")); try { - if (!config.dev?.auth) { - throw new MissingConfigError("config.dev.auth"); - } - - assert(config.dev.auth); - const auth = await unwrapHook(config.dev.auth); + const auth = await unwrapHook(config.auth); this.#latestConfig = config; this.#latestBundle = bundle; @@ -305,12 +283,6 @@ export class RemoteRuntimeController extends RuntimeController { logger.log(chalk.dim("⎔ Detected changes, restarted server.")); } - // Recreate session if the worker name changed, since the session - // host bakes in the name from creation time. - if (this.#session && config.name !== this.#session.name) { - this.#session = undefined; - } - this.#session ??= await this.#getPreviewSession(config, auth); await this.#updatePreviewToken(config, bundle, auth, id); } catch (error) { @@ -337,8 +309,7 @@ export class RemoteRuntimeController extends RuntimeController { } try { - assert(this.#latestConfig.dev.auth); - const auth = await unwrapHook(this.#latestConfig.dev.auth); + const auth = await unwrapHook(this.#latestConfig.auth); this.#session = await this.#getPreviewSession(this.#latestConfig, auth); @@ -371,7 +342,7 @@ export class RemoteRuntimeController extends RuntimeController { // Event Handlers // ****************** - onBundleStart(_: BundleStartEvent) { + onUpdateStart() { // Abort any previous operations when a new bundle is started this.#abortController.abort(); this.#abortController = new AbortController(); @@ -380,26 +351,15 @@ export class RemoteRuntimeController extends RuntimeController { onBundleComplete(ev: BundleCompleteEvent) { const id = ++this.#currentBundleId; - if (!ev.config.dev?.remote) { - void this.#mutex.runWith(() => this.teardown()); - return; - } - - this.emitReloadStartEvent({ - type: "reloadStart", - config: ev.config, - bundle: ev.bundle, - }); - void this.#mutex.runWith(() => this.#onBundleComplete(ev, id)); } - onPreviewTokenExpired(_: PreviewTokenExpiredEvent): void { + onPreviewTokenExpired(): void { logger.log(chalk.dim("⎔ Refreshing preview token...")); void this.#mutex.runWith(() => this.#refreshPreviewToken()); } - override async teardown() { - await super.teardown(); + async teardown() { + this.#tearingDown = true; if (this.#session) { logger.log(chalk.dim("⎔ Shutting down remote preview...")); } @@ -414,14 +374,13 @@ export class RemoteRuntimeController extends RuntimeController { logger.debug("RemoteRuntimeController teardown complete"); } - // ********************* - // Event Dispatchers - // ********************* - - override emitReloadStartEvent(data: ReloadStartEvent) { - this.bus.dispatch(data); - } - override emitReloadCompleteEvent(data: ReloadCompleteEvent) { - this.bus.dispatch(data); + private emitErrorEvent(event: ErrorEvent) { + if (this.#tearingDown) { + logger.debug("Suppressing error event during teardown"); + logger.debug(`Error in ${event.source}: ${event.reason}\n`, event.cause); + logger.debug("=> Error contextual data:", event.data); + return; + } + this.onError(event); } } diff --git a/packages/remote-bindings/src/startDevWorker/events.ts b/packages/remote-bindings/src/startDevWorker/events.ts index 2edb8f0a28..533a260eea 100644 --- a/packages/remote-bindings/src/startDevWorker/events.ts +++ b/packages/remote-bindings/src/startDevWorker/events.ts @@ -26,19 +26,6 @@ export function castErrorCause(cause: unknown) { return error; } -// ConfigController -export type ConfigUpdateEvent = { - type: "configUpdate"; - - config: StartDevWorkerOptions; -}; - -// BundlerController -export type BundleStartEvent = { - type: "bundleStart"; - - config: StartDevWorkerOptions; -}; export type BundleCompleteEvent = { type: "bundleComplete"; @@ -46,13 +33,6 @@ export type BundleCompleteEvent = { bundle: Bundle; }; -// RuntimeController -export type ReloadStartEvent = { - type: "reloadStart"; - - config: StartDevWorkerOptions; - bundle: Bundle; -}; export type ReloadCompleteEvent = { type: "reloadComplete"; @@ -60,13 +40,6 @@ export type ReloadCompleteEvent = { bundle: Bundle; proxyData: ProxyData; }; -// ProxyController -export type PreviewTokenExpiredEvent = { - type: "previewTokenExpired"; - - proxyData: ProxyData; - // ... other details of failed request/response -}; export type ReadyEvent = { type: "ready"; proxyWorker: Miniflare; diff --git a/packages/remote-bindings/src/startDevWorker/types.ts b/packages/remote-bindings/src/startDevWorker/types.ts index 7d43da937f..6c7791c853 100644 --- a/packages/remote-bindings/src/startDevWorker/types.ts +++ b/packages/remote-bindings/src/startDevWorker/types.ts @@ -1,4 +1,3 @@ -import type { DevEnv } from "./DevEnv"; import type { AsyncHook, CfAccount, @@ -8,14 +7,6 @@ import type { StartDevWorkerInput, } from "@cloudflare/workers-utils"; -export interface Worker { - ready: Promise; - url: Promise; - patchConfig(config: StartDevWorkerOptions): void; - dispose(): Promise; - raw: DevEnv; -} - export type StartDevWorkerOptions = { name: string; entrypoint: string; @@ -23,14 +14,11 @@ export type StartDevWorkerOptions = { compatibilityDate: StartDevWorkerInput["compatibilityDate"]; compatibilityFlags: StartDevWorkerInput["compatibilityFlags"]; complianceRegion: Config["compliance_region"]; - dev: { - remote: "minimal"; - auth?: AsyncHook; - server: { - hostname?: string; - port: number; - secure: boolean; - }; + auth: AsyncHook; + server: { + hostname?: string; + port: number; + secure: boolean; }; }; diff --git a/packages/remote-bindings/src/utils/create-worker-preview.ts b/packages/remote-bindings/src/utils/create-worker-preview.ts index abe9bbbba5..d65417feba 100644 --- a/packages/remote-bindings/src/utils/create-worker-preview.ts +++ b/packages/remote-bindings/src/utils/create-worker-preview.ts @@ -16,7 +16,6 @@ import { logger } from "../logger"; import type { CfWorkerInitWithName } from "./remote"; import type { ApiCredentials, - CfWorkerContext, ComplianceConfig, } from "@cloudflare/workers-utils"; import type { HeadersInit, RequestInit } from "undici"; @@ -119,33 +118,8 @@ export interface CfPreviewSession { * The host where the session is available. */ host: string; - /** - * The worker name used when the session was created. - * Used to detect when the session needs to be recreated. - */ - name: string | undefined; } -/** - * Session configuration for realish preview. This is sent to the API as the - * `wrangler-session-config` form data part. - * - * Only one of `workers_dev` and `routes` can be specified: - * * If `workers_dev` is set, the preview will run using a `workers.dev` subdomain. - * * If `routes` is set, the preview will run using the list of routes provided, which must be under a single zone - * - * `minimal_mode` is a flag to tell the API to enable "raw" mode bindings in this session - */ -type CfPreviewMode = - | { - workers_dev: true; - minimal_mode?: boolean; - } - | { - routes: string[]; - minimal_mode?: boolean; - }; - /** * A preview token. */ @@ -170,21 +144,6 @@ export interface CfPreviewToken { tailUrl?: string; } -// URLs are often relative to the zone. Sometimes the base zone -// will be grey-clouded, and so the host must be swapped out for -// the worker route host, which is more likely to be orange-clouded. -// However, this switching should only happen if we're running a zone preview -// rather than a workers.dev preview -function switchHost( - originalUrl: string, - host: string | undefined, - zonePreview: boolean -): URL { - const url = new URL(originalUrl); - url.hostname = zonePreview ? (host ?? url.hostname) : url.hostname; - return url; -} - /** * Try and get a re-encoded token from the edge. Returns null if the exchange * fails for any reason (expected with particular zone settings). @@ -192,11 +151,10 @@ function switchHost( */ async function tryExpandToken( exchangeUrl: string, - ctx: CfWorkerContext, abortSignal: AbortSignal ): Promise { try { - const switchedExchangeUrl = switchHost(exchangeUrl, ctx.host, !!ctx.zone); + const switchedExchangeUrl = new URL(exchangeUrl); const accessHeaders = await getAccessHeaders(switchedExchangeUrl.hostname, { logger, @@ -244,14 +202,11 @@ async function tryExpandToken( export async function createPreviewSession( complianceConfig: ComplianceConfig, account: CfAccount, - ctx: CfWorkerContext, abortSignal: AbortSignal, - name: string | undefined + name: string ): Promise { const { accountId } = account; - const initUrl = ctx.zone - ? `/zones/${ctx.zone}/workers/edge-preview` - : `/accounts/${accountId}/workers/subdomain/edge-preview`; + const initUrl = `/accounts/${accountId}/workers/subdomain/edge-preview`; const { token, exchange_url } = await fetchResult<{ token: string; @@ -259,35 +214,26 @@ export async function createPreviewSession( }>(complianceConfig, account, initUrl, undefined, withTimeout(abortSignal)); const previewSessionToken = exchange_url - ? ((await tryExpandToken(exchange_url, ctx, withTimeout(abortSignal))) ?? - token) + ? ((await tryExpandToken(exchange_url, withTimeout(abortSignal))) ?? token) : token; try { - let host = ctx.host; - if (!host) { - const subdomain = await getOrRegisterWorkersDevSubdomain( - complianceConfig, - account, - withTimeout(abortSignal) - ); - host = `${name ?? crypto.randomUUID()}.${subdomain}${getComplianceRegionSubdomain(complianceConfig)}.workers.dev`; - } + const subdomain = await getOrRegisterWorkersDevSubdomain( + complianceConfig, + account, + withTimeout(abortSignal) + ); + const host = `${name}.${subdomain}${getComplianceRegionSubdomain(complianceConfig)}.workers.dev`; return { value: previewSessionToken, - host: host, - name, + host, }; } catch (e) { if (!(e instanceof ParseError)) { throw e; } else { throw new UserError( - `Could not create remote preview session on ${ - ctx.zone - ? ` host \`${ctx.host}\` on zone \`${ctx.zone}\`` - : `your account` - }.`, + "Could not create remote preview session on your account.", { telemetryMessage: "remote preview session creation failed" } ); } @@ -297,41 +243,22 @@ export async function createPreviewSession( /** * Creates a preview token. */ -async function createPreviewToken( +export async function createWorkerPreview( complianceConfig: ComplianceConfig, - account: CfAccount, worker: CfWorkerInitWithName, - ctx: CfWorkerContext, + account: CfAccount, session: CfPreviewSession, - abortSignal: AbortSignal, - minimal_mode?: boolean + abortSignal: AbortSignal ): Promise { const { value, host } = session; const { accountId } = account; const url = `/accounts/${accountId}/workers/scripts/${worker.name}/edge-preview`; - const mode: CfPreviewMode = ctx.zone - ? { - routes: - ctx.routes && ctx.routes.length > 0 - ? // extract all the route patterns - ctx.routes.map((route) => { - if (typeof route === "string") { - return route; - } - if (route.custom_domain) { - return `${route.pattern}/*`; - } - return route.pattern; - }) - : // if there aren't any patterns, then just match on all routes - ["*/*"], - minimal_mode, - } - : { workers_dev: true, minimal_mode }; - const formData = createWorkerUploadForm(worker, worker.bindings); - formData.set("wrangler-session-config", JSON.stringify(mode)); + formData.set( + "wrangler-session-config", + JSON.stringify({ workers_dev: true, minimal_mode: true }) + ); const { preview_token, tail_url } = await fetchResult<{ preview_token: string; @@ -356,31 +283,3 @@ async function createPreviewToken( tailUrl: tail_url, }; } - -/** - * A stub to create a Cloudflare Worker preview. - * - * @example - * const {value, host} = await createWorker(init, acct); - */ -export async function createWorkerPreview( - complianceConfig: ComplianceConfig, - init: CfWorkerInitWithName, - account: CfAccount, - ctx: CfWorkerContext, - session: CfPreviewSession, - abortSignal: AbortSignal, - minimal_mode?: boolean -): Promise { - const token = await createPreviewToken( - complianceConfig, - account, - init, - ctx, - session, - abortSignal, - minimal_mode - ); - - return token; -} diff --git a/packages/remote-bindings/src/utils/remote.ts b/packages/remote-bindings/src/utils/remote.ts index cf6158c296..97cef16bf7 100644 --- a/packages/remote-bindings/src/utils/remote.ts +++ b/packages/remote-bindings/src/utils/remote.ts @@ -5,10 +5,7 @@ import { APIError, UserError } from "@cloudflare/workers-utils"; import { logger } from "../logger"; import { isAbortError } from "./isAbortError"; import type { Bundle } from "../startDevWorker/types"; -import type { CfAccount } from "./create-worker-preview"; import type { - ApiCredentials, - CfWorkerContext, CfWorkerInit, StartDevWorkerInput, } from "@cloudflare/workers-utils"; @@ -160,26 +157,6 @@ export function createRemoteWorkerInit(props: { return init; } -export function getWorkerAccountAndContext(props: { - accountId: string; - apiToken: ApiCredentials; -}): { workerAccount: CfAccount; workerContext: CfWorkerContext } { - const workerAccount: CfAccount = { - accountId: props.accountId, - apiToken: props.apiToken, - }; - - const workerContext: CfWorkerContext = { - env: undefined, - zone: undefined, - host: undefined, - routes: undefined, - sendMetrics: undefined, - }; - - return { workerAccount, workerContext }; -} - /** * A switch for handling thrown error mappings to user friendly * messages, does not perform any logic other than logging errors. From 5bdef90c604acc90bfd6af2ff4f76679da996b58 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 16 Jul 2026 17:19:29 +0100 Subject: [PATCH 27/34] [remote-bindings] Embed workers for direct consumers --- packages/remote-bindings/package.json | 4 +- .../remote-bindings/scripts/embed-workers.ts | 39 +++++++++++ packages/remote-bindings/src/auth.test.ts | 6 -- packages/remote-bindings/src/logger.ts | 1 - .../src/maybe-start-or-update-session.test.ts | 6 -- .../src/start-remote-proxy-session.ts | 7 +- .../src/startDevWorker/DevEnv.ts | 5 +- .../src/startDevWorker/ProxyController.ts | 16 ++--- .../src/startDevWorker/types.ts | 2 +- packages/remote-bindings/src/worker.d.ts | 4 ++ packages/remote-bindings/tsdown.config.ts | 42 ++++-------- packages/remote-bindings/vitest.config.mts | 10 +++ packages/vite-plugin-cloudflare/package.json | 1 + .../src/miniflare-options.ts | 64 +++++++++++++++---- packages/vitest-pool-workers/package.json | 1 + .../vitest-pool-workers/src/pool/config.ts | 40 +++++++++--- .../key-providers/lazy-installer.ts | 9 +-- pnpm-lock.yaml | 9 +++ 18 files changed, 173 insertions(+), 93 deletions(-) create mode 100644 packages/remote-bindings/scripts/embed-workers.ts create mode 100644 packages/remote-bindings/src/worker.d.ts create mode 100644 packages/remote-bindings/vitest.config.mts diff --git a/packages/remote-bindings/package.json b/packages/remote-bindings/package.json index c5c7cc3474..bb80785ca6 100644 --- a/packages/remote-bindings/package.json +++ b/packages/remote-bindings/package.json @@ -13,8 +13,7 @@ "directory": "packages/remote-bindings" }, "files": [ - "dist", - "templates" + "dist" ], "type": "module", "sideEffects": false, @@ -41,6 +40,7 @@ "@types/ws": "^8.5.13", "capnweb": "catalog:default", "chalk": "catalog:default", + "esbuild": "catalog:default", "miniflare": "workspace:*", "tsdown": "0.16.3", "typescript": "catalog:default", diff --git a/packages/remote-bindings/scripts/embed-workers.ts b/packages/remote-bindings/scripts/embed-workers.ts new file mode 100644 index 0000000000..9dbc1d747e --- /dev/null +++ b/packages/remote-bindings/scripts/embed-workers.ts @@ -0,0 +1,39 @@ +import path from "node:path"; +import { build } from "esbuild"; + +const WORKER_PREFIX = "\0worker:"; +const templatesDir = path.resolve(import.meta.dirname, "../templates"); + +export function embedWorkersPlugin() { + return { + name: "embed-workers", + resolveId(id: string) { + if (!id.startsWith("worker:")) { + return; + } + return `${WORKER_PREFIX}${id.slice("worker:".length)}`; + }, + async load(id: string) { + if (!id.startsWith(WORKER_PREFIX)) { + return; + } + const result = await build({ + entryPoints: [ + path.resolve(templatesDir, `${id.slice(WORKER_PREFIX.length)}.ts`), + ], + platform: "node", + conditions: ["workerd", "worker", "browser"], + format: "esm", + target: "esnext", + bundle: true, + write: false, + external: ["cloudflare:email", "cloudflare:workers"], + }); + const source = result.outputFiles[0]?.text; + if (source === undefined) { + throw new Error(`Failed to bundle Worker ${id}`); + } + return `export default ${JSON.stringify(source)};`; + }, + }; +} diff --git a/packages/remote-bindings/src/auth.test.ts b/packages/remote-bindings/src/auth.test.ts index 89950b3617..f4c5f6a075 100644 --- a/packages/remote-bindings/src/auth.test.ts +++ b/packages/remote-bindings/src/auth.test.ts @@ -54,12 +54,6 @@ function createTestLogger(): RemoteBindingsLogger { warn: vi.fn(), error: vi.fn(), console: vi.fn(), - once: { - info: vi.fn(), - log: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, }; } diff --git a/packages/remote-bindings/src/logger.ts b/packages/remote-bindings/src/logger.ts index 412e12c256..a6c57dad9b 100644 --- a/packages/remote-bindings/src/logger.ts +++ b/packages/remote-bindings/src/logger.ts @@ -2,7 +2,6 @@ import type { Logger, LoggerLevel } from "@cloudflare/workers-utils"; export type RemoteBindingsLogger = Logger & { loggerLevel: LoggerLevel; - once: NonNullable; }; export let logger: RemoteBindingsLogger; diff --git a/packages/remote-bindings/src/maybe-start-or-update-session.test.ts b/packages/remote-bindings/src/maybe-start-or-update-session.test.ts index 3396388baf..8a51a6b06d 100644 --- a/packages/remote-bindings/src/maybe-start-or-update-session.test.ts +++ b/packages/remote-bindings/src/maybe-start-or-update-session.test.ts @@ -14,12 +14,6 @@ function createTestLogger(): RemoteBindingsLogger { warn: vi.fn(), error: vi.fn(), console: vi.fn(), - once: { - info: vi.fn(), - log: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, }; } diff --git a/packages/remote-bindings/src/start-remote-proxy-session.ts b/packages/remote-bindings/src/start-remote-proxy-session.ts index 30f772b6c8..9e00d410ea 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.ts @@ -1,9 +1,9 @@ import { randomUUID } from "node:crypto"; import events from "node:events"; -import { fileURLToPath } from "node:url"; import { UserError } from "@cloudflare/workers-utils"; import chalk from "chalk"; import { DeferredPromise } from "miniflare"; +import remoteBindingsWorkerSource from "worker:remoteBindings/ProxyServerWorker"; import { getRemoteBindingsAuthHook } from "./auth"; import { initLogger } from "./logger"; import { DevEnv } from "./startDevWorker/DevEnv"; @@ -73,12 +73,9 @@ export async function startRemoteProxySession( initLogger(options.logger); options.logger.log(chalk.dim("⎔ Establishing remote connection...")); const rawBindings = toRawBindings(bindings); - const remoteBindingsWorkerPath = fileURLToPath( - new URL("./proxy-worker.js", import.meta.url) - ); const workerConfig = { name: options.workerName ?? randomUUID(), - entrypoint: remoteBindingsWorkerPath, + entrypointSource: remoteBindingsWorkerSource, compatibilityDate: "2025-04-28", compatibilityFlags: [], complianceRegion: options.complianceRegion, diff --git a/packages/remote-bindings/src/startDevWorker/DevEnv.ts b/packages/remote-bindings/src/startDevWorker/DevEnv.ts index b61c635199..71a180acaa 100644 --- a/packages/remote-bindings/src/startDevWorker/DevEnv.ts +++ b/packages/remote-bindings/src/startDevWorker/DevEnv.ts @@ -1,5 +1,4 @@ import { EventEmitter } from "node:events"; -import { readFileSync } from "node:fs"; import { UserError } from "@cloudflare/workers-utils"; import { MiniflareCoreError } from "miniflare"; import { logger } from "../logger"; @@ -35,8 +34,8 @@ export class DevEnv extends EventEmitter { this.#config = config; this.#bundle = { - path: config.entrypoint, - entrypointSource: readFileSync(config.entrypoint, "utf8"), + path: "proxy-worker.js", + entrypointSource: config.entrypointSource, type: "esm", modules: [], }; diff --git a/packages/remote-bindings/src/startDevWorker/ProxyController.ts b/packages/remote-bindings/src/startDevWorker/ProxyController.ts index 5ecc62f937..8154e1fd3b 100644 --- a/packages/remote-bindings/src/startDevWorker/ProxyController.ts +++ b/packages/remote-bindings/src/startDevWorker/ProxyController.ts @@ -1,9 +1,8 @@ import assert from "node:assert"; import { randomUUID } from "node:crypto"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; import { assertNever } from "@cloudflare/workers-utils"; import { LogLevel, Miniflare, Mutex, Response } from "miniflare"; +import proxyWorkerSource from "worker:startDevWorker/ProxyWorker"; import { logger } from "../logger"; import { castLogLevel, @@ -23,10 +22,6 @@ import type { import type { Bundle, StartDevWorkerOptions } from "./types"; import type { LogOptions, MiniflareOptions } from "miniflare"; -const proxyWorkerPath = fileURLToPath( - new URL("./dev-proxy-worker.mjs", import.meta.url) -); - export class ProxyController { public ready = createDeferred(); @@ -61,8 +56,13 @@ export class ProxyController { name: "ProxyWorker", compatibilityDate: "2023-12-18", compatibilityFlags: ["nodejs_compat"], - modulesRoot: path.dirname(proxyWorkerPath), - modules: [{ type: "ESModule", path: proxyWorkerPath }], + modules: [ + { + type: "ESModule", + path: "dev-proxy-worker.mjs", + contents: proxyWorkerSource, + }, + ], durableObjects: { DURABLE_OBJECT: { className: "ProxyWorker", diff --git a/packages/remote-bindings/src/startDevWorker/types.ts b/packages/remote-bindings/src/startDevWorker/types.ts index 6c7791c853..68a33a27b0 100644 --- a/packages/remote-bindings/src/startDevWorker/types.ts +++ b/packages/remote-bindings/src/startDevWorker/types.ts @@ -9,7 +9,7 @@ import type { export type StartDevWorkerOptions = { name: string; - entrypoint: string; + entrypointSource: string; bindings: NonNullable; compatibilityDate: StartDevWorkerInput["compatibilityDate"]; compatibilityFlags: StartDevWorkerInput["compatibilityFlags"]; diff --git a/packages/remote-bindings/src/worker.d.ts b/packages/remote-bindings/src/worker.d.ts new file mode 100644 index 0000000000..2f7abc72cd --- /dev/null +++ b/packages/remote-bindings/src/worker.d.ts @@ -0,0 +1,4 @@ +declare module "worker:*" { + const source: string; + export default source; +} diff --git a/packages/remote-bindings/tsdown.config.ts b/packages/remote-bindings/tsdown.config.ts index 6e730d98bc..8aac64aff5 100644 --- a/packages/remote-bindings/tsdown.config.ts +++ b/packages/remote-bindings/tsdown.config.ts @@ -1,34 +1,14 @@ import { defineConfig } from "tsdown"; +import { embedWorkersPlugin } from "./scripts/embed-workers.ts"; -export default defineConfig([ - { - entry: { - index: "src/index.ts", - }, - platform: "node", - outDir: "dist", - dts: true, - tsconfig: "tsconfig.json", - define: { - __filename: "import.meta.filename", - }, - external: ["miniflare"], +export default defineConfig({ + entry: { + index: "src/index.ts", }, - { - entry: { - "proxy-worker": "templates/remoteBindings/ProxyServerWorker.ts", - }, - platform: "neutral", - outDir: "dist", - dts: false, - external: ["cloudflare:email", "cloudflare:workers"], - }, - { - entry: { - "dev-proxy-worker": "templates/startDevWorker/ProxyWorker.ts", - }, - platform: "node", - outDir: "dist", - dts: false, - }, -]); + platform: "node", + outDir: "dist", + dts: true, + tsconfig: "tsconfig.json", + external: [/^(?!(?:\0)?worker:)[^./]/], + plugins: [embedWorkersPlugin()], +}); diff --git a/packages/remote-bindings/vitest.config.mts b/packages/remote-bindings/vitest.config.mts new file mode 100644 index 0000000000..0c3c424439 --- /dev/null +++ b/packages/remote-bindings/vitest.config.mts @@ -0,0 +1,10 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; +import { embedWorkersPlugin } from "./scripts/embed-workers"; + +export default mergeConfig( + configShared, + defineConfig({ + plugins: [embedWorkersPlugin()], + }) +); diff --git a/packages/vite-plugin-cloudflare/package.json b/packages/vite-plugin-cloudflare/package.json index c6b7796a29..035f2c388b 100644 --- a/packages/vite-plugin-cloudflare/package.json +++ b/packages/vite-plugin-cloudflare/package.json @@ -64,6 +64,7 @@ "@cloudflare/config": "workspace:*", "@cloudflare/containers-shared": "workspace:*", "@cloudflare/mock-npm-registry": "workspace:*", + "@cloudflare/remote-bindings": "workspace:*", "@cloudflare/runtime-types": "workspace:*", "@cloudflare/workers-shared": "workspace:*", "@cloudflare/workers-tsconfig": "workspace:*", diff --git a/packages/vite-plugin-cloudflare/src/miniflare-options.ts b/packages/vite-plugin-cloudflare/src/miniflare-options.ts index 5b9eef7644..ee8551ac8c 100644 --- a/packages/vite-plugin-cloudflare/src/miniflare-options.ts +++ b/packages/vite-plugin-cloudflare/src/miniflare-options.ts @@ -3,10 +3,12 @@ import * as fs from "node:fs"; import * as fsp from "node:fs/promises"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; +import { format } from "node:util"; import { generateContainerBuildId, resolveDockerHost, } from "@cloudflare/containers-shared"; +import { maybeStartOrUpdateRemoteProxySession } from "@cloudflare/remote-bindings"; import { getBrowserRenderingHeadfulFromEnv, getLocalExplorerEnabledFromEnv, @@ -46,6 +48,10 @@ import type { } from "./context"; import type { PersistState } from "./plugin-config"; import type { ModuleType } from "@cloudflare/config"; +import type { + RemoteBindingsLogger, + RemoteProxySessionData, +} from "@cloudflare/remote-bindings"; import type { MiniflareOptions, ModuleRuleType, @@ -53,11 +59,7 @@ import type { WorkerOptions, } from "miniflare"; import type * as vite from "vite"; -import type { - Binding, - RemoteProxySession, - SourcelessWorkerOptions, -} from "wrangler"; +import type { 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 @@ -101,12 +103,34 @@ const WRAPPER_PATH = "__VITE_WORKER_ENTRY__"; /** Map that maps worker configPaths to their existing remote proxy session data (if any) */ const remoteProxySessionsDataMap = new Map< string, - { - session: RemoteProxySession; - remoteBindings: Record; - } | null + RemoteProxySessionData | null >(); +function createRemoteBindingsLogger(logger: vite.Logger): RemoteBindingsLogger { + const write = ( + level: "info" | "warn" | "error", + args: Parameters + ) => logger[level](format(...args)); + + return { + loggerLevel: "log", + debug() {}, + log: (...args) => write("info", args), + info: (...args) => write("info", args), + warn: (...args) => write("warn", args), + error: (...args) => write("error", args), + console(method, ...args) { + if (method === "error") { + write("error", args); + } else if (method === "warn") { + write("warn", args); + } else if (method !== "debug" && method !== "trace") { + write("info", args); + } + }, + }; +} + export async function getDevMiniflareOptions( ctx: AssetsOnlyPluginContext | WorkersPluginContext, viteDevServer: vite.ViteDevServer @@ -270,14 +294,21 @@ 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 ?? {}, + complianceRegion: worker.config.compliance_region, account_id: worker.config.account_id, profileDir: resolvedViteConfig.root, }, - preExistingRemoteProxySession ?? null + preExistingRemoteProxySession ?? null, + undefined, + { + logger: createRemoteBindingsLogger( + viteDevServer.config.logger + ), + } ); if (worker.config.configPath && remoteProxySessionData) { @@ -665,14 +696,21 @@ 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 ?? {}, + complianceRegion: workerConfig.compliance_region, account_id: workerConfig.account_id, profileDir: resolvedViteConfig.root, }, - preExistingRemoteProxySessionData ?? null + preExistingRemoteProxySessionData ?? null, + undefined, + { + logger: createRemoteBindingsLogger( + vitePreviewServer.config.logger + ), + } ); if (workerConfig.configPath && remoteProxySessionData) { diff --git a/packages/vitest-pool-workers/package.json b/packages/vitest-pool-workers/package.json index 20b5c2aa6f..ff87504c2c 100644 --- a/packages/vitest-pool-workers/package.json +++ b/packages/vitest-pool-workers/package.json @@ -61,6 +61,7 @@ }, "devDependencies": { "@cloudflare/mock-npm-registry": "workspace:*", + "@cloudflare/remote-bindings": "workspace:*", "@cloudflare/workers-tsconfig": "workspace:*", "@cloudflare/workers-types": "catalog:default", "@cloudflare/workers-utils": "workspace:*", diff --git a/packages/vitest-pool-workers/src/pool/config.ts b/packages/vitest-pool-workers/src/pool/config.ts index a6ee42ca97..9ce9f0ab7c 100644 --- a/packages/vitest-pool-workers/src/pool/config.ts +++ b/packages/vitest-pool-workers/src/pool/config.ts @@ -1,4 +1,6 @@ import path from "node:path"; +import { maybeStartOrUpdateRemoteProxySession } from "@cloudflare/remote-bindings"; +import { getCloudflareComplianceRegion } from "@cloudflare/workers-utils"; import { formatZodError, getRootPath, @@ -14,9 +16,12 @@ import { getRelativeProjectConfigPath, getRelativeProjectPath, } from "./helpers"; +import type { + RemoteBindingsLogger, + RemoteProxySessionData, +} from "@cloudflare/remote-bindings"; import type { ModuleRule, WorkerOptions } from "miniflare"; import type { TestProject } from "vitest/node"; -import type { Binding, RemoteProxySession } from "wrangler"; import type { ParseParams, ZodError } from "zod"; export interface WorkersConfigPluginAPI { @@ -151,6 +156,18 @@ function parseWorkerOptions( const log = new Log(LogLevel.WARN, { prefix: "vpw" }); +const remoteBindingsLogger: RemoteBindingsLogger = { + loggerLevel: "log", + debug: console.debug, + log: console.log, + info: console.info, + warn: console.warn, + error: console.error, + console(method, ...args) { + Reflect.apply(console[method], console, args); + }, +}; + function filterTails( tails: WorkerOptions["tails"], userWorkers?: { name?: string }[] @@ -186,10 +203,7 @@ function filterTails( /** Map that maps worker configPaths to their existing remote proxy session data (if any) */ export const remoteProxySessionsDataMap = new Map< string, - { - session: RemoteProxySession; - remoteBindings: Record; - } | null + RemoteProxySessionData | null >(); async function parseCustomPoolOptions( @@ -267,12 +281,20 @@ async function parseCustomPoolOptions( : undefined; const remoteProxySessionData = options.remoteBindings - ? await wrangler.maybeStartOrUpdateRemoteProxySession( + ? await maybeStartOrUpdateRemoteProxySession( { - path: options.wrangler.configPath, - environment: options.wrangler.environment, + name: wranglerConfig.name ?? "worker", + bindings: + wrangler.unstable_convertConfigBindingsToStartWorkerBindings( + wranglerConfig + ) ?? {}, + complianceRegion: getCloudflareComplianceRegion(wranglerConfig), + account_id: wranglerConfig.account_id, + profileDir: path.dirname(configPath), }, - preExistingRemoteProxySessionData ?? null + preExistingRemoteProxySessionData ?? null, + undefined, + { logger: remoteBindingsLogger } ) : null; diff --git a/packages/workers-auth/src/credential-store/key-providers/lazy-installer.ts b/packages/workers-auth/src/credential-store/key-providers/lazy-installer.ts index 61333ed16b..17169822cc 100644 --- a/packages/workers-auth/src/credential-store/key-providers/lazy-installer.ts +++ b/packages/workers-auth/src/credential-store/key-providers/lazy-installer.ts @@ -258,13 +258,6 @@ interface KeyringModule { Entry: new (service: string, account: string) => KeyringEntry; } -// `createRequire` is used to load the lazy-installed binding from an -// absolute path computed at runtime, defeating esbuild's static analysis -// of `require(...)`. The anchor `__filename` is provided in both the -// bundled CJS output and the source-loaded test environment. -// eslint-disable-next-line no-restricted-globals -- runtime resolution requires a CJS anchor -const dynamicRequire = createRequire(__filename); - /** * Resolve the active keyring entry factory, loading the lazy-installed * binding from `installDir` when no test override is registered. @@ -281,6 +274,6 @@ export function resolveKeyringEntryFactory( "`@napi-rs/keyring` binding not found. Call `installKeyringBindingSync()` first." ); } - const mod = dynamicRequire(bindingPath) as KeyringModule; + const mod = createRequire(bindingPath)(bindingPath) as KeyringModule; return (service, account) => new mod.Entry(service, account); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 594d9988e8..1dc52b3c85 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2592,6 +2592,9 @@ importers: chalk: specifier: catalog:default version: 5.3.0 + esbuild: + specifier: catalog:default + version: 0.28.1 miniflare: specifier: workspace:* version: link:../miniflare @@ -2709,6 +2712,9 @@ importers: '@cloudflare/mock-npm-registry': specifier: workspace:* version: link:../mock-npm-registry + '@cloudflare/remote-bindings': + specifier: workspace:* + version: link:../remote-bindings '@cloudflare/runtime-types': specifier: workspace:* version: link:../runtime-types @@ -3953,6 +3959,9 @@ importers: '@cloudflare/mock-npm-registry': specifier: workspace:* version: link:../mock-npm-registry + '@cloudflare/remote-bindings': + specifier: workspace:* + version: link:../remote-bindings '@cloudflare/workers-tsconfig': specifier: workspace:* version: link:../workers-tsconfig From 43765905dd71cbaefa33f3b5ff71918f67e09a77 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 16 Jul 2026 18:29:29 +0100 Subject: [PATCH 28/34] [remote-bindings] Fix direct consumer integration --- packages/remote-bindings/src/logger.ts | 1 + .../src/start-remote-proxy-session.ts | 27 ++++++++- .../remoteBindings/ProxyServerWorker.ts | 21 +++++-- .../templates/startDevWorker/ProxyWorker.ts | 12 +++- packages/remote-bindings/tsdown.config.ts | 9 ++- packages/remote-bindings/turbo.json | 3 + packages/workers-auth/src/access.ts | 3 +- packages/workers-utils/src/logger.ts | 2 +- .../dev/remote-bindings-errors.test.ts | 60 +++++++++---------- .../src/__tests__/dev/remote-bindings.test.ts | 7 ++- packages/wrangler/src/user/access.ts | 1 + 11 files changed, 99 insertions(+), 47 deletions(-) diff --git a/packages/remote-bindings/src/logger.ts b/packages/remote-bindings/src/logger.ts index a6c57dad9b..d24891633b 100644 --- a/packages/remote-bindings/src/logger.ts +++ b/packages/remote-bindings/src/logger.ts @@ -2,6 +2,7 @@ import type { Logger, LoggerLevel } from "@cloudflare/workers-utils"; export type RemoteBindingsLogger = Logger & { loggerLevel: LoggerLevel; + console: NonNullable; }; export let logger: RemoteBindingsLogger; diff --git a/packages/remote-bindings/src/start-remote-proxy-session.ts b/packages/remote-bindings/src/start-remote-proxy-session.ts index 9e00d410ea..cbf789e11c 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.ts @@ -7,6 +7,7 @@ import remoteBindingsWorkerSource from "worker:remoteBindings/ProxyServerWorker" import { getRemoteBindingsAuthHook } from "./auth"; import { initLogger } from "./logger"; import { DevEnv } from "./startDevWorker/DevEnv"; +import { RemoteSessionAuthenticationError } from "./utils/remote"; import type { RemoteBindingsLogger } from "./logger"; import type { AsyncHook, @@ -70,8 +71,8 @@ export async function startRemoteProxySession( bindings: StartDevWorkerInput["bindings"], options: StartRemoteProxySessionOptions ): Promise { - initLogger(options.logger); options.logger.log(chalk.dim("⎔ Establishing remote connection...")); + initLogger(getInternalLogger(options.logger)); const rawBindings = toRawBindings(bindings); const workerConfig = { name: options.workerName ?? randomUUID(), @@ -123,6 +124,12 @@ export async function startRemoteProxySession( ]); if (maybeError && maybeError.error) { + if ( + isErrorEvent(maybeError.error) && + maybeError.error.cause instanceof RemoteSessionAuthenticationError + ) { + throw maybeError.error.cause; + } const details = formatRemoteProxySessionError(maybeError.error); throw new Error( details @@ -186,3 +193,21 @@ function toRawBindings(bindings: StartDevWorkerInput["bindings"]) { ]) ); } + +function getInternalLogger(logger: RemoteBindingsLogger): RemoteBindingsLogger { + if (logger.loggerLevel === "debug") { + return logger; + } + + const disabled = () => {}; + const loggerLevel = logger.loggerLevel === "none" ? "none" : "error"; + return { + loggerLevel, + debug: disabled, + log: disabled, + info: disabled, + warn: disabled, + error: loggerLevel === "none" ? disabled : logger.error.bind(logger), + console: disabled, + }; +} diff --git a/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts b/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts index 4a43f4518c..0ca9c5b8a6 100644 --- a/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts +++ b/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts @@ -1,7 +1,15 @@ import { newWorkersRpcResponse } from "capnweb"; import { EmailMessage } from "cloudflare:email"; -interface Env extends Record {} +type Env = Record; + +type SendEmailInput = + | Parameters[0] + | { + from: string; + to: string; + "EmailMessage::raw": ReadableStream; + }; class BindingNotFoundError extends Error { constructor(name?: string) { @@ -42,7 +50,7 @@ function getExposedJSRPCBinding(request: Request, env: Env) { if (targetBinding.constructor.name === "SendEmail") { return { - async send(e: any) { + async send(e: SendEmailInput) { // Check if this is an EmailMessage (has EmailMessage::raw property) or MessageBuilder if ("EmailMessage::raw" in e) { // EmailMessage API - reconstruct the EmailMessage object @@ -60,10 +68,11 @@ function getExposedJSRPCBinding(request: Request, env: Env) { }; } - if (url.searchParams.has("MF-Dispatch-Namespace-Options")) { - const { name, args, options } = JSON.parse( - url.searchParams.get("MF-Dispatch-Namespace-Options")! - ); + const dispatchNamespaceOptions = url.searchParams.get( + "MF-Dispatch-Namespace-Options" + ); + if (dispatchNamespaceOptions) { + const { name, args, options } = JSON.parse(dispatchNamespaceOptions); return (targetBinding as DispatchNamespace).get(name, args, options); } diff --git a/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts b/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts index 70beec0cce..fb89a87794 100644 --- a/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts +++ b/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts @@ -87,7 +87,9 @@ export class ProxyWorker implements DurableObject { processQueue() { const { proxyData } = this; // store proxyData at the moment this function was called - if (proxyData === undefined) return; + if (proxyData === undefined) { + return; + } for (const [request, deferredResponse] of this.getOrderedQueue()) { this.requestRetryQueue.delete(request); @@ -109,7 +111,9 @@ export class ProxyWorker implements DurableObject { // Preserve client `Accept-Encoding`, rather than using Worker's default // of `Accept-Encoding: br, gzip` const encoding = request.cf?.clientAcceptEncoding; - if (encoding !== undefined) headers.set("Accept-Encoding", encoding); + if (encoding !== undefined) { + headers.set("Accept-Encoding", encoding); + } rewriteUrlRelatedHeaders(headers, outerUrl, innerUrl); @@ -120,7 +124,9 @@ export class ProxyWorker implements DurableObject { // merge proxyData headers with the request headers for (const [key, value] of Object.entries(proxyData.headers ?? {})) { - if (value === undefined) continue; + if (value === undefined) { + continue; + } if (key.toLowerCase() === "cookie") { const existing = request.headers.get("cookie") ?? ""; diff --git a/packages/remote-bindings/tsdown.config.ts b/packages/remote-bindings/tsdown.config.ts index 8aac64aff5..ba6944b7c8 100644 --- a/packages/remote-bindings/tsdown.config.ts +++ b/packages/remote-bindings/tsdown.config.ts @@ -9,6 +9,13 @@ export default defineConfig({ outDir: "dist", dts: true, tsconfig: "tsconfig.json", - external: [/^(?!(?:\0)?worker:)[^./]/], + external: (id) => { + const unprefixedId = id.charCodeAt(0) === 0 ? id.slice(1) : id; + return ( + !unprefixedId.startsWith("worker:") && + !unprefixedId.startsWith(".") && + !unprefixedId.startsWith("/") + ); + }, plugins: [embedWorkersPlugin()], }); diff --git a/packages/remote-bindings/turbo.json b/packages/remote-bindings/turbo.json index 6556dcf3e5..e1ecb71251 100644 --- a/packages/remote-bindings/turbo.json +++ b/packages/remote-bindings/turbo.json @@ -4,6 +4,9 @@ "tasks": { "build": { "outputs": ["dist/**"] + }, + "test:ci": { + "env": ["CLOUDFLARE_CF_AUTH"] } } } diff --git a/packages/workers-auth/src/access.ts b/packages/workers-auth/src/access.ts index c7e8dee7d5..5888e66480 100644 --- a/packages/workers-auth/src/access.ts +++ b/packages/workers-auth/src/access.ts @@ -89,6 +89,7 @@ export async function getAccessHeaders( domain: string, options: { logger: OAuthFlowLogger; + isNonInteractiveOrCI?: () => boolean; } ): Promise> { const logger = options.logger; @@ -137,7 +138,7 @@ export async function getAccessHeaders( } // 2. If non-interactive (CI), error with actionable message - if (isNonInteractiveOrCI()) { + if ((options.isNonInteractiveOrCI ?? isNonInteractiveOrCI)()) { throw new UserError( `The domain "${domain}" is behind Cloudflare Access, but no Access Service Token credentials were found ` + `and the current environment is non-interactive.\n` + diff --git a/packages/workers-utils/src/logger.ts b/packages/workers-utils/src/logger.ts index c134bef460..1e398cd083 100644 --- a/packages/workers-utils/src/logger.ts +++ b/packages/workers-utils/src/logger.ts @@ -23,7 +23,7 @@ export type Logger = { warn: typeof console.warn; error: typeof console.error; }; - console>( + console?>( method: M, ...args: Parameters ): void; 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..eaadda4e53 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,10 @@ import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; -import { assert, beforeEach, describe, it, vi } from "vitest"; +import { http, HttpResponse } from "msw"; +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(), -})); +import { createFetchResult, msw, mswSuccessUserHandlers } from "../helpers/msw"; mockConsoleMethods(); @@ -55,15 +48,26 @@ describe("errors during dev with remote bindings", () => { 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."); - }); + msw.use( + http.get( + "*/accounts/test-account-id/workers/subdomain/edge-preview", + () => + HttpResponse.json(createFetchResult({ token: "test-session-value" })) + ), + http.get("*/accounts/test-account-id/workers/subdomain", () => + HttpResponse.json(createFetchResult({ subdomain: "test" })) + ), + http.post( + "*/accounts/test-account-id/workers/scripts/:scriptName/edge-preview", + () => + HttpResponse.json( + createFetchResult({}, false, [ + { code: 1000, message: "The remote worker preview failed." }, + ]), + { status: 400 } + ) + ) + ); let thrownError: Error | undefined; @@ -84,18 +88,12 @@ 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.message).toContain( + "Failed to start the remote proxy session. Failed to obtain a preview token" ); - - 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.cause).toMatchObject({ + reason: "Failed to obtain a preview token", + type: "error", + }); }); }); diff --git a/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts b/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts index 2acc90c966..a92bda092a 100644 --- a/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts +++ b/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts @@ -21,8 +21,9 @@ import { mswZoneHandlers, } from "../helpers/msw"; import { runWrangler } from "../helpers/run-wrangler"; -import type { Binding, StartRemoteProxySessionOptions } from "../../api"; +import type { Binding } from "../../api"; import type { StartDevOptions } from "../../dev"; +import type { StartRemoteProxySessionOptions } from "@cloudflare/remote-bindings"; import type { RawConfig } from "@cloudflare/workers-utils"; import type { RemoteProxyConnectionString, WorkerOptions } from "miniflare"; @@ -789,7 +790,7 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => { workerName: "worker", }); assert(auth); - expect(await unwrapHook(auth, { account_id: undefined })).toEqual({ + expect(await unwrapHook(auth)).toEqual({ accountId: "some-account-id", apiToken: { apiToken: "some-api-token" }, }); @@ -833,7 +834,7 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => { workerName: "worker", }); assert(auth2); - expect(await unwrapHook(auth2, { account_id: undefined })).toEqual({ + expect(await unwrapHook(auth2)).toEqual({ accountId: "mock-account-id", apiToken: { apiToken: "some-api-token" }, }); diff --git a/packages/wrangler/src/user/access.ts b/packages/wrangler/src/user/access.ts index ade9a3582d..1959773c7a 100644 --- a/packages/wrangler/src/user/access.ts +++ b/packages/wrangler/src/user/access.ts @@ -22,5 +22,6 @@ export async function getAccessHeaders( ): Promise> { return packageGetAccessHeaders(domain, { logger, + isNonInteractiveOrCI, }); } From 710e34f44310ffd5b853b072aab4f86b5bf4e6cd Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 16 Jul 2026 20:58:37 +0100 Subject: [PATCH 29/34] [remote-bindings] Fix Windows worker embedding build --- packages/remote-bindings/tsdown.config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/remote-bindings/tsdown.config.ts b/packages/remote-bindings/tsdown.config.ts index ba6944b7c8..d11d1f8cd2 100644 --- a/packages/remote-bindings/tsdown.config.ts +++ b/packages/remote-bindings/tsdown.config.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { defineConfig } from "tsdown"; import { embedWorkersPlugin } from "./scripts/embed-workers.ts"; @@ -14,7 +15,7 @@ export default defineConfig({ return ( !unprefixedId.startsWith("worker:") && !unprefixedId.startsWith(".") && - !unprefixedId.startsWith("/") + !path.isAbsolute(unprefixedId) ); }, plugins: [embedWorkersPlugin()], From fb1afc26076d789d7b5c6c3d390bf42412189975 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 16 Jul 2026 20:58:37 +0100 Subject: [PATCH 30/34] [wrangler] Update invalid account error snapshot --- fixtures/get-platform-proxy-remote-bindings/tests/index.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fixtures/get-platform-proxy-remote-bindings/tests/index.test.ts b/fixtures/get-platform-proxy-remote-bindings/tests/index.test.ts index c0d8a508e9..b6914ab21c 100644 --- a/fixtures/get-platform-proxy-remote-bindings/tests/index.test.ts +++ b/fixtures/get-platform-proxy-remote-bindings/tests/index.test.ts @@ -259,6 +259,8 @@ if (auth) { ).toMatchInlineSnapshot(` "X [ERROR] A request to the Cloudflare API (/accounts/NOT a valid account id/workers/subdomain/edge-preview) failed. + Could not route to /client/v4/accounts/NOT%20a%20valid%20account%20id/workers/subdomain/edge-preview, perhaps your object identifier is invalid? [code: 7003] + " `); }); From 3ae2834db614fbff5ab411c288384a11ada15879 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Fri, 17 Jul 2026 11:49:17 +0100 Subject: [PATCH 31/34] [remote-bindings] Prevent stale preview bindings --- .../src/startDevWorker/DevEnv.test.ts | 48 +++++++++++++++++++ .../src/startDevWorker/DevEnv.ts | 7 ++- 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 packages/remote-bindings/src/startDevWorker/DevEnv.test.ts diff --git a/packages/remote-bindings/src/startDevWorker/DevEnv.test.ts b/packages/remote-bindings/src/startDevWorker/DevEnv.test.ts new file mode 100644 index 0000000000..183c75102d --- /dev/null +++ b/packages/remote-bindings/src/startDevWorker/DevEnv.test.ts @@ -0,0 +1,48 @@ +import { describe, it, vi } from "vitest"; +import { DevEnv } from "./DevEnv"; +import type { ProxyController } from "./ProxyController"; +import type { RemoteRuntimeController } from "./RemoteRuntimeController"; +import type { StartDevWorkerOptions } from "./types"; + +const config: StartDevWorkerOptions = { + name: "remote-bindings-proxy", + entrypointSource: "export default {};", + bindings: {}, + compatibilityDate: "2026-07-17", + compatibilityFlags: [], + complianceRegion: undefined, + auth: () => ({ + accountId: "account-id", + apiToken: { apiToken: "api-token" }, + }), + server: { port: 0, secure: false }, +}; + +describe("DevEnv", () => { + it("changes the uploaded source on every update", ({ expect }) => { + const devEnv = new DevEnv(config); + const onBundleComplete = + vi.fn(); + devEnv.proxy = { pause: vi.fn() } as unknown as ProxyController; + devEnv.runtime = { + onUpdateStart: vi.fn(), + onBundleComplete, + } as unknown as RemoteRuntimeController; + + devEnv.update(config); + devEnv.update(config); + + const [firstCall, secondCall] = onBundleComplete.mock.calls; + if (!firstCall || !secondCall) { + throw new Error("Expected two bundle updates"); + } + const firstBundle = firstCall[0].bundle; + const secondBundle = secondCall[0].bundle; + expect(firstBundle.entrypointSource).toBe( + "export default {};\n// remote-bindings-update:1" + ); + expect(secondBundle.entrypointSource).toBe( + "export default {};\n// remote-bindings-update:2" + ); + }); +}); diff --git a/packages/remote-bindings/src/startDevWorker/DevEnv.ts b/packages/remote-bindings/src/startDevWorker/DevEnv.ts index 71a180acaa..00c66ad563 100644 --- a/packages/remote-bindings/src/startDevWorker/DevEnv.ts +++ b/packages/remote-bindings/src/startDevWorker/DevEnv.ts @@ -11,6 +11,7 @@ export class DevEnv extends EventEmitter { runtime: RemoteRuntimeController; proxy: ProxyController; #bundle: Bundle; + #bundleVersion = 0; #config: StartDevWorkerOptions; start() { @@ -25,7 +26,11 @@ export class DevEnv extends EventEmitter { this.runtime.onBundleComplete({ type: "bundleComplete", config, - bundle: this.#bundle, + bundle: { + ...this.#bundle, + // Ensure binding-only updates cannot reuse the previous edge-preview artifact. + entrypointSource: `${this.#bundle.entrypointSource}\n// remote-bindings-update:${++this.#bundleVersion}`, + }, }); } From fe26a97e7da1012228d1c02aebbbee082be6248b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Somhairle=20MacLe=C3=B2id?= Date: Mon, 20 Jul 2026 13:40:09 +0100 Subject: [PATCH 32/34] Update packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts Co-authored-by: James Opstad <13586373+jamesopstad@users.noreply.github.com> --- .../src/startDevWorker/RemoteRuntimeController.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts b/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts index 461e8ae1d0..a582b8e9cd 100644 --- a/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts +++ b/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts @@ -142,7 +142,7 @@ export class RemoteRuntimeController { { headers: { "Sec-WebSocket-Protocol": TRACE_VERSION, // needs to be `trace-v1` to be accepted - "User-Agent": `wrangler/${packageVersion}`, + "User-Agent": `remote-bindings/${packageVersion}`, }, signal: this.#abortController.signal, } From 3f012b69c3746bc61e89a26945ad05e399918de9 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 21 Jul 2026 11:02:15 +0100 Subject: [PATCH 33/34] [remote-bindings] Address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the empty `WranglerLog` wrapper — `ProxyControllerLogger` now extends Miniflare's `Log` directly - De-wrangler-ify the extracted package — `ProxyController` log prefix and the tail WebSocket `User-Agent` now say `remote-bindings` rather than `wrangler` - Move runtime dependencies out of `devDependencies` into `dependencies`, matching the convention in other packages --- packages/remote-bindings/package.json | 16 +++-- .../src/startDevWorker/ProxyController.ts | 14 ++-- .../remote-bindings/src/utils/miniflare.ts | 4 +- pnpm-lock.yaml | 71 ++++++++++--------- 4 files changed, 51 insertions(+), 54 deletions(-) diff --git a/packages/remote-bindings/package.json b/packages/remote-bindings/package.json index bb80785ca6..886e313b3c 100644 --- a/packages/remote-bindings/package.json +++ b/packages/remote-bindings/package.json @@ -30,22 +30,24 @@ "test:ci": "vitest run --passWithNoTests", "test:watch": "vitest" }, - "devDependencies": { + "dependencies": { "@cloudflare/cli-shared-helpers": "workspace:*", "@cloudflare/deploy-helpers": "workspace:*", "@cloudflare/workers-auth": "workspace:*", + "@cloudflare/workers-utils": "workspace:*", + "chalk": "catalog:default", + "miniflare": "workspace:*", + "undici": "catalog:default", + "ws": "catalog:default" + }, + "devDependencies": { "@cloudflare/workers-tsconfig": "workspace:*", "@cloudflare/workers-types": "catalog:default", - "@cloudflare/workers-utils": "workspace:*", "@types/ws": "^8.5.13", "capnweb": "catalog:default", - "chalk": "catalog:default", "esbuild": "catalog:default", - "miniflare": "workspace:*", "tsdown": "0.16.3", "typescript": "catalog:default", - "undici": "catalog:default", - "vitest": "catalog:default", - "ws": "catalog:default" + "vitest": "catalog:default" } } diff --git a/packages/remote-bindings/src/startDevWorker/ProxyController.ts b/packages/remote-bindings/src/startDevWorker/ProxyController.ts index 8154e1fd3b..aca10d24d1 100644 --- a/packages/remote-bindings/src/startDevWorker/ProxyController.ts +++ b/packages/remote-bindings/src/startDevWorker/ProxyController.ts @@ -1,14 +1,10 @@ import assert from "node:assert"; import { randomUUID } from "node:crypto"; import { assertNever } from "@cloudflare/workers-utils"; -import { LogLevel, Miniflare, Mutex, Response } from "miniflare"; +import { Log, LogLevel, Miniflare, Mutex, Response } from "miniflare"; import proxyWorkerSource from "worker:startDevWorker/ProxyWorker"; import { logger } from "../logger"; -import { - castLogLevel, - handleStructuredLogs, - WranglerLog, -} from "../utils/miniflare"; +import { castLogLevel, handleStructuredLogs } from "../utils/miniflare"; import { castErrorCause } from "./events"; import { createDeferred } from "./utils"; import type { @@ -101,8 +97,8 @@ export class ProxyController { prefix: // if debugging, log requests with specic ProxyWorker prefix logger.loggerLevel === "debug" - ? "wrangler-ProxyWorker" - : "wrangler", + ? "remote-bindings-ProxyWorker" + : "remote-bindings", }, this.localServerReady.promise ), @@ -254,7 +250,7 @@ export class ProxyController { } } -class ProxyControllerLogger extends WranglerLog { +class ProxyControllerLogger extends Log { constructor( level: LogLevel, opts: LogOptions, diff --git a/packages/remote-bindings/src/utils/miniflare.ts b/packages/remote-bindings/src/utils/miniflare.ts index c155986a92..b054613916 100644 --- a/packages/remote-bindings/src/utils/miniflare.ts +++ b/packages/remote-bindings/src/utils/miniflare.ts @@ -1,10 +1,8 @@ -import { Log, LogLevel } from "miniflare"; +import { LogLevel } from "miniflare"; import { logger } from "../logger"; import type { LoggerLevel } from "@cloudflare/workers-utils"; import type { WorkerdStructuredLog } from "miniflare"; -export class WranglerLog extends Log {} - export function castLogLevel(level: LoggerLevel): LogLevel { let key = level.toUpperCase() as Uppercase; if (key === "LOG") { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1dc52b3c85..5506031b10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2564,7 +2564,7 @@ importers: version: 3.5.0(esbuild@0.28.1) packages/remote-bindings: - devDependencies: + dependencies: '@cloudflare/cli-shared-helpers': specifier: workspace:* version: link:../cli @@ -2574,45 +2574,46 @@ importers: '@cloudflare/workers-auth': specifier: workspace:* version: link:../workers-auth + '@cloudflare/workers-utils': + specifier: workspace:* + version: link:../workers-utils + chalk: + specifier: catalog:default + version: 5.3.0 + miniflare: + specifier: workspace:* + version: link:../miniflare + undici: + specifier: catalog:default + version: 7.28.0 + ws: + specifier: catalog:default + version: 8.21.0 + devDependencies: '@cloudflare/workers-tsconfig': specifier: workspace:* version: link:../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default version: 5.20260714.1 - '@cloudflare/workers-utils': - specifier: workspace:* - version: link:../workers-utils '@types/ws': specifier: ^8.5.13 version: 8.5.13 capnweb: specifier: catalog:default version: 0.5.0 - chalk: - specifier: catalog:default - version: 5.3.0 esbuild: specifier: catalog:default version: 0.28.1 - miniflare: - specifier: workspace:* - version: link:../miniflare 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 - undici: - specifier: catalog:default - version: 7.28.0 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)) - ws: - specifier: catalog:default - version: 8.21.0 packages/runtime-types: dependencies: @@ -5501,7 +5502,7 @@ packages: react: ^15.0.0-0 || ^16.0.0-0 || ^17.0.0-0 '@cloudflare/intl-types@1.5.7': - resolution: {integrity: sha512-5p+NqAoM3rOMsZsAS6RMWvClhuxWA3YqRkfIxkTcc6uYNsays90GuyzdXmN/v+T7UiSkmzRa7Atu75tD/245MQ==} + resolution: {integrity: sha512-5p+NqAoM3rOMsZsAS6RMWvClhuxWA3YqRkfIxkTcc6uYNsays90GuyzdXmN/v+T7UiSkmzRa7Atu75tD/245MQ==, tarball: https://registry.npmjs.org/@cloudflare/intl-types/-/intl-types-1.5.7.tgz} peerDependencies: react: ^15.0.0-0 || ^16.0.0-0 || ^17.0.0-0 @@ -5538,7 +5539,7 @@ packages: react: ^15.0.0-0 || ^16.0.0-0 || ^17.0.0-0 '@cloudflare/style-const@6.1.3': - resolution: {integrity: sha512-kwKNttljHfLMY9iVY4r4P00L9TrIc3xWvFoFte/ImLIOq13MH1sDXkQbA8nLC1qcFq7Liv/gbGrtgouPW2lO5Q==} + resolution: {integrity: sha512-kwKNttljHfLMY9iVY4r4P00L9TrIc3xWvFoFte/ImLIOq13MH1sDXkQbA8nLC1qcFq7Liv/gbGrtgouPW2lO5Q==, tarball: https://registry.npmjs.org/@cloudflare/style-const/-/style-const-6.1.3.tgz} peerDependencies: react: ^15.0.0-0 || ^16.0.0-0 || ^17.0.0-0 @@ -5566,7 +5567,7 @@ packages: react: ^15.0.0-0 || ^16.0.0-0 || ^17.0.0-0 '@cloudflare/types@6.29.1': - resolution: {integrity: sha512-3AfpWx3G47NWgrkTMMIxcDxl/JpS8K4a5w28+4afK8Eyzd2Mnh7+JRB3C59A6mUjo6e+KTJ/cEvyVIHYO6FQDA==} + resolution: {integrity: sha512-3AfpWx3G47NWgrkTMMIxcDxl/JpS8K4a5w28+4afK8Eyzd2Mnh7+JRB3C59A6mUjo6e+KTJ/cEvyVIHYO6FQDA==, tarball: https://registry.npmjs.org/@cloudflare/types/-/types-6.29.1.tgz} peerDependencies: react: ^15.0.0-0 || ^16.0.0-0 || ^17.0.0-0 @@ -5583,7 +5584,7 @@ packages: resolution: {integrity: sha512-qdCFf90hoZzT4o4xEmxOKUf9+bEJNGh4ANnRYApo6BMyVnHoHEHAQ3nWmGSHBmo+W9hOk2Ik7r1oHLbI0O/RRg==} '@cloudflare/util-en-garde@8.0.15': - resolution: {integrity: sha512-wFs0Ug1TGhGcYZZ2JfPM4g0fNTIkBKSp8XRdZHZ3iq+2B/RdP7fYbzZxWpdUKzphVt+S2gVpMbo3nwn+dRhw+g==} + resolution: {integrity: sha512-wFs0Ug1TGhGcYZZ2JfPM4g0fNTIkBKSp8XRdZHZ3iq+2B/RdP7fYbzZxWpdUKzphVt+S2gVpMbo3nwn+dRhw+g==, tarball: https://registry.npmjs.org/@cloudflare/util-en-garde/-/util-en-garde-8.0.15.tgz} '@cloudflare/util-hooks@1.3.1': resolution: {integrity: sha512-gIsPlzgUbMswIE1h8vGK6LZr/Io5yocUl01WCLy5fxEajhCQ0mNLixkD2Uqne+WPTfqzu4jgC5NxYXgl+Hf6yQ==} @@ -5601,91 +5602,91 @@ packages: vitest: ^4.1.0 '@cloudflare/workerd-darwin-64@1.20260317.1': - resolution: {integrity: sha512-8hjh3sPMwY8M/zedq3/sXoA2Q4BedlGufn3KOOleIG+5a4ReQKLlUah140D7J6zlKmYZAFMJ4tWC7hCuI/s79g==} + resolution: {integrity: sha512-8hjh3sPMwY8M/zedq3/sXoA2Q4BedlGufn3KOOleIG+5a4ReQKLlUah140D7J6zlKmYZAFMJ4tWC7hCuI/s79g==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260317.1.tgz} engines: {node: '>=16'} cpu: [x64] os: [darwin] '@cloudflare/workerd-darwin-64@1.20260423.1': - resolution: {integrity: sha512-+1vfsTa/fyE/9GCrNHBkWdIYj4gXSjWSH7ATdy7/FHs07iB8Mapuk8/sW8Fxs2oXpaNeJ3CN83Ux5i4nQDlwNw==} + resolution: {integrity: sha512-+1vfsTa/fyE/9GCrNHBkWdIYj4gXSjWSH7ATdy7/FHs07iB8Mapuk8/sW8Fxs2oXpaNeJ3CN83Ux5i4nQDlwNw==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260423.1.tgz} engines: {node: '>=16'} cpu: [x64] os: [darwin] '@cloudflare/workerd-darwin-64@1.20260714.1': - resolution: {integrity: sha512-ZWXqAN8G7Cx9hMRQuk+59ziJhR3j1F4iO+Qs8aHdfKZ3Dq5Yi/57xvkJTgCGBnW1YU/L78r8f6HEy51bwbTpNw==} + resolution: {integrity: sha512-ZWXqAN8G7Cx9hMRQuk+59ziJhR3j1F4iO+Qs8aHdfKZ3Dq5Yi/57xvkJTgCGBnW1YU/L78r8f6HEy51bwbTpNw==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260714.1.tgz} engines: {node: '>=16'} cpu: [x64] os: [darwin] '@cloudflare/workerd-darwin-arm64@1.20260317.1': - resolution: {integrity: sha512-M/MnNyvO5HMgoIdr3QHjdCj2T1ki9gt0vIUnxYxBu9ISXS/jgtMl6chUVPJ7zHYBn9MyYr8ByeN6frjYxj0MGg==} + resolution: {integrity: sha512-M/MnNyvO5HMgoIdr3QHjdCj2T1ki9gt0vIUnxYxBu9ISXS/jgtMl6chUVPJ7zHYBn9MyYr8ByeN6frjYxj0MGg==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260317.1.tgz} engines: {node: '>=16'} cpu: [arm64] os: [darwin] '@cloudflare/workerd-darwin-arm64@1.20260423.1': - resolution: {integrity: sha512-plI6RlUg+hPZ83DUbeWCNG+JNeVHLysinowtYT8O02LKm9Dy2GMYgnosOe3xDFhKE7ou+9SI8KJiNfSnwgHSIg==} + resolution: {integrity: sha512-plI6RlUg+hPZ83DUbeWCNG+JNeVHLysinowtYT8O02LKm9Dy2GMYgnosOe3xDFhKE7ou+9SI8KJiNfSnwgHSIg==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260423.1.tgz} engines: {node: '>=16'} cpu: [arm64] os: [darwin] '@cloudflare/workerd-darwin-arm64@1.20260714.1': - resolution: {integrity: sha512-tueWxWC3wyCbMG6zRAxsMXX0YLgrRWbiAPYFQ2uJ7dUH8G+5E7UTWaQS9B1HdJ0bpKFW1NWxhs1o2noKVFSUYg==} + resolution: {integrity: sha512-tueWxWC3wyCbMG6zRAxsMXX0YLgrRWbiAPYFQ2uJ7dUH8G+5E7UTWaQS9B1HdJ0bpKFW1NWxhs1o2noKVFSUYg==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260714.1.tgz} engines: {node: '>=16'} cpu: [arm64] os: [darwin] '@cloudflare/workerd-linux-64@1.20260317.1': - resolution: {integrity: sha512-1ltuEjkRcS3fsVF7CxsKlWiRmzq2ZqMfqDN0qUOgbUwkpXsLVJsXmoblaLf5OP00ELlcgF0QsN0p2xPEua4Uug==} + resolution: {integrity: sha512-1ltuEjkRcS3fsVF7CxsKlWiRmzq2ZqMfqDN0qUOgbUwkpXsLVJsXmoblaLf5OP00ELlcgF0QsN0p2xPEua4Uug==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260317.1.tgz} engines: {node: '>=16'} cpu: [x64] os: [linux] '@cloudflare/workerd-linux-64@1.20260423.1': - resolution: {integrity: sha512-Ud5xtpXhCB3/XtgJK4ac6VYlQAb/LPOflvhi0/ncndx8dSyJ8iXHGXd7VOOikGXGWdRsttGpstlXYUEN6hU8TQ==} + resolution: {integrity: sha512-Ud5xtpXhCB3/XtgJK4ac6VYlQAb/LPOflvhi0/ncndx8dSyJ8iXHGXd7VOOikGXGWdRsttGpstlXYUEN6hU8TQ==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260423.1.tgz} engines: {node: '>=16'} cpu: [x64] os: [linux] '@cloudflare/workerd-linux-64@1.20260714.1': - resolution: {integrity: sha512-1VChTZRb0l0F7R4e1G5RtLKV4oFi6x+rQgxh2+yu887j3l/3TLgatuv1L8/5zhc9gKEhATTxOh0e52Rtd9dDWQ==} + resolution: {integrity: sha512-1VChTZRb0l0F7R4e1G5RtLKV4oFi6x+rQgxh2+yu887j3l/3TLgatuv1L8/5zhc9gKEhATTxOh0e52Rtd9dDWQ==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260714.1.tgz} engines: {node: '>=16'} cpu: [x64] os: [linux] '@cloudflare/workerd-linux-arm64@1.20260317.1': - resolution: {integrity: sha512-3QrNnPF1xlaNwkHpasvRvAMidOvQs2NhXQmALJrEfpIJ/IDL2la8g499yXp3eqhG3hVMCB07XVY149GTs42Xtw==} + resolution: {integrity: sha512-3QrNnPF1xlaNwkHpasvRvAMidOvQs2NhXQmALJrEfpIJ/IDL2la8g499yXp3eqhG3hVMCB07XVY149GTs42Xtw==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260317.1.tgz} engines: {node: '>=16'} cpu: [arm64] os: [linux] '@cloudflare/workerd-linux-arm64@1.20260423.1': - resolution: {integrity: sha512-xI2SqbkRDOwPQUUGd8N6qb2wuxFlu6GWi7qz6OFolZIDGu6m5Q/oMzMtV0txkXVClw1puLWYlc/wcURxAi+qsg==} + resolution: {integrity: sha512-xI2SqbkRDOwPQUUGd8N6qb2wuxFlu6GWi7qz6OFolZIDGu6m5Q/oMzMtV0txkXVClw1puLWYlc/wcURxAi+qsg==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260423.1.tgz} engines: {node: '>=16'} cpu: [arm64] os: [linux] '@cloudflare/workerd-linux-arm64@1.20260714.1': - resolution: {integrity: sha512-rMm3G+NirG2UdgHIRDdF1asNC6FqgIzZzkRG+VDhhDGcVxAQwvrMT1E38BivEvHr3G04MB4AfhcOczX0+GtRkQ==} + resolution: {integrity: sha512-rMm3G+NirG2UdgHIRDdF1asNC6FqgIzZzkRG+VDhhDGcVxAQwvrMT1E38BivEvHr3G04MB4AfhcOczX0+GtRkQ==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260714.1.tgz} engines: {node: '>=16'} cpu: [arm64] os: [linux] '@cloudflare/workerd-windows-64@1.20260317.1': - resolution: {integrity: sha512-MfZTz+7LfuIpMGTa3RLXHX8Z/pnycZLItn94WRdHr8LPVet+C5/1Nzei399w/jr3+kzT4pDKk26JF/tlI5elpQ==} + resolution: {integrity: sha512-MfZTz+7LfuIpMGTa3RLXHX8Z/pnycZLItn94WRdHr8LPVet+C5/1Nzei399w/jr3+kzT4pDKk26JF/tlI5elpQ==, tarball: https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260317.1.tgz} engines: {node: '>=16'} cpu: [x64] os: [win32] '@cloudflare/workerd-windows-64@1.20260423.1': - resolution: {integrity: sha512-3ZhiwG/MSCF9YFxSOkfbXWM2yEIoMKWGnnZMZklY6jnNRTQIGqjvVBdzPYZyLiUqTRV5L+1W7Mvb7tg/merhiQ==} + resolution: {integrity: sha512-3ZhiwG/MSCF9YFxSOkfbXWM2yEIoMKWGnnZMZklY6jnNRTQIGqjvVBdzPYZyLiUqTRV5L+1W7Mvb7tg/merhiQ==, tarball: https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260423.1.tgz} engines: {node: '>=16'} cpu: [x64] os: [win32] '@cloudflare/workerd-windows-64@1.20260714.1': - resolution: {integrity: sha512-cGqnU3Hg2YZS/k3SAqrMp1DjpdsyFde72tWltdl6ZT9+SFz/Zrk/8gyTU1TcxC4YApXeNVH5TyU5cOGPgUJ0pg==} + resolution: {integrity: sha512-cGqnU3Hg2YZS/k3SAqrMp1DjpdsyFde72tWltdl6ZT9+SFz/Zrk/8gyTU1TcxC4YApXeNVH5TyU5cOGPgUJ0pg==, tarball: https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260714.1.tgz} engines: {node: '>=16'} cpu: [x64] os: [win32] From 2b3ea8b9c527369af59c3e78b56445c345295e42 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 21 Jul 2026 14:13:01 +0100 Subject: [PATCH 34/34] [remote-bindings] Preserve UserError type from async proxy session startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `startRemoteProxySession` only unwrapped `RemoteSessionAuthenticationError` at the error event's direct `cause`, so a `UserError` surfaced asynchronously (e.g. the Miniflare user-error case) fell through to being wrapped in a generic `Error`. That made Wrangler treat it as an unexpected internal error (bug prompt + Sentry report) rather than a clean user-facing error. Surface a directly-emitted `UserError` verbatim, and walk the error event's cause chain for a `RemoteSessionAuthenticationError` (mirroring the original `findRemoteSessionAuthError`). Generic API errors — which also extend `UserError` via `APIError` — still fall through to the "Failed to start the remote proxy session" wrapper. Adds regression tests covering each case. --- .../src/start-remote-proxy-session.test.ts | 139 ++++++++++++++++++ .../src/start-remote-proxy-session.ts | 39 ++++- 2 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 packages/remote-bindings/src/start-remote-proxy-session.test.ts diff --git a/packages/remote-bindings/src/start-remote-proxy-session.test.ts b/packages/remote-bindings/src/start-remote-proxy-session.test.ts new file mode 100644 index 0000000000..3aa5beef1f --- /dev/null +++ b/packages/remote-bindings/src/start-remote-proxy-session.test.ts @@ -0,0 +1,139 @@ +import { APIError, UserError } from "@cloudflare/workers-utils"; +import { describe, it, vi } from "vitest"; +import { startRemoteProxySession } from "./start-remote-proxy-session"; +import { RemoteSessionAuthenticationError } from "./utils/remote"; +import type { RemoteBindingsLogger } from "./logger"; +import type { ErrorEvent } from "./startDevWorker/events"; + +// The error that the mocked `DevEnv` emits asynchronously from `start()`. +const mockDevEnv = vi.hoisted(() => ({ error: undefined as unknown })); + +// Replace `DevEnv` with a stub that never becomes ready and instead emits a +// configurable error event just after `start()`, so we can exercise the async +// error-handling path in `startRemoteProxySession` without any real Miniflare. +vi.mock("./startDevWorker/DevEnv", async () => { + const { EventEmitter } = await import("node:events"); + class DevEnv extends EventEmitter { + proxy = { + // Never resolves — the startup race is always settled by the error event. + localServerReady: { promise: new Promise(() => {}) }, + ready: { promise: new Promise(() => {}) }, + runtimeMessageMutex: { drained: async () => {} }, + }; + constructor() { + super(); + // Avoid Node's throw-on-unhandled-"error" behaviour. + this.on("error", () => {}); + } + start() { + queueMicrotask(() => this.emit("error", mockDevEnv.error)); + } + update() {} + async teardown() {} + } + return { DevEnv }; +}); + +function createTestLogger(): RemoteBindingsLogger { + return { + loggerLevel: "none", + debug: vi.fn(), + log: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + console: vi.fn(), + }; +} + +function makeErrorEvent(cause: ErrorEvent["cause"]): ErrorEvent { + return { + type: "error", + reason: "Failed to start ProxyWorker", + cause, + source: "RemoteRuntimeController", + data: undefined, + }; +} + +describe("startRemoteProxySession error handling", () => { + // Regression: a `UserError` emitted asynchronously (e.g. the Miniflare + // user-error case in `DevEnv.handleErrorEvent`) used to fall through to being + // wrapped in a generic `Error`, which makes Wrangler treat it as an + // unexpected internal error (bug prompt + Sentry report) rather than a clean + // user-facing error. + it("re-throws a UserError emitted asynchronously without wrapping it", async ({ + expect, + }) => { + const userError = new UserError("nodejs_compat is required", { + telemetryMessage: "test user error", + }); + mockDevEnv.error = userError; + + await expect( + startRemoteProxySession({}, { logger: createTestLogger() }) + ).rejects.toBe(userError); + }); + + it("unwraps a UserError nested in an error event's cause", async ({ + expect, + }) => { + const authError = new RemoteSessionAuthenticationError(new Error("401")); + mockDevEnv.error = makeErrorEvent(authError); + + const thrown = await startRemoteProxySession( + {}, + { logger: createTestLogger() } + ).catch((error: unknown) => error); + + expect(thrown).toBe(authError); + expect(thrown).toBeInstanceOf(UserError); + }); + + it("wraps a non-user error in a generic Error, preserving the message", async ({ + expect, + }) => { + mockDevEnv.error = makeErrorEvent(new Error("boom")); + + const thrown = await startRemoteProxySession( + {}, + { logger: createTestLogger() } + ).catch((error: unknown) => error); + + expect(thrown).toBeInstanceOf(Error); + expect(thrown).not.toBeInstanceOf(UserError); + expect((thrown as Error).message).toContain( + "Failed to start the remote proxy session" + ); + expect((thrown as Error).message).toContain("boom"); + }); + + // `APIError` extends `UserError` (via `ParseError`), but a generic API + // failure during preview-token creation must still be wrapped in the + // "Failed to start the remote proxy session" envelope — not re-thrown raw — + // so only `RemoteSessionAuthenticationError` is unwrapped from the cause + // chain. Regression guard for the too-broad `UserError` walk. + it("wraps a generic APIError from the cause chain rather than re-throwing it", async ({ + expect, + }) => { + mockDevEnv.error = makeErrorEvent( + new APIError({ + text: "The remote worker preview failed.", + telemetryMessage: "test api error", + }) + ); + + const thrown = await startRemoteProxySession( + {}, + { logger: createTestLogger() } + ).catch((error: unknown) => error); + + expect(thrown).not.toBeInstanceOf(APIError); + expect((thrown as Error).message).toContain( + "Failed to start the remote proxy session" + ); + expect((thrown as Error).message).toContain( + "The remote worker preview failed." + ); + }); +}); diff --git a/packages/remote-bindings/src/start-remote-proxy-session.ts b/packages/remote-bindings/src/start-remote-proxy-session.ts index cbf789e11c..28f6efb1be 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.ts @@ -67,6 +67,28 @@ function formatRemoteProxySessionError(error: unknown): string | undefined { return getErrorMessage(error); } +/** + * Walk an error's cause chain (including through error events) for a + * {@link RemoteSessionAuthenticationError}, mirroring the original Wrangler + * `findRemoteSessionAuthError` walk. Only this specific user error is surfaced + * verbatim from the chain — generic API errors (which also extend + * {@link UserError} via {@link APIError}) fall through to the wrapping logic. + */ +function findRemoteSessionAuthError( + error: unknown +): RemoteSessionAuthenticationError | undefined { + if (error instanceof RemoteSessionAuthenticationError) { + return error; + } + if (isErrorEvent(error)) { + return findRemoteSessionAuthError(error.cause); + } + if (error instanceof Error) { + return findRemoteSessionAuthError(error.cause); + } + return undefined; +} + export async function startRemoteProxySession( bindings: StartDevWorkerInput["bindings"], options: StartRemoteProxySessionOptions @@ -124,11 +146,18 @@ export async function startRemoteProxySession( ]); if (maybeError && maybeError.error) { - if ( - isErrorEvent(maybeError.error) && - maybeError.error.cause instanceof RemoteSessionAuthenticationError - ) { - throw maybeError.error.cause; + // A UserError emitted directly (e.g. the Miniflare user-error case that + // DevEnv re-emits), and a RemoteSessionAuthenticationError found in the + // error event's cause chain, are surfaced verbatim so callers can still + // branch on `instanceof UserError` and the user sees a single actionable + // message. Everything else — including generic API errors — falls through + // to the wrapping below. + if (maybeError.error instanceof UserError) { + throw maybeError.error; + } + const authError = findRemoteSessionAuthError(maybeError.error); + if (authError) { + throw authError; } const details = formatRemoteProxySessionError(maybeError.error); throw new Error(