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/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] + " `); }); 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/package.json b/packages/remote-bindings/package.json new file mode 100644 index 0000000000..886e313b3c --- /dev/null +++ b/packages/remote-bindings/package.json @@ -0,0 +1,53 @@ +{ + "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", + "test:ci": "vitest run --passWithNoTests", + "test:watch": "vitest" + }, + "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", + "@types/ws": "^8.5.13", + "capnweb": "catalog:default", + "esbuild": "catalog:default", + "tsdown": "0.16.3", + "typescript": "catalog:default", + "vitest": "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 new file mode 100644 index 0000000000..f4c5f6a075 --- /dev/null +++ b/packages/remote-bindings/src/auth.test.ts @@ -0,0 +1,143 @@ +import assert from "node:assert"; +import { afterEach, beforeEach, describe, it, vi } from "vitest"; +import { createRemoteBindingsAuth, getRemoteBindingsAuthHook } from "./auth"; +import type { RemoteBindingsLogger } from "./logger"; +import type { CfAccount } from "@cloudflare/workers-utils"; + +const mocks = vi.hoisted(() => ({ + 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; + +function createTestLogger(): RemoteBindingsLogger { + return { + loggerLevel: "log", + debug: vi.fn(), + log: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + console: vi.fn(), + }; +} + +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; + } +}); + +describe("createRemoteBindingsAuth", () => { + 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(); + }); +}); + +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 new file mode 100644 index 0000000000..8e0cb85538 --- /dev/null +++ b/packages/remote-bindings/src/auth.ts @@ -0,0 +1,92 @@ +import { inputPrompt } from "@cloudflare/cli-shared-helpers/interactive"; +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() { + 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, + }; +} + +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/index.ts b/packages/remote-bindings/src/index.ts new file mode 100644 index 0000000000..df418124af --- /dev/null +++ b/packages/remote-bindings/src/index.ts @@ -0,0 +1,15 @@ +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..d24891633b --- /dev/null +++ b/packages/remote-bindings/src/logger.ts @@ -0,0 +1,12 @@ +import type { Logger, LoggerLevel } from "@cloudflare/workers-utils"; + +export type RemoteBindingsLogger = Logger & { + loggerLevel: LoggerLevel; + console: NonNullable; +}; + +export let logger: RemoteBindingsLogger; + +export function initLogger(value: RemoteBindingsLogger): void { + logger = value; +} 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..8a51a6b06d --- /dev/null +++ b/packages/remote-bindings/src/maybe-start-or-update-session.test.ts @@ -0,0 +1,73 @@ +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(), + }; +} + +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 new file mode 100644 index 0000000000..a06232f492 --- /dev/null +++ b/packages/remote-bindings/src/maybe-start-or-update-session.ts @@ -0,0 +1,136 @@ +import assert from "node:assert"; +import { getBindingLocalSupport } from "@cloudflare/workers-utils"; +import { getRemoteBindingsAuthHook } 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 | 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 + ); + let remoteProxySession = preExistingRemoteProxySessionData?.session; + + if (!authSameAsBefore) { + if (preExistingRemoteProxySessionData?.session) { + await preExistingRemoteProxySessionData.session.dispose(); + } + + remoteProxySession = await startSession(remoteBindings, { + workerName: workerConfigObject.name, + complianceRegion: workerConfigObject.complianceRegion, + auth: getRemoteBindingsAuthHook( + auth, + workerConfigObject.account_id, + 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: getRemoteBindingsAuthHook( + auth, + workerConfigObject.account_id, + workerConfigObject.profileDir, + context.logger + ), + logger: context.logger, + }); + } + } else { + await remoteProxySession.updateBindings(remoteBindings); + } + } + } + + await remoteProxySession?.ready; + if (!remoteProxySession) { + return null; + } + return { + session: remoteProxySession, + remoteBindings, + auth, + }; +} + +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.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 new file mode 100644 index 0000000000..28f6efb1be --- /dev/null +++ b/packages/remote-bindings/src/start-remote-proxy-session.ts @@ -0,0 +1,242 @@ +import { randomUUID } from "node:crypto"; +import events from "node:events"; +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"; +import { RemoteSessionAuthenticationError } from "./utils/remote"; +import type { RemoteBindingsLogger } from "./logger"; +import type { + AsyncHook, + CfAccount, + Config, + StartDevWorkerInput, +} from "@cloudflare/workers-utils"; +import type { RemoteProxyConnectionString } from "miniflare"; + +type ErrorEvent = { + type: "error"; + reason: string; + cause: unknown; +}; + +export type StartRemoteProxySessionOptions = { + workerName?: string; + auth?: AsyncHook; + /** 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); +} + +/** + * 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 +): Promise { + options.logger.log(chalk.dim("⎔ Establishing remote connection...")); + initLogger(getInternalLogger(options.logger)); + const rawBindings = toRawBindings(bindings); + const workerConfig = { + name: options.workerName ?? randomUUID(), + entrypointSource: remoteBindingsWorkerSource, + compatibilityDate: "2025-04-28", + compatibilityFlags: [], + complianceRegion: options.complianceRegion, + bindings: rawBindings, + auth: getRemoteBindingsAuthHook( + options.auth, + undefined, + undefined, + options.logger + ), + server: { port: 0, secure: false }, + }; + + 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 }); + }; + devEnv.addListener("error", onStartupError); + let remoteProxyConnectionString: RemoteProxyConnectionString; + try { + const maybeError = await Promise.race([ + maybeErrorPromise, + devEnv.proxy.localServerReady.promise, + ]); + + if (maybeError && maybeError.error) { + // 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( + 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 } + ); + } + + remoteProxyConnectionString = (await devEnv.proxy.ready.promise) + .url as RemoteProxyConnectionString; + } catch (error) { + await devEnv.teardown(); + throw error; + } finally { + devEnv.removeListener("error", onStartupError); + } + const updateBindings = async ( + newBindings: StartDevWorkerInput["bindings"] + ) => { + const reloadComplete = events.once(devEnv, "reloadComplete"); + devEnv.update({ + ...workerConfig, + 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 devEnv.proxy.runtimeMessageMutex.drained(); + }; + + return { + ready: Promise.resolve(), + remoteProxyConnectionString, + updateBindings, + dispose: () => devEnv.teardown(), + }; +} + +export type RemoteProxySession = { + ready: Promise; + dispose(): Promise; + 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 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/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 new file mode 100644 index 0000000000..00c66ad563 --- /dev/null +++ b/packages/remote-bindings/src/startDevWorker/DevEnv.ts @@ -0,0 +1,99 @@ +import { EventEmitter } from "node:events"; +import { UserError } from "@cloudflare/workers-utils"; +import { MiniflareCoreError } from "miniflare"; +import { logger } from "../logger"; +import { ProxyController } from "./ProxyController"; +import { RemoteRuntimeController } from "./RemoteRuntimeController"; +import type { ErrorEvent, ReloadCompleteEvent } from "./events"; +import type { Bundle, StartDevWorkerOptions } from "./types"; + +export class DevEnv extends EventEmitter { + runtime: RemoteRuntimeController; + proxy: ProxyController; + #bundle: Bundle; + #bundleVersion = 0; + #config: StartDevWorkerOptions; + + start() { + this.proxy.start(this.#config); + this.update(this.#config); + } + + update(config: StartDevWorkerOptions) { + this.#config = config; + this.proxy.pause(config); + this.runtime.onUpdateStart(); + this.runtime.onBundleComplete({ + type: "bundleComplete", + config, + bundle: { + ...this.#bundle, + // Ensure binding-only updates cannot reuse the previous edge-preview artifact. + entrypointSource: `${this.#bundle.entrypointSource}\n// remote-bindings-update:${++this.#bundleVersion}`, + }, + }); + } + + constructor(config: StartDevWorkerOptions) { + super(); + + this.#config = config; + this.#bundle = { + path: "proxy-worker.js", + entrypointSource: config.entrypointSource, + 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); + logger.debug("=> Error contextual data:", event.data); + }); + } + + private handleReloadComplete(event: ReloadCompleteEvent) { + this.proxy.play(event); + this.emit("reloadComplete", event); + } + + private handleErrorEvent(event: ErrorEvent): void { + if ( + event.cause instanceof MiniflareCoreError && + event.cause.isUserError() + ) { + this.emit( + "error", + new UserError(event.cause.message, { + telemetryMessage: "api dev miniflare user error", + }) + ); + } else if ( + event.source === "ProxyController" && + 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); + } + // if other knowable + recoverable errors occur, handle them here + else { + // otherwise, re-emit the unknowable errors to the top-level + this.emit("error", event); + } + } + + async teardown() { + logger.debug("DevEnv teardown beginning..."); + + await Promise.all([this.runtime.teardown(), this.proxy.teardown()]); + + logger.debug("DevEnv teardown complete"); + } +} diff --git a/packages/remote-bindings/src/startDevWorker/ProxyController.ts b/packages/remote-bindings/src/startDevWorker/ProxyController.ts new file mode 100644 index 0000000000..aca10d24d1 --- /dev/null +++ b/packages/remote-bindings/src/startDevWorker/ProxyController.ts @@ -0,0 +1,275 @@ +import assert from "node:assert"; +import { randomUUID } from "node:crypto"; +import { assertNever } from "@cloudflare/workers-utils"; +import { Log, LogLevel, Miniflare, Mutex, Response } from "miniflare"; +import proxyWorkerSource from "worker:startDevWorker/ProxyWorker"; +import { logger } from "../logger"; +import { castLogLevel, handleStructuredLogs } from "../utils/miniflare"; +import { castErrorCause } from "./events"; +import { createDeferred } from "./utils"; +import type { + ErrorEvent, + ProxyWorkerIncomingRequestBody, + ProxyWorkerOutgoingRequestBody, + ReadyEvent, + ReloadCompleteEvent, + SerializedError, +} from "./events"; +import type { Bundle, StartDevWorkerOptions } from "./types"; +import type { LogOptions, MiniflareOptions } from "miniflare"; + +export class ProxyController { + public ready = createDeferred(); + + public localServerReady = createDeferred(); + + public proxyWorker?: Miniflare; + + protected latestConfig?: StartDevWorkerOptions; + protected latestBundle?: Bundle; + + secret = randomUUID(); + + constructor( + private onError: (event: ErrorEvent) => void, + private onPreviewTokenExpired: () => void + ) {} + + protected createProxyWorker() { + if (this._torndown || this.proxyWorker) { + return; + } + assert(this.latestConfig !== undefined); + + const proxyWorkerOptions: MiniflareOptions = { + host: this.latestConfig.server.hostname, + port: this.latestConfig.server.port, + https: this.latestConfig.server.secure, + stripDisablePrettyError: false, + unsafeLocalExplorer: false, + workers: [ + { + name: "ProxyWorker", + compatibilityDate: "2023-12-18", + compatibilityFlags: ["nodejs_compat"], + modules: [ + { + type: "ESModule", + path: "dev-proxy-worker.mjs", + contents: proxyWorkerSource, + }, + ], + durableObjects: { + DURABLE_OBJECT: { + className: "ProxyWorker", + unsafePreventEviction: true, + }, + }, + // Miniflare will strip CF-Connecting-IP from outgoing fetches from a Worker (to fix https://github.com/cloudflare/workers-sdk/issues/7924) + // However, the proxy worker only makes outgoing requests to the user Worker Miniflare instance, which _should_ receive CF-Connecting-IP + stripCfConnectingIp: false, + serviceBindings: { + PROXY_CONTROLLER: async (req): Promise => { + const message = + (await req.json()) as ProxyWorkerOutgoingRequestBody; + + this.onProxyWorkerMessage(message); + + return new Response(null, { status: 204 }); + }, + }, + bindings: { + PROXY_CONTROLLER_AUTH_SECRET: this.secret, + }, + + // no need to use file-system, so don't + cache: false, + unsafeEphemeralDurableObjects: true, + }, + ], + + verbose: logger.loggerLevel === "debug", + + // log requests into the ProxyWorker (for local + remote mode) + log: new ProxyControllerLogger( + castLogLevel(logger.loggerLevel), + { + prefix: + // if debugging, log requests with specic ProxyWorker prefix + logger.loggerLevel === "debug" + ? "remote-bindings-ProxyWorker" + : "remote-bindings", + }, + this.localServerReady.promise + ), + handleStructuredLogs, + }; + + const proxyWorker = new Miniflare(proxyWorkerOptions); + this.proxyWorker = proxyWorker; + + void proxyWorker.ready + .then((url) => { + assert(url); + this.emitReadyEvent(proxyWorker, url); + }) + .catch((error) => { + if (this._torndown) { + return; + } + this.emitErrorEvent("Failed to start ProxyWorker", error); + }); + } + + runtimeMessageMutex = new Mutex(); + async sendMessageToProxyWorker( + message: ProxyWorkerIncomingRequestBody, + retries = 3 + ): Promise { + if (this._torndown) { + return; + } + + // Don't do any async work here. Enqueue the message with the mutex immediately. + + try { + await this.runtimeMessageMutex.runWith(async () => { + const { proxyWorker } = await this.ready.promise; + + const ready = await proxyWorker.ready.catch(() => undefined); + if (!ready) { + return; + } + + return proxyWorker.dispatchFetch( + `http://dummy/cdn-cgi/ProxyWorker/${message.type}`, + { + headers: { Authorization: this.secret }, + cf: { hostMetadata: message }, + } + ); + }); + } catch (cause) { + if (this._torndown) { + return; + } + + const error = castErrorCause(cause); + + if (retries > 0) { + return this.sendMessageToProxyWorker(message, retries - 1); + } + + this.emitErrorEvent( + `Failed to send message to ProxyWorker: ${JSON.stringify(message)}`, + error + ); + } + } + start(config: StartDevWorkerOptions) { + this.latestConfig = config; + this.createProxyWorker(); + } + pause(config: StartDevWorkerOptions) { + this.latestConfig = config; + void this.sendMessageToProxyWorker({ type: "pause" }); + } + play(data: ReloadCompleteEvent) { + this.localServerReady.resolve(); + + this.latestConfig = data.config; + this.latestBundle = data.bundle; + + void this.sendMessageToProxyWorker({ + type: "play", + proxyData: data.proxyData, + }); + } + onProxyWorkerMessage(message: ProxyWorkerOutgoingRequestBody) { + switch (message.type) { + case "previewTokenExpired": + this.onPreviewTokenExpired(); + + break; + case "error": + this.emitErrorEvent("Error inside ProxyWorker", message.error); + + break; + default: + assertNever(message); + } + } + _torndown = false; + async teardown() { + logger.debug("ProxyController teardown beginning..."); + this._torndown = true; + + const { proxyWorker } = this; + this.proxyWorker = undefined; + + await proxyWorker?.dispose(); + + logger.debug("ProxyController teardown complete"); + } + + // ********************* + // Event Dispatchers + // ********************* + + emitReadyEvent(proxyWorker: Miniflare, url: URL) { + const data: ReadyEvent = { + type: "ready", + proxyWorker, + url, + }; + + this.ready.resolve(data); + } + 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", + source: "ProxyController", + cause: castErrorCause(cause), + reason: data, + data: { + config: this.latestConfig, + bundle: this.latestBundle, + }, + }; + } + 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); + } +} + +class ProxyControllerLogger extends Log { + constructor( + level: LogLevel, + opts: LogOptions, + private localServerReady: Promise + ) { + super(level, opts); + } + + override logReady(message: string): void { + this.localServerReady.then(() => super.logReady(message)).catch(() => {}); + } + + 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 + if (message.includes("/cdn-cgi/") && this.level < LogLevel.DEBUG) { + return; + } + super.log(message); + } +} diff --git a/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts b/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts new file mode 100644 index 0000000000..a582b8e9cd --- /dev/null +++ b/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts @@ -0,0 +1,386 @@ +import { getAccessHeaders } from "@cloudflare/workers-auth"; +import { retryOnAPIFailure } from "@cloudflare/workers-utils"; +import chalk from "chalk"; +import { Mutex } from "miniflare"; +import { WebSocket } from "ws"; +import { version as packageVersion } from "../../package.json"; +import { logger } from "../logger"; +import { TRACE_VERSION } from "../utils/constants"; +import { + createPreviewSession, + createWorkerPreview, +} from "../utils/create-worker-preview"; +import { realishPrintLogs } from "../utils/printing"; +import { + createRemoteWorkerInit, + handlePreviewSessionCreationError, + handlePreviewSessionUploadError, +} from "../utils/remote"; +import { castErrorCause } from "./events"; +import { PREVIEW_TOKEN_REFRESH_INTERVAL, unwrapHook } from "./utils"; +import type { + CfAccount, + CfPreviewSession, + CfPreviewToken, +} from "../utils/create-worker-preview"; +import type { + BundleCompleteEvent, + ErrorEvent, + ProxyData, + ReloadCompleteEvent, +} from "./events"; +import type { Bundle, StartDevWorkerOptions } from "./types"; +import type { ComplianceConfig } from "@cloudflare/workers-utils"; + +type CreateRemoteWorkerInitProps = Parameters[0]; + +export class RemoteRuntimeController { + #abortController = new AbortController(); + + #currentBundleId = 0; + #mutex = new Mutex(); + + #session?: CfPreviewSession; + + #activeTail?: WebSocket; + + #latestConfig?: StartDevWorkerOptions; + #latestBundle?: Bundle; + #latestProxyData?: ProxyData; + + // 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 & { + complianceConfig: ComplianceConfig; + name: string; + } + ): Promise { + try { + return await retryOnAPIFailure( + () => + createPreviewSession( + props.complianceConfig, + props, + this.#abortController.signal, + props.name + ), + logger, + undefined, + undefined, + this.#abortController.signal + ); + } catch (err: unknown) { + if (err instanceof Error && err.name == "AbortError") { + return; // ignore + } + + handlePreviewSessionCreationError(err, props.accountId); + + throw err; + } + } + + async #previewToken( + props: CreateRemoteWorkerInitProps & + CfAccount & { + complianceConfig: ComplianceConfig; + bundleId: number; + } + ): Promise { + if (!this.#session) { + return; + } + // Capture session in a local variable so TypeScript can narrow + // the type inside the retryOnAPIFailure closure below. + const session = this.#session; + + try { + // 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; + } + // Suppress errors from terminating a WebSocket that hasn't connected yet + this.#activeTail?.removeAllListeners("error"); + this.#activeTail?.on("error", () => {}); + this.#activeTail?.terminate(); + const init = createRemoteWorkerInit({ + bundle: props.bundle, + name: props.name, + bindings: props.bindings, + compatibilityDate: props.compatibilityDate, + compatibilityFlags: props.compatibilityFlags, + }); + + const workerPreviewToken = await retryOnAPIFailure( + () => + createWorkerPreview( + props.complianceConfig, + init, + props, + session, + this.#abortController.signal + ), + logger, + undefined, + undefined, + this.#abortController.signal + ); + + if (workerPreviewToken.tailUrl) { + this.#activeTail = new WebSocket( + workerPreviewToken.tailUrl, + TRACE_VERSION, + { + headers: { + "Sec-WebSocket-Protocol": TRACE_VERSION, // needs to be `trace-v1` to be accepted + "User-Agent": `remote-bindings/${packageVersion}`, + }, + signal: this.#abortController.signal, + } + ); + + 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 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 + // tail down — this listener covers the window between WebSocket + // construction and the next terminate, plus any transient network + // errors during normal operation. + this.#activeTail.on("error", (err) => { + logger.debug("Active tail WebSocket error (ignored):", err); + }); + } + return workerPreviewToken; + } catch (err: unknown) { + if (err instanceof Error && err.name == "AbortError") { + return; // ignore + } + + const shouldRestartSession = handlePreviewSessionUploadError( + err, + props.accountId + ); + if (shouldRestartSession) { + this.#session = await this.#previewSession(props); + return this.#previewToken(props); + } + + this.emitErrorEvent({ + type: "error", + reason: "Failed to obtain a preview token", + cause: castErrorCause(err), + source: "RemoteRuntimeController", + data: undefined, + }); + } + } + + #getPreviewSession(config: StartDevWorkerOptions, auth: CfAccount) { + return this.#previewSession({ + complianceConfig: { compliance_region: config.complianceRegion }, + accountId: auth.accountId, + apiToken: auth.apiToken, + name: config.name, + }); + } + + async #updatePreviewToken( + config: StartDevWorkerOptions, + bundle: Bundle, + auth: CfAccount, + bundleId: number + ): Promise { + // If we received a new `bundleComplete` event before we were able to + // dispatch a `reloadComplete` for this bundle, ignore this bundle. + if (bundleId !== this.#currentBundleId) { + return false; + } + + const token = await this.#previewToken({ + bundle, + accountId: auth.accountId, + apiToken: auth.apiToken, + complianceConfig: { compliance_region: config.complianceRegion }, + name: config.name, + bindings: config.bindings, + compatibilityDate: config.compatibilityDate, + compatibilityFlags: config.compatibilityFlags, + bundleId, + }); + // If we received a new `bundleComplete` event before we were able to + // dispatch a `reloadComplete` for this bundle, ignore this bundle. + // If `token` is undefined, we've surfaced a relevant error to the user above, so ignore this bundle + if (bundleId !== this.#currentBundleId || !token) { + return false; + } + + const accessHeaders = await getAccessHeaders(token.host, { + logger, + }); + + const proxyData: ProxyData = { + userWorkerUrl: { + protocol: "https:", + hostname: token.host, + port: "443", + }, + headers: { + "cf-workers-preview-token": token.value, + ...accessHeaders, + "cf-connecting-ip": "", + }, + }; + + this.#latestProxyData = proxyData; + + this.onReloadComplete({ + type: "reloadComplete", + bundle, + config, + proxyData, + }); + + this.#scheduleRefresh(PREVIEW_TOKEN_REFRESH_INTERVAL); + return true; + } + + #scheduleRefresh(interval: number) { + clearTimeout(this.#refreshTimer); + this.#refreshTimer = setTimeout(() => { + if (this.#latestProxyData) { + this.onPreviewTokenExpired(); + } + }, interval); + } + + async #onBundleComplete({ config, bundle }: BundleCompleteEvent, id: number) { + // A newer bundle has already been queued — skip this stale one. + if (id !== this.#currentBundleId) { + return; + } + + logger.log(chalk.dim("⎔ Starting remote preview...")); + + try { + const auth = await unwrapHook(config.auth); + + this.#latestConfig = config; + this.#latestBundle = bundle; + + if (this.#session) { + logger.log(chalk.dim("⎔ Detected changes, restarted server.")); + } + + this.#session ??= await this.#getPreviewSession(config, auth); + await this.#updatePreviewToken(config, bundle, auth, id); + } catch (error) { + if (error instanceof Error && error.name == "AbortError") { + return; + } + + this.emitErrorEvent({ + type: "error", + reason: "Error reloading remote server", + cause: castErrorCause(error), + source: "RemoteRuntimeController", + data: undefined, + }); + } + } + + async #refreshPreviewToken() { + if (!this.#latestConfig || !this.#latestBundle) { + logger.warn( + "Cannot refresh preview token: missing config or bundle data" + ); + return; + } + + try { + const auth = await unwrapHook(this.#latestConfig.auth); + + this.#session = await this.#getPreviewSession(this.#latestConfig, auth); + + const refreshed = await this.#updatePreviewToken( + this.#latestConfig, + this.#latestBundle, + auth, + this.#currentBundleId + ); + + if (refreshed) { + logger.log(chalk.green("✔ Preview token refreshed successfully")); + } + } catch (error) { + if (error instanceof Error && error.name == "AbortError") { + return; + } + + this.emitErrorEvent({ + type: "error", + reason: "Error refreshing preview token", + cause: castErrorCause(error), + source: "RemoteRuntimeController", + data: undefined, + }); + } + } + + // ****************** + // Event Handlers + // ****************** + + onUpdateStart() { + // Abort any previous operations when a new bundle is started + this.#abortController.abort(); + this.#abortController = new AbortController(); + clearTimeout(this.#refreshTimer); + } + onBundleComplete(ev: BundleCompleteEvent) { + const id = ++this.#currentBundleId; + + void this.#mutex.runWith(() => this.#onBundleComplete(ev, id)); + } + onPreviewTokenExpired(): void { + logger.log(chalk.dim("⎔ Refreshing preview token...")); + void this.#mutex.runWith(() => this.#refreshPreviewToken()); + } + + async teardown() { + this.#tearingDown = true; + if (this.#session) { + logger.log(chalk.dim("⎔ Shutting down remote preview...")); + } + logger.debug("RemoteRuntimeController teardown beginning..."); + this.#session = undefined; + clearTimeout(this.#refreshTimer); + this.#abortController.abort(); + // Suppress errors from terminating a WebSocket that hasn't connected yet + this.#activeTail?.removeAllListeners("error"); + this.#activeTail?.on("error", () => {}); + this.#activeTail?.terminate(); + logger.debug("RemoteRuntimeController teardown complete"); + } + + 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 new file mode 100644 index 0000000000..533a260eea --- /dev/null +++ b/packages/remote-bindings/src/startDevWorker/events.ts @@ -0,0 +1,69 @@ +import type { Bundle, StartDevWorkerOptions } from "./types"; +import type { Miniflare } from "miniflare"; + +export type ErrorEvent = + | BaseErrorEvent<"RemoteRuntimeController"> + | BaseErrorEvent< + "ProxyController", + { config?: StartDevWorkerOptions; bundle?: Bundle } + >; +type BaseErrorEvent = { + type: "error"; + reason: string; + cause: Error | SerializedError; + source: Source; + data: Data; +}; + +export function castErrorCause(cause: unknown) { + if (cause instanceof Error) { + return cause; + } + + const error = new Error(); + error.cause = cause; + + return error; +} + +export type BundleCompleteEvent = { + type: "bundleComplete"; + + config: StartDevWorkerOptions; + bundle: Bundle; +}; + +export type ReloadCompleteEvent = { + type: "reloadComplete"; + + config: StartDevWorkerOptions; + bundle: Bundle; + proxyData: ProxyData; +}; +export type ReadyEvent = { + type: "ready"; + proxyWorker: Miniflare; + url: URL; +}; + +// ProxyWorker +export type ProxyWorkerIncomingRequestBody = + | { type: "play"; proxyData: ProxyData } + | { type: "pause" }; +export type ProxyWorkerOutgoingRequestBody = + | { type: "error"; error: SerializedError } + | { type: "previewTokenExpired"; proxyData: ProxyData }; + +export type SerializedError = { + message: string; + name?: string; + stack?: string | undefined; + cause?: unknown; +}; +export type UrlOriginParts = Pick; + +export type ProxyData = { + userWorkerUrl: UrlOriginParts; + userWorkerInnerUrlOverrides?: Partial; + headers: Record; +}; diff --git a/packages/remote-bindings/src/startDevWorker/types.ts b/packages/remote-bindings/src/startDevWorker/types.ts new file mode 100644 index 0000000000..68a33a27b0 --- /dev/null +++ b/packages/remote-bindings/src/startDevWorker/types.ts @@ -0,0 +1,30 @@ +import type { + AsyncHook, + CfAccount, + CfModule, + CfModuleType, + Config, + StartDevWorkerInput, +} from "@cloudflare/workers-utils"; + +export type StartDevWorkerOptions = { + name: string; + entrypointSource: string; + bindings: NonNullable; + compatibilityDate: StartDevWorkerInput["compatibilityDate"]; + compatibilityFlags: StartDevWorkerInput["compatibilityFlags"]; + complianceRegion: Config["compliance_region"]; + auth: AsyncHook; + server: { + hostname?: string; + port: number; + secure: boolean; + }; +}; + +export type Bundle = { + path: string; + entrypointSource: string; + type: CfModuleType; + modules: CfModule[]; +}; diff --git a/packages/remote-bindings/src/startDevWorker/utils.ts b/packages/remote-bindings/src/startDevWorker/utils.ts new file mode 100644 index 0000000000..1bc9704c3d --- /dev/null +++ b/packages/remote-bindings/src/startDevWorker/utils.ts @@ -0,0 +1,101 @@ +import assert from "node:assert"; +import type { Hook, HookValues } from "@cloudflare/workers-utils"; + +/** + * When to proactively refresh the preview token. + * + * Preview tokens expire after 1 hour (hardcoded in the Workers control plane), so we retry after 50 mins. + */ +export const PREVIEW_TOKEN_REFRESH_INTERVAL = 50 * 60 * 1000; + +export type MaybePromise = T | Promise; +export type DeferredPromise = { + promise: Promise; + resolve: (_: MaybePromise) => void; + reject: (_: Error) => void; +}; + +export function createDeferred( + previousDeferred?: DeferredPromise +): DeferredPromise { + let resolve, reject; + const newPromise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + assert(resolve); + assert(reject); + + // if passed a previousDeferred, ensure it is resolved with the newDeferred + // so that await-ers of previousDeferred are now await-ing newDeferred + previousDeferred?.resolve(newPromise); + + return { + promise: newPromise, + resolve, + reject, + }; +} + +export function urlFromParts( + parts: Partial, + base = "http://localhost" +): URL { + const url = new URL(base); + + Object.assign(url, parts); + + return url; +} + +/** + * Rewrites the absolute URLs inside a single header value (e.g. `Location`, + * `Origin`, `Access-Control-Allow-Origin`), mapping only those whose host + * that match the target host. + * + * This function ensures that the host is replaced in a robust manner avoiding + * corruptions (that can happen for example if a naive replacement was used). + * + * @param value - The raw header value string that may contain absolute URLs to rewrite. + * @param from - The URL whose host should be matched against URLs found in the header value. + * @param to - The URL whose origin will replace the matched origin in the header value. + * @returns The header value string with all matching absolute URLs rewritten to use the target origin. + */ +export function rewriteUrlInHeaderValue( + value: string, + from: URL, + to: URL +): string { + // Split each absolute URL into its `scheme://authority` origin and the + // literal remainder (path/query/fragment). Keeping the remainder verbatim + // avoids URL normalization such as appending a trailing slash to a bare + // origin (which would corrupt an `Origin` header), and leaves host-like + // substrings inside query strings untouched. + return value.replace( + /(https?:\/\/[^/?#\s,;"']+)([^\s,;"']*)/gi, + (match, origin: string, rest: string) => { + let url: URL; + try { + url = new URL(origin); + } catch { + return match; + } + if (url.host !== from.host) { + return match; + } + return to.origin + rest; + } + ); +} + +type UnwrapHook< + T extends HookValues | Promise, + Args extends unknown[], +> = Hook; + +export function unwrapHook< + T extends HookValues | Promise, + Args extends unknown[], +>(hook: UnwrapHook, ...args: Args): T { + return typeof hook === "function" ? hook(...args) : hook; +} diff --git a/packages/remote-bindings/src/utils/constants.ts b/packages/remote-bindings/src/utils/constants.ts new file mode 100644 index 0000000000..57896ef36c --- /dev/null +++ b/packages/remote-bindings/src/utils/constants.ts @@ -0,0 +1 @@ +export const TRACE_VERSION = "trace-v1"; diff --git a/packages/remote-bindings/src/utils/create-worker-preview.ts b/packages/remote-bindings/src/utils/create-worker-preview.ts new file mode 100644 index 0000000000..d65417feba --- /dev/null +++ b/packages/remote-bindings/src/utils/create-worker-preview.ts @@ -0,0 +1,285 @@ +import crypto from "node:crypto"; +import { URL } from "node:url"; +import { createWorkerUploadForm } from "@cloudflare/deploy-helpers/create-worker-upload-form"; +import { getAccessHeaders } from "@cloudflare/workers-auth"; +import { + APIError, + fetchResultBase, + getComplianceRegionSubdomain, + ParseError, + parseJSON, + UserError, +} from "@cloudflare/workers-utils"; +import { fetch } from "undici"; +import { version as packageVersion } from "../../package.json"; +import { logger } from "../logger"; +import type { CfWorkerInitWithName } from "./remote"; +import type { + ApiCredentials, + ComplianceConfig, +} from "@cloudflare/workers-utils"; +import type { HeadersInit, RequestInit } from "undici"; + +/** + * Maximum time (ms) to wait for an individual preview API request before + * treating it as a timeout. Without this, a hung API response blocks the + * entire dev-session reload indefinitely. + */ +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. + */ +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. + */ + +export interface CfAccount { + /** + * An API token. + * + * @link https://api.cloudflare.com/#user-api-tokens-properties + */ + apiToken: ApiCredentials; + /** + * An account ID. + */ + accountId: string; +} + +/** + * A Preview Session on the edge + */ +export interface CfPreviewSession { + /** + * A value to use when creating a worker preview under a session + */ + value: string; + /** + * The host where the session is available. + */ + host: string; +} + +/** + * A preview token. + */ +export interface CfPreviewToken { + /** + * The header value required to trigger a preview. + * + * @example + * const headers = { 'cf-workers-preview-token': value } + * const response = await fetch('https://' + host, { headers }) + */ + value: string; + /** + * The host where the preview is available. + */ + host: string; + /** + * A URL that when fetched starts a tail. Essentially, `wrangler tail` for realish previews. + * + * https://developers.cloudflare.com/api/resources/workers/subresources/scripts/subresources/tail/methods/create/ + */ + tailUrl?: string; +} + +/** + * Try and get a re-encoded token from the edge. Returns null if the exchange + * fails for any reason (expected with particular zone settings). + * Rethrows AbortError so callers can handle cancellation. + */ +async function tryExpandToken( + exchangeUrl: string, + abortSignal: AbortSignal +): Promise { + try { + const switchedExchangeUrl = new URL(exchangeUrl); + + const accessHeaders = await getAccessHeaders(switchedExchangeUrl.hostname, { + logger, + }); + const headers: HeadersInit = { ...accessHeaders }; + + logger.debug("-- START EXCHANGE API REQUEST:"); + + logger.debug("-- END EXCHANGE API REQUEST"); + const exchangeResponse = await fetch(switchedExchangeUrl, { + signal: abortSignal, + headers, + }); + const bodyText = await exchangeResponse.text(); + logger.debug( + "-- START EXCHANGE API RESPONSE:", + exchangeResponse.statusText, + exchangeResponse.status + ); + logger.debug("HEADERS:", JSON.stringify(exchangeResponse.headers, null, 2)); + + logger.debug("-- END EXCHANGE API RESPONSE"); + + if (!exchangeResponse.ok) { + return null; + } + + const body = parseJSON(bodyText) as { + token?: string; + }; + if (typeof body?.token !== "string") { + return null; + } + return body.token; + } catch (e) { + if (e instanceof Error && e.name === "AbortError") { + throw e; + } + return null; + } +} +/** + * Generates a preview session token. + */ +export async function createPreviewSession( + complianceConfig: ComplianceConfig, + account: CfAccount, + abortSignal: AbortSignal, + name: string +): Promise { + const { accountId } = account; + const initUrl = `/accounts/${accountId}/workers/subdomain/edge-preview`; + + const { token, exchange_url } = await fetchResult<{ + token: string; + exchange_url?: string; + }>(complianceConfig, account, initUrl, undefined, withTimeout(abortSignal)); + + const previewSessionToken = exchange_url + ? ((await tryExpandToken(exchange_url, withTimeout(abortSignal))) ?? token) + : token; + + try { + const subdomain = await getOrRegisterWorkersDevSubdomain( + complianceConfig, + account, + withTimeout(abortSignal) + ); + const host = `${name}.${subdomain}${getComplianceRegionSubdomain(complianceConfig)}.workers.dev`; + return { + value: previewSessionToken, + host, + }; + } catch (e) { + if (!(e instanceof ParseError)) { + throw e; + } else { + throw new UserError( + "Could not create remote preview session on your account.", + { telemetryMessage: "remote preview session creation failed" } + ); + } + } +} + +/** + * Creates a preview token. + */ +export async function createWorkerPreview( + complianceConfig: ComplianceConfig, + worker: CfWorkerInitWithName, + account: CfAccount, + session: CfPreviewSession, + abortSignal: AbortSignal +): Promise { + const { value, host } = session; + const { accountId } = account; + const url = `/accounts/${accountId}/workers/scripts/${worker.name}/edge-preview`; + + const formData = createWorkerUploadForm(worker, worker.bindings); + formData.set( + "wrangler-session-config", + JSON.stringify({ workers_dev: true, minimal_mode: true }) + ); + + const { preview_token, tail_url } = await fetchResult<{ + preview_token: string; + tail_url: string; + }>( + complianceConfig, + account, + url, + { + method: "POST", + body: formData, + headers: { + "cf-preview-upload-config-token": value, + }, + }, + withTimeout(abortSignal) + ); + + return { + value: preview_token, + host, + tailUrl: tail_url, + }; +} 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/miniflare.ts b/packages/remote-bindings/src/utils/miniflare.ts new file mode 100644 index 0000000000..b054613916 --- /dev/null +++ b/packages/remote-bindings/src/utils/miniflare.ts @@ -0,0 +1,35 @@ +import { LogLevel } from "miniflare"; +import { logger } from "../logger"; +import type { LoggerLevel } from "@cloudflare/workers-utils"; +import type { WorkerdStructuredLog } from "miniflare"; + +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/printing.ts b/packages/remote-bindings/src/utils/printing.ts new file mode 100644 index 0000000000..3727487d7b --- /dev/null +++ b/packages/remote-bindings/src/utils/printing.ts @@ -0,0 +1,82 @@ +import { inspect } from "node:util"; +import { logger } from "../logger"; +import type WebSocket from "ws"; + +/** + * Everything captured by the trace worker and sent to us via + * `wrangler tail` is structured JSON that deserializes to this type. + */ +export type TailEventMessage = { + /** + * The name of the script we're tailing + */ + scriptName?: string; + + /** + * The name of the entrypoint invoked by the Worker + */ + entrypoint?: string; + + /** + * Any exceptions raised by the worker + */ + exceptions: { + /** + * The name of the exception. + */ + name: string; + + /** + * The error message + */ + message: unknown; + + /** + * When the exception was raised/thrown + */ + timestamp: number; + + /** + * The stack trace of the exception, sourcemaps are already resolved. + */ + stack?: string; + }[]; + + /** + * Any logs sent out by the worker + */ + logs: { + message: unknown[]; + level: "debug" | "info" | "log" | "warn" | "error"; + timestamp: number; + }[]; + + /** + * When the event was triggered + */ + eventTimestamp: number; +}; + +/** + * Pretty-Print a Tail message from a realish-preview attached tail worker + * This is a simplified version of `prettyPrintLogs` that: + * - Only prints logs from HTTP triggers, since realish previews don't receive any other types of trigger. + * - Doesn't print the request log line (e.g. GET https://example.com/ - Ok) since in the realish + * context this is printed by Wrangler's proxy controller. + */ +export function realishPrintLogs(data: WebSocket.RawData): void { + const eventMessage: TailEventMessage = JSON.parse(data.toString()); + + if (eventMessage.logs.length > 0) { + eventMessage.logs.forEach(({ level, message }) => { + logger.console(level, ...message); + }); + } + + if (eventMessage.exceptions.length > 0) { + eventMessage.exceptions.forEach(({ name, message, stack }) => { + const errorLine = `${name}: ${typeof message === "string" ? message : inspect(message)}`; + logger.error(`${errorLine}${stack ? `\n${stack}` : ""}`); + }); + } +} diff --git a/packages/remote-bindings/src/utils/remote.ts b/packages/remote-bindings/src/utils/remote.ts new file mode 100644 index 0000000000..97cef16bf7 --- /dev/null +++ b/packages/remote-bindings/src/utils/remote.ts @@ -0,0 +1,193 @@ +import assert from "node:assert"; +import path from "node:path"; +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 { + CfWorkerInit, + StartDevWorkerInput, +} from "@cloudflare/workers-utils"; + +/** + * Error thrown when a remote dev session fails due to an authentication + * problem. The error message is a user-friendly description with actionable + * guidance, tailored to the caller's authentication method (environment + * variable token vs. OAuth). The original API error is preserved as the + * error's {@link Error.cause | cause}. + * + * Consumers that catch this error can display {@link Error.message | message} + * directly — no additional logging helper is needed. + */ +export class RemoteSessionAuthenticationError extends UserError { + /** + * @param cause - The original error that triggered the authentication + * failure (e.g. an {@link APIError} with code 9106 or 10000). + */ + constructor(cause: unknown) { + const envAuth = getAuthFromEnv(); + + let errorMessage = + "Failed to establish remote session due to an authentication issue.\n"; + if (envAuth !== undefined) { + // The user is authenticating via an environment variable + const method = + "apiToken" in envAuth + ? "a custom API token (`CLOUDFLARE_API_TOKEN`)" + : "a Global API Key (`CLOUDFLARE_API_KEY`)"; + + errorMessage += + `It looks like you are authenticating via ${method} set in an environment variable.\n` + + "The token may be invalid or lack the required permissions for this operation.\n\n" + + "To fix this, verify that your token is valid and has the correct permissions.\n" + + "You can also run `wrangler whoami` to check your current authentication status."; + } else { + // The user is authenticating via OAuth (wrangler login) + errorMessage += + "Your credentials may have expired or been revoked.\n\n" + + "To fix this, try to:\n" + + " - Run `wrangler whoami` to check your current authentication status.\n" + + " - Run `wrangler logout` and then `wrangler login` to re-authenticate."; + } + + super(errorMessage, { + cause, + telemetryMessage: "remote dev authentication error", + }); + } +} + +export function handlePreviewSessionUploadError( + err: unknown, + accountId: string +): boolean { + assert(err && typeof err === "object"); + // we want to log the error, but not end the process + // since it could recover after the developer fixes whatever's wrong + // instead of logging the raw API error to the user, + // give them friendly instructions + if (!isAbortError(err)) { + // code 10049 happens when the preview token expires + if ("code" in err && err.code === 10049) { + logger.log("Preview token expired, fetching a new one"); + + // since we want a new preview token when this happens, + // lets increment the counter, and trigger a rerun of + // the useEffect above + return true; + } else if (!handleUserFriendlyError(err, accountId)) { + logger.error("Error on remote worker:", err); + } + } + return false; +} + +export function handlePreviewSessionCreationError( + err: unknown, + accountId: string +) { + assert(err && typeof err === "object"); + if (handleUserFriendlyError(err, accountId)) { + return; + } + if ( + "cause" in err && + (err.cause as { code: string; hostname: string })?.code === "ENOTFOUND" + ) { + logger.error( + `Could not access \`${(err.cause as { code: string; hostname: string }).hostname}\`. Make sure the domain is set up to be proxied by Cloudflare.\nFor more details, refer to https://developers.cloudflare.com/workers/configuration/routing/routes/#set-up-a-route` + ); + } else if (err instanceof UserError) { + logger.error(err.message); + } + // we want to log the error, but not end the process + // since it could recover after the developer fixes whatever's wrong + else if (!isAbortError(err)) { + logger.error("Error while creating remote dev session:", err); + } +} + +export type CfWorkerInitWithName = Required> & + Omit & { + bindings: StartDevWorkerInput["bindings"]; + }; + +/** + * Create remote worker init from StartDevWorkerInput["bindings"] format + * (flat Record). + */ +export function createRemoteWorkerInit(props: { + bundle: Bundle; + name: string; + bindings: StartDevWorkerInput["bindings"]; + compatibilityDate: string | undefined; + compatibilityFlags: string[] | undefined; +}) { + const bindings = { ...props.bindings }; + + const init: CfWorkerInitWithName = { + name: props.name, + main: { + name: path.basename(props.bundle.path), + filePath: props.bundle.path, + type: props.bundle.type, + content: props.bundle.entrypointSource, + }, + modules: props.bundle.modules, + bindings, + migrations: undefined, // no migrations in dev + exports: undefined, + compatibility_date: props.compatibilityDate, + compatibility_flags: props.compatibilityFlags, + keepVars: true, + keepSecrets: true, + logpush: false, + sourceMaps: undefined, + containers: undefined, // Containers are not supported in remote dev mode + assets: undefined, + placement: undefined, // no placement in dev + tail_consumers: undefined, + streaming_tail_consumers: undefined, + limits: undefined, // no limits in preview - not supported yet but can be added + observability: undefined, // no observability in dev, + cache: undefined, // no cache in dev + }; + + return init; +} + +/** + * A switch for handling thrown error mappings to user friendly + * messages, does not perform any logic other than logging errors. + * @returns if the error was handled or not + */ +function handleUserFriendlyError(error: unknown, accountId?: string) { + if (error instanceof APIError) { + switch (error.code) { + // code 9106 and 10000 are authentication errors + case 9106: + case 10000: { + throw new RemoteSessionAuthenticationError(error); + } + + // for error 10063 (workers.dev subdomain required) + case 10063: { + const onboardingLink = accountId + ? `https://dash.cloudflare.com/${accountId}/workers/onboarding` + : "https://dash.cloudflare.com/?to=/:account/workers/onboarding"; + + 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: ${onboardingLink}` + ); + + return true; + } + + default: { + logger.error(error); + return true; + } + } + } +} 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/wrangler/templates/remoteBindings/ProxyServerWorker.ts b/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts similarity index 91% rename from packages/wrangler/templates/remoteBindings/ProxyServerWorker.ts rename to packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts index 4a43f4518c..0ca9c5b8a6 100644 --- a/packages/wrangler/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 new file mode 100644 index 0000000000..fb89a87794 --- /dev/null +++ b/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts @@ -0,0 +1,271 @@ +import { + createDeferred, + type DeferredPromise, + rewriteUrlInHeaderValue, + urlFromParts, +} from "../../src/startDevWorker/utils"; +import type { + ProxyData, + ProxyWorkerIncomingRequestBody, + ProxyWorkerOutgoingRequestBody, +} from "../../src/startDevWorker/events"; + +interface Env { + PROXY_CONTROLLER: Fetcher; + PROXY_CONTROLLER_AUTH_SECRET: string; + DURABLE_OBJECT: DurableObjectNamespace; +} + +// request.cf.hostMetadata is verbose to type using the workers-types Request -- this allows us to have Request correctly typed in this scope +type Request = Parameters< + NonNullable< + ExportedHandler["fetch"] + > +>[0]; + +export default { + fetch(req, env) { + const singleton = env.DURABLE_OBJECT.idFromName(""); + const inspectorProxy = env.DURABLE_OBJECT.get(singleton); + + return inspectorProxy.fetch(req); + }, +} as ExportedHandler; + +export class ProxyWorker implements DurableObject { + constructor( + _state: DurableObjectState, + readonly env: Env + ) {} + + proxyData?: ProxyData; + requestQueue = new Map>(); + requestRetryQueue = new Map>(); + + fetch(request: Request) { + if (isRequestFromProxyController(request, this.env)) { + // requests from ProxyController + + return this.processProxyControllerRequest(request); + } + + // regular requests to be proxied + const deferred = createDeferred(); + + this.requestQueue.set(request, deferred); + this.processQueue(); + + return deferred.promise; + } + + processProxyControllerRequest(request: Request) { + const event = request.cf?.hostMetadata; + switch (event?.type) { + case "pause": + this.proxyData = undefined; + break; + + case "play": + this.proxyData = event.proxyData; + this.processQueue(); + + break; + } + + return new Response(null, { status: 204 }); + } + + /** + * Process requests that are being retried first, then process newer requests. + * Requests that are being retried are, by definition, older than requests which haven't been processed yet. + * We don't need to be more accurate than this re ordering, since the requests are being fired off synchronously. + */ + *getOrderedQueue() { + yield* this.requestRetryQueue; + yield* this.requestQueue; + } + + processQueue() { + const { proxyData } = this; // store proxyData at the moment this function was called + if (proxyData === undefined) { + return; + } + + for (const [request, deferredResponse] of this.getOrderedQueue()) { + this.requestRetryQueue.delete(request); + this.requestQueue.delete(request); + + const outerUrl = new URL(request.url); + const headers = new Headers(request.headers); + + // override url parts for proxying + const userWorkerUrl = new URL(request.url); + Object.assign(userWorkerUrl, proxyData.userWorkerUrl); + + // set request.url in the UserWorker + const innerUrl = urlFromParts( + proxyData.userWorkerInnerUrlOverrides ?? {}, + request.url + ); + + // 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); + } + + rewriteUrlRelatedHeaders(headers, outerUrl, innerUrl); + + // Set after `rewriteUrlRelatedHeaders` so that any occurrences of the + // outer host inside the URL's query string (e.g. `?redirect_uri=`) + // are preserved in `request.url` inside the user worker. + headers.set("MF-Original-URL", innerUrl.href); + + // merge proxyData headers with the request headers + for (const [key, value] of Object.entries(proxyData.headers ?? {})) { + if (value === undefined) { + continue; + } + + if (key.toLowerCase() === "cookie") { + const existing = request.headers.get("cookie") ?? ""; + headers.set("cookie", `${existing};${value}`); + } else { + headers.set(key, value); + } + } + + // explicitly NOT await-ing this promise, we are in a loop and want to process the whole queue quickly + synchronously + void fetch(userWorkerUrl, new Request(request, { headers })) + .then(async (res) => { + res = new Response(res.body, res); + rewriteUrlRelatedHeaders(res.headers, innerUrl, outerUrl); + + await checkForPreviewTokenError(res, this.env, proxyData); + + deferredResponse.resolve(res); + }) + .catch((error: Error) => { + // errors here are network errors or from response post-processing + // to catch only network errors, use the 2nd param of the fetch.then() + + // we have crossed an async boundary, so proxyData may have changed + // if proxyData.userWorkerUrl has changed, it means there is a new downstream UserWorker + // and that this error is stale since it was for a request to the old UserWorker + // so here we construct a newUserWorkerUrl so we can compare it to the (old) userWorkerUrl + const newUserWorkerUrl = + this.proxyData && urlFromParts(this.proxyData.userWorkerUrl); + + // only report errors if the downstream proxy has NOT changed + if (userWorkerUrl.href === newUserWorkerUrl?.href) { + void sendMessageToProxyController(this.env, { + type: "error", + error: { + name: error.name, + message: error.message, + stack: error.stack, + cause: error.cause, + }, + }); + + deferredResponse.reject(error); + } + + // if the request can be retried (subset of idempotent requests which have no body), requeue it + else if (request.method === "GET" || request.method === "HEAD") { + this.requestRetryQueue.set(request, deferredResponse); + // we would only end up here if the downstream UserWorker is chang*ing* + // i.e. we are in a `pause`d state and expecting a `play` message soon + // this request will be processed (retried) when the `play` message arrives + // for that reason, we do not need to call `this.processQueue` here + // (but, also, it can't hurt to call it since it bails when + // in a `pause`d state i.e. `this.proxyData` is undefined) + } + + // if the request cannot be retried, respond with 503 Service Unavailable + // important to note, this is not an (unexpected) error -- it is an acceptable flow of local development + // it would be incorrect to retry non-idempotent requests + // and would require cloning all body streams to avoid stream reuse (which is inefficient but not out of the question in the future) + // this is a good enough UX for now since it solves the most common GET use-case + else { + deferredResponse.resolve( + new Response( + "Your worker restarted mid-request. Please try sending the request again. Only GET or HEAD requests are retried automatically.", + { + status: 503, + headers: { "Retry-After": "0" }, + } + ) + ); + } + }); + } + } +} + +function isRequestFromProxyController(req: Request, env: Env): boolean { + return req.headers.get("Authorization") === env.PROXY_CONTROLLER_AUTH_SECRET; +} +function sendMessageToProxyController( + env: Env, + message: ProxyWorkerOutgoingRequestBody +) { + return env.PROXY_CONTROLLER.fetch("http://dummy", { + method: "POST", + body: JSON.stringify(message), + }); +} + +async function checkForPreviewTokenError( + response: Response, + env: Env, + proxyData: ProxyData +) { + if (response.status !== 400) { + return; + } + + // At this point HTMLRewriter tries to parse the compressed stream, + // so we clone and read the text instead. + const clone = response.clone(); + const text = await clone.text(); + // Naive string match should be good enough when combined with status code check. + // "Invalid Workers Preview configuration" is the HTML error returned when the + // preview token has expired. "error code: 1031" is a text/plain error returned + // by remote bindings (e.g. Workers AI) when their underlying session has timed out. + // Both indicate the preview session needs to be refreshed. + if ( + text.includes("Invalid Workers Preview configuration") || + text.includes("error code: 1031") + ) { + void sendMessageToProxyController(env, { + type: "previewTokenExpired", + proxyData, + }); + } +} +/** + * 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/tsconfig.json b/packages/remote-bindings/tsconfig.json new file mode 100644 index 0000000000..9f7ff72405 --- /dev/null +++ b/packages/remote-bindings/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/base.json", + "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 new file mode 100644 index 0000000000..d11d1f8cd2 --- /dev/null +++ b/packages/remote-bindings/tsdown.config.ts @@ -0,0 +1,22 @@ +import path from "node:path"; +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", + external: (id) => { + const unprefixedId = id.charCodeAt(0) === 0 ? id.slice(1) : id; + return ( + !unprefixedId.startsWith("worker:") && + !unprefixedId.startsWith(".") && + !path.isAbsolute(unprefixedId) + ); + }, + plugins: [embedWorkersPlugin()], +}); diff --git a/packages/remote-bindings/turbo.json b/packages/remote-bindings/turbo.json new file mode 100644 index 0000000000..e1ecb71251 --- /dev/null +++ b/packages/remote-bindings/turbo.json @@ -0,0 +1,12 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"] + }, + "test:ci": { + "env": ["CLOUDFLARE_CF_AUTH"] + } + } +} 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/access.ts b/packages/workers-auth/src/access.ts index 2856f0f5ed..5888e66480 100644 --- a/packages/workers-auth/src/access.ts +++ b/packages/workers-auth/src/access.ts @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { UserError } from "@cloudflare/workers-utils"; +import { isNonInteractiveOrCI, UserError } from "@cloudflare/workers-utils"; import { fetch } from "undici"; import { getAccessClientIdFromEnv, @@ -89,11 +89,10 @@ export async function getAccessHeaders( domain: string, options: { logger: OAuthFlowLogger; - isNonInteractiveOrCI: () => boolean; + isNonInteractiveOrCI?: () => boolean; } ): Promise> { const logger = options.logger; - const isNonInteractiveOrCI = options.isNonInteractiveOrCI; // 1. If Access Service Token credentials are provided, use them directly. // @@ -139,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-auth/src/context.ts b/packages/workers-auth/src/context.ts index 71c5edf351..3c76fb4b45 100644 --- a/packages/workers-auth/src/context.ts +++ b/packages/workers-auth/src/context.ts @@ -2,6 +2,7 @@ import type { AuthConfigStorage } from "./config-file/auth"; import type { TemporaryAccountStorage } from "./config-file/temporary"; import type { generateAuthUrl as defaultGenerateAuthUrl } from "./generate-auth-url"; import type { generateRandomState as defaultGenerateRandomState } from "./generate-random-state"; +import type { Logger } from "@cloudflare/workers-utils"; /** * The dependencies the OAuth flow needs to mint/reuse a short-lived "temporary @@ -36,13 +37,7 @@ export interface OAuthConsentPages { * Subset of the wrangler `logger` singleton used by the OAuth flow. * Consumers pass in an implementation that maps to their own logging surface. */ -export interface OAuthFlowLogger { - debug(...args: unknown[]): void; - info(...args: unknown[]): void; - log(...args: unknown[]): void; - warn(...args: unknown[]): void; - error(...args: unknown[]): void; -} +export type OAuthFlowLogger = Logger; /** * Dependency-injection surface for {@link createOAuthFlow}. 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/packages/workers-utils/src/logger.ts b/packages/workers-utils/src/logger.ts index 42bc17ab79..1e398cd083 100644 --- a/packages/workers-utils/src/logger.ts +++ b/packages/workers-utils/src/logger.ts @@ -23,4 +23,8 @@ export type Logger = { warn: typeof console.warn; error: typeof console.error; }; + console?>( + method: M, + ...args: Parameters + ): void; }; 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/__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 1b19e833d3..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"; @@ -783,13 +784,13 @@ 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", }); assert(auth); - expect(await unwrapHook(auth, { account_id: undefined })).toEqual({ + expect(await unwrapHook(auth)).toEqual({ accountId: "some-account-id", apiToken: { apiToken: "some-api-token" }, }); @@ -827,13 +828,13 @@ 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", }); 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/__tests__/utils/retry.test.ts b/packages/wrangler/src/__tests__/utils/retry.test.ts index bd1738e580..6cd652ead9 100644 --- a/packages/wrangler/src/__tests__/utils/retry.test.ts +++ b/packages/wrangler/src/__tests__/utils/retry.test.ts @@ -1,7 +1,9 @@ -import { APIError } from "@cloudflare/workers-utils"; +import { + APIError, + retryOnAPIFailure as retryOnAPIFailureWithLogger, +} from "@cloudflare/workers-utils"; import { beforeEach, describe, it } from "vitest"; import { logger } from "../../logger"; -import { retryOnAPIFailure } from "../../utils/retry"; import { mockConsoleMethods } from "../helpers/mock-console"; describe("retryOnAPIFailure", () => { @@ -231,3 +233,18 @@ function getRetryAndErrorLogs(debugOutput: string): string[] { .split("\n") .filter((line) => line.includes("Retrying") || line.includes("APIError")); } + +function retryOnAPIFailure( + action: () => T | Promise, + backoff?: number, + attempts?: number, + abortSignal?: AbortSignal +): Promise { + return retryOnAPIFailureWithLogger( + action, + logger, + backoff, + attempts, + abortSignal + ); +} diff --git a/packages/wrangler/src/api/remoteBindings/index.ts b/packages/wrangler/src/api/remoteBindings/index.ts index 1322bb88ff..68abb047c0 100644 --- a/packages/wrangler/src/api/remoteBindings/index.ts +++ b/packages/wrangler/src/api/remoteBindings/index.ts @@ -1,221 +1,47 @@ -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"; +export { startRemoteProxySession } from "./start-remote-proxy-session"; +export type { StartRemoteProxySessionOptions } from "./start-remote-proxy-session"; 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 }, + startRemoteProxySession ); - - 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..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,247 +1,19 @@ -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 { startRemoteProxySession as startRemoteProxySessionFromPackage } from "@cloudflare/remote-bindings"; 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( +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 + 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"; - } + return startRemoteProxySessionFromPackage(bindings, { ...options, logger }); } diff --git a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts index 5fae00af09..ef80312ec4 100644 --- a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts @@ -1,5 +1,8 @@ import assert from "node:assert"; -import { MissingConfigError } from "@cloudflare/workers-utils"; +import { + MissingConfigError, + retryOnAPIFailure, +} from "@cloudflare/workers-utils"; import chalk from "chalk"; import { Mutex, type Miniflare } from "miniflare"; import { WebSocket } from "ws"; @@ -18,7 +21,6 @@ import { logger } from "../../logger"; import { TRACE_VERSION } from "../../tail/createTail"; import { realishPrintLogs } from "../../tail/printing"; import { getAccessHeaders } from "../../user/access"; -import { retryOnAPIFailure } from "../../utils/retry"; import { RuntimeController } from "./BaseController"; import { castErrorCause } from "./events"; import { PREVIEW_TOKEN_REFRESH_INTERVAL, unwrapHook } from "./utils"; @@ -76,6 +78,7 @@ export class RemoteRuntimeController extends RuntimeController { this.#abortController.signal, props.name ), + logger, undefined, undefined, this.#abortController.signal @@ -175,6 +178,7 @@ export class RemoteRuntimeController extends RuntimeController { this.#abortController.signal, props.minimal_mode ), + logger, undefined, undefined, this.#abortController.signal diff --git a/packages/wrangler/src/email-routing/utils.ts b/packages/wrangler/src/email-routing/utils.ts index c455e4b008..2292e8ac00 100644 --- a/packages/wrangler/src/email-routing/utils.ts +++ b/packages/wrangler/src/email-routing/utils.ts @@ -1,7 +1,7 @@ -import { UserError } from "@cloudflare/workers-utils"; +import { retryOnAPIFailure, UserError } from "@cloudflare/workers-utils"; import { fetchListResult, fetchResult } from "../cfetch"; +import { logger } from "../logger"; import { requireAuth } from "../user"; -import { retryOnAPIFailure } from "../utils/retry"; import { listEmailSendingSubdomains } from "./client"; import type { EmailSendingSubdomain } from "./index"; import type { ComplianceConfig, Config } from "@cloudflare/workers-utils"; @@ -29,16 +29,18 @@ async function getZoneIdByDomain( domain: string, accountId: string ): Promise { - const zones = await retryOnAPIFailure(() => - fetchListResult<{ id: string }>( - complianceConfig, - `/zones`, - {}, - new URLSearchParams({ - name: domain, - "account.id": accountId, - }) - ) + const zones = await retryOnAPIFailure( + () => + fetchListResult<{ id: string }>( + complianceConfig, + `/zones`, + {}, + new URLSearchParams({ + name: domain, + "account.id": accountId, + }) + ), + logger ); const zoneId = zones[0]?.id; @@ -67,8 +69,10 @@ export async function resolveDomain( // If zone ID is provided directly, fetch the zone name to determine subdomain status if (zoneId) { await requireAuth(config); - const zone = await retryOnAPIFailure(() => - fetchResult<{ id: string; name: string }>(config, `/zones/${zoneId}`) + const zone = await retryOnAPIFailure( + () => + fetchResult<{ id: string; name: string }>(config, `/zones/${zoneId}`), + logger ); return { zoneId, @@ -84,16 +88,18 @@ export async function resolveDomain( const labels = domain.split("."); for (let i = 0; i <= labels.length - 2; i++) { const candidate = labels.slice(i).join("."); - const zones = await retryOnAPIFailure(() => - fetchListResult<{ id: string; name: string }>( - config, - `/zones`, - {}, - new URLSearchParams({ - name: candidate, - "account.id": accountId, - }) - ) + const zones = await retryOnAPIFailure( + () => + fetchListResult<{ id: string; name: string }>( + config, + `/zones`, + {}, + new URLSearchParams({ + name: candidate, + "account.id": accountId, + }) + ), + logger ); if (zones[0]) { return { diff --git a/packages/wrangler/src/utils/retry.ts b/packages/wrangler/src/utils/retry.ts deleted file mode 100644 index dfa2e1637a..0000000000 --- a/packages/wrangler/src/utils/retry.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { setTimeout } from "node:timers/promises"; -import { APIError } from "@cloudflare/workers-utils"; -import chalk from "chalk"; -import { logger } from "../logger"; - -const MAX_ATTEMPTS = 3; -/** - * Wrap around calls to the Cloudflare API to automatically retry - * calls that result in a 5xx error code, indicating an API failure. - * - * Retries will back off at a rate of 1000ms per retry, with a 0ms delay for the first retry - * - * Note: this will not retry 4xx or other failures, as those are - * likely legitimate user error. - */ -export async function retryOnAPIFailure( - action: () => T | Promise, - backoff = 0, - attempts = MAX_ATTEMPTS, - abortSignal?: AbortSignal -): Promise { - try { - return await action(); - } catch (err) { - if (err instanceof APIError) { - if (!err.isRetryable()) { - throw err; - } - } else if (err instanceof DOMException && err.name === "TimeoutError") { - // Per-request timeouts (from AbortSignal.timeout()) are transient - // and should be retried, but user-initiated aborts (AbortError) - // should not. - } else if (!(err instanceof TypeError)) { - throw err; - } - - logger.debug(chalk.dim(`Retrying API call after error...`)); - logger.debug(err); - - if (attempts <= 1) { - throw err; - } - - await setTimeout(backoff, undefined, { signal: abortSignal }); - return retryOnAPIFailure(action, backoff + 1000, attempts - 1, abortSignal); - } -} diff --git a/packages/wrangler/templates/remoteBindings/wrangler.jsonc b/packages/wrangler/templates/remoteBindings/wrangler.jsonc deleted file mode 100644 index a9a5b5c8af..0000000000 --- a/packages/wrangler/templates/remoteBindings/wrangler.jsonc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "main": "./ProxyServerWorker.ts", - "compatibility_date": "2025-04-28", -} diff --git a/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"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9a05df14f..5506031b10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2563,6 +2563,58 @@ importers: specifier: ^3.5.0 version: 3.5.0(esbuild@0.28.1) + packages/remote-bindings: + dependencies: + '@cloudflare/cli-shared-helpers': + specifier: workspace:* + version: link:../cli + '@cloudflare/deploy-helpers': + specifier: workspace:* + version: link:../deploy-helpers + '@cloudflare/workers-auth': + specifier: workspace:* + version: link:../workers-auth + '@cloudflare/workers-utils': + specifier: workspace:* + version: link:../workers-utils + 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 + '@types/ws': + specifier: ^8.5.13 + version: 8.5.13 + capnweb: + specifier: catalog:default + version: 0.5.0 + esbuild: + specifier: catalog:default + version: 0.28.1 + 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: miniflare: @@ -2661,6 +2713,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 @@ -3905,6 +3960,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 @@ -4369,6 +4427,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 @@ -5441,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 @@ -5478,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 @@ -5506,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 @@ -5523,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==} @@ -5541,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]