From 60c4eba1069f932a11386e5e90605834358245d6 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 14 Jul 2026 15:50:25 +0100 Subject: [PATCH 01/37] [remote-bindings] Add empty package --- packages/remote-bindings/package.json | 36 +++++++++++++++++++++++ packages/remote-bindings/src/index.ts | 1 + packages/remote-bindings/tsconfig.json | 4 +++ packages/remote-bindings/tsdown.config.ts | 11 +++++++ packages/remote-bindings/turbo.json | 9 ++++++ pnpm-lock.yaml | 12 ++++++++ 6 files changed, 73 insertions(+) create mode 100644 packages/remote-bindings/package.json create mode 100644 packages/remote-bindings/src/index.ts create mode 100644 packages/remote-bindings/tsconfig.json create mode 100644 packages/remote-bindings/tsdown.config.ts create mode 100644 packages/remote-bindings/turbo.json diff --git a/packages/remote-bindings/package.json b/packages/remote-bindings/package.json new file mode 100644 index 00000000000..1bd92f239e6 --- /dev/null +++ b/packages/remote-bindings/package.json @@ -0,0 +1,36 @@ +{ + "name": "@cloudflare/remote-bindings", + "version": "0.0.0", + "private": true, + "homepage": "https://github.com/cloudflare/workers-sdk/tree/main/packages/remote-bindings#readme", + "bugs": { + "url": "https://github.com/cloudflare/workers-sdk/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/cloudflare/workers-sdk.git", + "directory": "packages/remote-bindings" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "scripts": { + "build": "tsdown", + "check:type": "tsc", + "dev": "tsdown --watch" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "tsdown": "0.16.3", + "typescript": "catalog:default" + } +} diff --git a/packages/remote-bindings/src/index.ts b/packages/remote-bindings/src/index.ts new file mode 100644 index 00000000000..cb0ff5c3b54 --- /dev/null +++ b/packages/remote-bindings/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/remote-bindings/tsconfig.json b/packages/remote-bindings/tsconfig.json new file mode 100644 index 00000000000..43c3882de2e --- /dev/null +++ b/packages/remote-bindings/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@cloudflare/workers-tsconfig/base.json", + "include": ["src"] +} diff --git a/packages/remote-bindings/tsdown.config.ts b/packages/remote-bindings/tsdown.config.ts new file mode 100644 index 00000000000..0c9545f0894 --- /dev/null +++ b/packages/remote-bindings/tsdown.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + }, + platform: "node", + outDir: "dist", + dts: true, + tsconfig: "tsconfig.json", +}); diff --git a/packages/remote-bindings/turbo.json b/packages/remote-bindings/turbo.json new file mode 100644 index 00000000000..6556dcf3e5e --- /dev/null +++ b/packages/remote-bindings/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"] + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2766ca220fb..5fd52975883 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2557,6 +2557,18 @@ importers: specifier: ^3.5.0 version: 3.5.0(esbuild@0.28.1) + packages/remote-bindings: + devDependencies: + '@cloudflare/workers-tsconfig': + specifier: workspace:* + version: link:../workers-tsconfig + tsdown: + specifier: 0.16.3 + version: 0.16.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3) + typescript: + specifier: catalog:default + version: 5.8.3 + packages/runtime-types: dependencies: miniflare: From 13c0ce7b3d77d85ab3dfa48349bdb342979ce773 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 14 Jul 2026 16:57:15 +0100 Subject: [PATCH 02/37] [remote-bindings] Extract remote binding implementation --- packages/remote-bindings/package.json | 17 +- packages/remote-bindings/src/auth.test.ts | 72 +++++ packages/remote-bindings/src/auth.ts | 57 ++++ packages/remote-bindings/src/index.ts | 16 +- packages/remote-bindings/src/logger.ts | 6 + .../src/maybe-start-or-update-session.ts | 181 +++++++++++++ .../src/start-remote-proxy-session.ts | 180 +++++++++++++ packages/remote-bindings/src/start-worker.ts | 19 ++ .../remoteBindings/ProxyServerWorker.ts | 0 .../templates/remoteBindings/wrangler.jsonc | 0 packages/remote-bindings/tsconfig.json | 5 +- packages/remote-bindings/tsdown.config.ts | 28 +- packages/wrangler/package.json | 1 + .../wrangler/src/api/remoteBindings/index.ts | 218 ++------------- .../start-remote-proxy-session.ts | 248 +----------------- pnpm-lock.yaml | 27 ++ 16 files changed, 617 insertions(+), 458 deletions(-) create mode 100644 packages/remote-bindings/src/auth.test.ts create mode 100644 packages/remote-bindings/src/auth.ts create mode 100644 packages/remote-bindings/src/logger.ts create mode 100644 packages/remote-bindings/src/maybe-start-or-update-session.ts create mode 100644 packages/remote-bindings/src/start-remote-proxy-session.ts create mode 100644 packages/remote-bindings/src/start-worker.ts rename packages/{wrangler => remote-bindings}/templates/remoteBindings/ProxyServerWorker.ts (100%) rename packages/{wrangler => remote-bindings}/templates/remoteBindings/wrangler.jsonc (100%) diff --git a/packages/remote-bindings/package.json b/packages/remote-bindings/package.json index 1bd92f239e6..4c404219d91 100644 --- a/packages/remote-bindings/package.json +++ b/packages/remote-bindings/package.json @@ -13,7 +13,8 @@ "directory": "packages/remote-bindings" }, "files": [ - "dist" + "dist", + "templates" ], "type": "module", "sideEffects": false, @@ -26,11 +27,21 @@ "scripts": { "build": "tsdown", "check:type": "tsc", - "dev": "tsdown --watch" + "dev": "tsdown --watch", + "test:ci": "vitest run --passWithNoTests", + "test:watch": "vitest" }, "devDependencies": { + "@cloudflare/cli-shared-helpers": "workspace:*", + "@cloudflare/workers-auth": "workspace:*", "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "@cloudflare/workers-utils": "workspace:*", + "capnweb": "catalog:default", + "chalk": "catalog:default", + "miniflare": "workspace:*", "tsdown": "0.16.3", - "typescript": "catalog:default" + "typescript": "catalog:default", + "vitest": "catalog:default" } } diff --git a/packages/remote-bindings/src/auth.test.ts b/packages/remote-bindings/src/auth.test.ts new file mode 100644 index 00000000000..00f9c052968 --- /dev/null +++ b/packages/remote-bindings/src/auth.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, it, vi } from "vitest"; +import { createRemoteBindingsAuth } from "./auth"; +import type { RemoteBindingsLogger } from "./logger"; + +const mocks = vi.hoisted(() => ({ + cfAuth: { source: "cf" }, + wranglerAuth: { source: "wrangler" }, + createCfAuth: vi.fn(), + createWranglerAuth: vi.fn(), +})); + +vi.mock("@cloudflare/workers-auth/cf", () => ({ + createCfAuth: mocks.createCfAuth.mockReturnValue(mocks.cfAuth), +})); + +vi.mock("@cloudflare/workers-auth/wrangler", () => ({ + createWranglerAuth: mocks.createWranglerAuth.mockReturnValue( + mocks.wranglerAuth + ), +})); + +const originalCfAuth = process.env.CLOUDFLARE_CF_AUTH; + +function createTestLogger(): RemoteBindingsLogger { + return { + loggerLevel: "log", + debug: vi.fn(), + log: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + once: { + info: vi.fn(), + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + }; +} + +describe("createRemoteBindingsAuth", () => { + beforeEach(() => { + delete process.env.CLOUDFLARE_CF_AUTH; + }); + + afterEach(() => { + vi.clearAllMocks(); + if (originalCfAuth === undefined) { + delete process.env.CLOUDFLARE_CF_AUTH; + } else { + process.env.CLOUDFLARE_CF_AUTH = originalCfAuth; + } + }); + + it("uses Wrangler auth by default", ({ expect }) => { + const result = createRemoteBindingsAuth(createTestLogger()); + + expect(result).toEqual({ auth: mocks.wranglerAuth, useCfAuth: false }); + expect(mocks.createWranglerAuth).toHaveBeenCalledOnce(); + expect(mocks.createCfAuth).not.toHaveBeenCalled(); + }); + + it("uses CF auth when CLOUDFLARE_CF_AUTH is present", ({ expect }) => { + process.env.CLOUDFLARE_CF_AUTH = ""; + + const result = createRemoteBindingsAuth(createTestLogger()); + + expect(result).toEqual({ auth: mocks.cfAuth, useCfAuth: true }); + expect(mocks.createCfAuth).toHaveBeenCalledOnce(); + expect(mocks.createWranglerAuth).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/remote-bindings/src/auth.ts b/packages/remote-bindings/src/auth.ts new file mode 100644 index 00000000000..4b4a23ba8aa --- /dev/null +++ b/packages/remote-bindings/src/auth.ts @@ -0,0 +1,57 @@ +import { inputPrompt } from "@cloudflare/cli-shared-helpers/interactive"; +import { createCfAuth } from "@cloudflare/workers-auth/cf"; +import { createWranglerAuth } from "@cloudflare/workers-auth/wrangler"; +import { isNonInteractiveOrCI, UserError } from "@cloudflare/workers-utils"; +import { version as packageVersion } from "../package.json"; +import type { RemoteBindingsLogger } from "./logger"; + +class NoDefaultValueProvided extends UserError { + constructor() { + super("This command cannot be run in a non-interactive context", { + telemetryMessage: "remote bindings prompt default missing", + }); + } +} + +export function createRemoteBindingsAuth(logger: RemoteBindingsLogger) { + const context = { + logger, + userAgent: `remote-bindings/${packageVersion}`, + async prompt(question: string) { + if (isNonInteractiveOrCI()) { + throw new NoDefaultValueProvided(); + } + return inputPrompt({ + type: "text", + question, + label: "Answer", + throwOnError: true, + }); + }, + async select( + question: string, + options: { choices: { title: string; value: string }[] } + ) { + if (isNonInteractiveOrCI()) { + throw new NoDefaultValueProvided(); + } + return inputPrompt({ + type: "select", + question, + label: "Account", + options: options.choices.map((choice) => ({ + label: choice.title, + value: choice.value, + })), + throwOnError: true, + }); + }, + isNoDefaultValueProvidedError: (error: unknown) => + error instanceof NoDefaultValueProvided, + }; + const useCfAuth = "CLOUDFLARE_CF_AUTH" in process.env; + return { + auth: useCfAuth ? createCfAuth(context) : createWranglerAuth(context), + useCfAuth, + }; +} diff --git a/packages/remote-bindings/src/index.ts b/packages/remote-bindings/src/index.ts index cb0ff5c3b54..df418124afb 100644 --- a/packages/remote-bindings/src/index.ts +++ b/packages/remote-bindings/src/index.ts @@ -1 +1,15 @@ -export {}; +export { + maybeStartOrUpdateRemoteProxySession, + pickRemoteBindings, +} from "./maybe-start-or-update-session"; +export type { + RemoteBindingsContext, + RemoteProxySessionData, + WorkerConfigObject, +} from "./maybe-start-or-update-session"; +export { startRemoteProxySession } from "./start-remote-proxy-session"; +export type { RemoteBindingsLogger } from "./logger"; +export type { + RemoteProxySession, + StartRemoteProxySessionOptions, +} from "./start-remote-proxy-session"; diff --git a/packages/remote-bindings/src/logger.ts b/packages/remote-bindings/src/logger.ts new file mode 100644 index 00000000000..6754385c697 --- /dev/null +++ b/packages/remote-bindings/src/logger.ts @@ -0,0 +1,6 @@ +import type { Logger, LoggerLevel } from "@cloudflare/workers-utils"; + +export type RemoteBindingsLogger = Logger & { + loggerLevel: LoggerLevel; + once: NonNullable; +}; diff --git a/packages/remote-bindings/src/maybe-start-or-update-session.ts b/packages/remote-bindings/src/maybe-start-or-update-session.ts new file mode 100644 index 00000000000..b4d7f46d319 --- /dev/null +++ b/packages/remote-bindings/src/maybe-start-or-update-session.ts @@ -0,0 +1,181 @@ +import assert from "node:assert"; +import { createCfProfileStore } from "@cloudflare/workers-auth/cf"; +import { createWranglerProfileStore } from "@cloudflare/workers-auth/wrangler"; +import { getBindingLocalSupport } from "@cloudflare/workers-utils"; +import { createRemoteBindingsAuth } from "./auth"; +import { startRemoteProxySession } from "./start-remote-proxy-session"; +import type { RemoteBindingsLogger } from "./logger"; +import type { RemoteProxySession } from "./start-remote-proxy-session"; +import type { + AsyncHook, + Binding, + CfAccount, + Config, + StartDevWorkerInput, +} from "@cloudflare/workers-utils"; + +export function pickRemoteBindings( + bindings: Record +): Record { + return Object.fromEntries( + Object.entries(bindings ?? {}).filter(([, binding]) => { + if ( + getBindingLocalSupport(binding.type) === + "DO-NOT-USE-this-resource-will-never-have-a-local-simulator" + ) { + return true; + } + return "remote" in binding && binding.remote; + }) + ); +} + +export type WorkerConfigObject = { + /** The name of the worker. */ + name?: string; + /** The Worker's bindings. */ + bindings: NonNullable; + /** If running in a non-public compliance region, set this here. */ + complianceRegion?: Config["compliance_region"]; + /** ID of the account owning the worker. */ + account_id?: Config["account_id"]; + /** Directory used to resolve the auth profile from directory bindings. */ + profileDir?: string; +}; + +export type RemoteProxySessionData = { + session: RemoteProxySession; + remoteBindings: Record; + auth?: AsyncHook; +}; + +export type RemoteBindingsContext = { + logger: RemoteBindingsLogger; +}; + +/** Potentially starts or updates a remote proxy session. */ +export async function maybeStartOrUpdateRemoteProxySession( + workerConfigObject: WorkerConfigObject, + preExistingRemoteProxySessionData?: RemoteProxySessionData | null, + auth?: AsyncHook, + context?: RemoteBindingsContext, + startSession: typeof startRemoteProxySession = startRemoteProxySession +): Promise { + const remoteBindings = pickRemoteBindings(workerConfigObject.bindings); + const authSameAsBefore = deepStrictEqual( + auth, + preExistingRemoteProxySessionData?.auth + ); + let remoteProxySession = preExistingRemoteProxySessionData?.session; + + if (!authSameAsBefore) { + if (preExistingRemoteProxySessionData?.session) { + await preExistingRemoteProxySessionData.session.dispose(); + } + + remoteProxySession = await startSession(remoteBindings, { + workerName: workerConfigObject.name, + complianceRegion: workerConfigObject.complianceRegion, + auth: getAuthHook( + auth, + workerConfigObject.account_id + ? { account_id: workerConfigObject.account_id } + : undefined, + workerConfigObject.profileDir, + context?.logger + ), + logger: context?.logger, + }); + } else { + const remoteBindingsAreSameAsBefore = deepStrictEqual( + remoteBindings, + preExistingRemoteProxySessionData?.remoteBindings + ); + + if (!remoteBindingsAreSameAsBefore) { + if (!remoteProxySession) { + if (Object.keys(remoteBindings).length > 0) { + remoteProxySession = await startSession(remoteBindings, { + workerName: workerConfigObject.name, + complianceRegion: workerConfigObject.complianceRegion, + auth: getAuthHook( + auth, + workerConfigObject.account_id + ? { account_id: workerConfigObject.account_id } + : undefined, + workerConfigObject.profileDir, + context?.logger + ), + logger: context?.logger, + }); + } + } else { + await remoteProxySession.updateBindings(remoteBindings); + } + } + } + + await remoteProxySession?.ready; + if (!remoteProxySession) { + return null; + } + return { + session: remoteProxySession, + remoteBindings, + auth, + }; +} + +/** + * Gets the auth hook to use for the remote proxy session, this is either the user provided auth + * hook if there is one, or an ad-hoc hook created using the account_id from the user's wrangler + * config file otherwise. + * + * @param auth the auth hook provided by the user if any + * @param config the user's wrangler config if any + * @param profileDir working directory used to resolve the auth profile from directory bindings, + * falls back to `process.cwd()` when not provided + * @returns the auth hook to pass to the startRemoteProxy session function if any + */ +function getAuthHook( + auth: AsyncHook | undefined, + config: Pick | undefined, + profileDir: string | undefined, + logger?: RemoteBindingsLogger +): AsyncHook | undefined { + if (!logger) { + throw new Error("A logger is required to resolve remote binding auth"); + } + const { auth: remoteBindingsAuth, useCfAuth } = + createRemoteBindingsAuth(logger); + const profileStore = useCfAuth + ? createCfProfileStore({ logger }) + : createWranglerProfileStore({ logger }); + const profile = profileStore.resolve({ + cwd: profileDir ?? process.cwd(), + }); + remoteBindingsAuth.setProfile(profile); + if (auth) { + return auth; + } + + if (config?.account_id) { + return async () => { + return { + accountId: await remoteBindingsAuth.requireAuth(config), + apiToken: remoteBindingsAuth.requireApiToken(), + }; + }; + } + + return undefined; +} + +function deepStrictEqual(source: unknown, target: unknown): boolean { + try { + assert.deepStrictEqual(source, target); + return true; + } catch { + return false; + } +} diff --git a/packages/remote-bindings/src/start-remote-proxy-session.ts b/packages/remote-bindings/src/start-remote-proxy-session.ts new file mode 100644 index 00000000000..1ade0a2712e --- /dev/null +++ b/packages/remote-bindings/src/start-remote-proxy-session.ts @@ -0,0 +1,180 @@ +import events from "node:events"; +import { fileURLToPath } from "node:url"; +import { UserError } from "@cloudflare/workers-utils"; +import chalk from "chalk"; +import { DeferredPromise } from "miniflare"; +import { startWorker } from "./start-worker"; +import type { RemoteBindingsLogger } from "./logger"; +import type { Worker } from "./start-worker"; +import type { + Config, + LoggerLevel, + StartDevWorkerInput, +} from "@cloudflare/workers-utils"; +import type { RemoteProxyConnectionString } from "miniflare"; + +type ErrorEvent = { + type: "error"; + reason: string; + cause: unknown; +}; + +export type StartRemoteProxySessionOptions = { + workerName?: string; + auth?: NonNullable["auth"]; + /** If running in a non-public compliance region, set this here. */ + complianceRegion?: Config["compliance_region"]; + logger?: RemoteBindingsLogger; +}; + +function isErrorEvent(error: unknown): error is ErrorEvent { + return ( + typeof error === "object" && + error !== null && + "type" in error && + error.type === "error" && + "reason" in error && + "cause" in error + ); +} + +function getErrorMessage(error: unknown): string | undefined { + if (error instanceof Error) { + return getErrorMessage(error.cause) ?? error.message; + } + if (typeof error === "string") { + return error; + } + if (typeof error === "object" && error !== null) { + const maybeMessage = (error as { message?: unknown }).message; + if (typeof maybeMessage === "string") { + const maybeCause = (error as { cause?: unknown }).cause; + return getErrorMessage(maybeCause) ?? maybeMessage; + } + } + return undefined; +} + +function formatRemoteProxySessionError(error: unknown): string | undefined { + if (isErrorEvent(error)) { + const causeMessage = getErrorMessage(error.cause); + return causeMessage ? `${error.reason}: ${causeMessage}` : error.reason; + } + return getErrorMessage(error); +} + +export async function startRemoteProxySession( + bindings: StartDevWorkerInput["bindings"], + options?: StartRemoteProxySessionOptions +): Promise { + options?.logger?.log(chalk.dim("⎔ Establishing remote connection...")); + const rawBindings = toRawBindings(bindings); + const proxyServerWorkerWranglerConfig = fileURLToPath( + new URL("../templates/remoteBindings/wrangler.jsonc", import.meta.url) + ); + const remoteBindingsWorkerPath = fileURLToPath( + new URL("./proxy-worker.js", import.meta.url) + ); + + const worker = await startWorker({ + name: options?.workerName, + entrypoint: remoteBindingsWorkerPath, + config: proxyServerWorkerWranglerConfig, + compatibilityDate: "2025-04-28", + dev: { + remote: "minimal", + auth: options?.auth, + server: { port: 0 }, + inspector: false, + logLevel: getStartWorkerLogLevel(options?.logger?.loggerLevel ?? "error"), + }, + bindings: rawBindings, + }).catch((startWorkerError: unknown) => { + if (startWorkerError instanceof UserError) { + throw startWorkerError; + } + let errorMessage = startWorkerError; + if (startWorkerError instanceof Error) { + errorMessage = + startWorkerError.cause instanceof Error + ? startWorkerError.cause.message + : startWorkerError.message; + } + throw new Error( + `Failed to start the remote proxy session, see the error details below:\n\n${errorMessage}` + ); + }); + + const maybeErrorPromise = new DeferredPromise<{ error: unknown }>(); + worker.raw.addListener("error", (error) => { + maybeErrorPromise.resolve({ error }); + }); + const maybeError = await Promise.race([ + maybeErrorPromise, + worker.raw.proxy.localServerReady.promise, + ]); + + if (maybeError && maybeError.error) { + const details = formatRemoteProxySessionError(maybeError.error); + throw new Error( + details + ? `Failed to start the remote proxy session. ${details}` + : "Failed to start the remote proxy session. There is likely additional logging output above.", + { cause: maybeError.error } + ); + } + + const remoteProxyConnectionString = + (await worker.url) as RemoteProxyConnectionString; + const updateBindings = async ( + newBindings: StartDevWorkerInput["bindings"] + ) => { + const reloadComplete = events.once(worker.raw, "reloadComplete"); + await worker.patchConfig({ bindings: toRawBindings(newBindings) }); + try { + await reloadComplete; + } catch (errorOrEvent) { + throw errorOrEvent instanceof Error + ? errorOrEvent + : new Error( + `RemoteProxySession.updateBindings failed during reload: ${ + (errorOrEvent as { reason?: string })?.reason ?? "unknown" + }`, + { cause: errorOrEvent } + ); + } + await worker.raw.proxy.runtimeMessageMutex.drained(); + }; + + return { + ready: worker.ready, + remoteProxyConnectionString, + updateBindings, + dispose: worker.dispose, + }; +} + +export type RemoteProxySession = Pick & { + updateBindings: (bindings: StartDevWorkerInput["bindings"]) => Promise; + remoteProxyConnectionString: RemoteProxyConnectionString; +}; + +function toRawBindings(bindings: StartDevWorkerInput["bindings"]) { + return Object.fromEntries( + Object.entries(bindings ?? {}).map(([key, binding]) => [ + key, + { ...binding, raw: true }, + ]) + ); +} + +function getStartWorkerLogLevel(wranglerLogLevel: LoggerLevel): LoggerLevel { + switch (wranglerLogLevel) { + case "debug": + return "debug"; + case "none": + return "none"; + default: + return "error"; + } +} diff --git a/packages/remote-bindings/src/start-worker.ts b/packages/remote-bindings/src/start-worker.ts new file mode 100644 index 00000000000..7a21cd5ee11 --- /dev/null +++ b/packages/remote-bindings/src/start-worker.ts @@ -0,0 +1,19 @@ +import type { Binding, StartDevWorkerInput } from "@cloudflare/workers-utils"; +import type { EventEmitter } from "node:events"; + +export type Worker = { + ready: Promise; + url: Promise; + dispose(): Promise; + patchConfig(config: { bindings: Record }): Promise; + raw: EventEmitter & { + proxy: { + localServerReady: { promise: Promise }; + runtimeMessageMutex: { drained(): Promise }; + }; + }; +}; + +export function startWorker(_input: StartDevWorkerInput): Promise { + throw new Error("startWorker() is not implemented"); +} diff --git a/packages/wrangler/templates/remoteBindings/ProxyServerWorker.ts b/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts similarity index 100% rename from packages/wrangler/templates/remoteBindings/ProxyServerWorker.ts rename to packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts diff --git a/packages/wrangler/templates/remoteBindings/wrangler.jsonc b/packages/remote-bindings/templates/remoteBindings/wrangler.jsonc similarity index 100% rename from packages/wrangler/templates/remoteBindings/wrangler.jsonc rename to packages/remote-bindings/templates/remoteBindings/wrangler.jsonc diff --git a/packages/remote-bindings/tsconfig.json b/packages/remote-bindings/tsconfig.json index 43c3882de2e..9f7ff724058 100644 --- a/packages/remote-bindings/tsconfig.json +++ b/packages/remote-bindings/tsconfig.json @@ -1,4 +1,7 @@ { "extends": "@cloudflare/workers-tsconfig/base.json", - "include": ["src"] + "compilerOptions": { + "types": ["@cloudflare/workers-types/experimental", "@types/node"] + }, + "include": ["src", "templates"] } diff --git a/packages/remote-bindings/tsdown.config.ts b/packages/remote-bindings/tsdown.config.ts index 0c9545f0894..1e5e7730763 100644 --- a/packages/remote-bindings/tsdown.config.ts +++ b/packages/remote-bindings/tsdown.config.ts @@ -1,11 +1,23 @@ import { defineConfig } from "tsdown"; -export default defineConfig({ - entry: { - index: "src/index.ts", +export default defineConfig([ + { + entry: { + index: "src/index.ts", + }, + platform: "node", + outDir: "dist", + dts: true, + tsconfig: "tsconfig.json", + external: ["miniflare", /^@cloudflare\/workers-utils/], }, - platform: "node", - outDir: "dist", - dts: true, - tsconfig: "tsconfig.json", -}); + { + entry: { + "proxy-worker": "templates/remoteBindings/ProxyServerWorker.ts", + }, + platform: "neutral", + outDir: "dist", + dts: false, + external: ["cloudflare:email", "cloudflare:workers"], + }, +]); diff --git a/packages/wrangler/package.json b/packages/wrangler/package.json index 1a107d29129..7f981508d87 100644 --- a/packages/wrangler/package.json +++ b/packages/wrangler/package.json @@ -96,6 +96,7 @@ "@cloudflare/containers-shared": "workspace:*", "@cloudflare/deploy-helpers": "workspace:*", "@cloudflare/pages-shared": "workspace:^", + "@cloudflare/remote-bindings": "workspace:*", "@cloudflare/runtime-types": "workspace:*", "@cloudflare/types": "6.18.4", "@cloudflare/workers-auth": "workspace:*", diff --git a/packages/wrangler/src/api/remoteBindings/index.ts b/packages/wrangler/src/api/remoteBindings/index.ts index 1322bb88ff0..092cec0fd22 100644 --- a/packages/wrangler/src/api/remoteBindings/index.ts +++ b/packages/wrangler/src/api/remoteBindings/index.ts @@ -1,221 +1,43 @@ -import assert from "node:assert"; -import { createWranglerProfileStore } from "@cloudflare/workers-auth/wrangler"; -import { - getBindingLocalSupport, - getCloudflareComplianceRegion, -} from "@cloudflare/workers-utils"; +import { maybeStartOrUpdateRemoteProxySession as maybeStartOrUpdateRemoteProxySessionFromPackage } from "@cloudflare/remote-bindings"; +import { getCloudflareComplianceRegion } from "@cloudflare/workers-utils"; import { readConfig } from "../../config"; import { logger } from "../../logger"; -import { requireApiToken, requireAuth, setProfile } from "../../user"; import { convertConfigBindingsToStartWorkerBindings } from "../startDevWorker"; -import { startRemoteProxySession } from "./start-remote-proxy-session"; -import type { CfAccount } from "../../dev/create-worker-preview"; import type { - AsyncHook, - Binding, - StartDevWorkerInput, -} from "../startDevWorker/types"; -import type { RemoteProxySession } from "./start-remote-proxy-session"; -import type { Config } from "@cloudflare/workers-utils"; + RemoteProxySessionData, + WorkerConfigObject, +} from "@cloudflare/remote-bindings"; +import type { AsyncHook, CfAccount } from "@cloudflare/workers-utils"; -export * from "./start-remote-proxy-session"; - -export function pickRemoteBindings( - bindings: Record -): Record { - return Object.fromEntries( - Object.entries(bindings ?? {}).filter(([, binding]) => { - if ( - getBindingLocalSupport(binding.type) === - "DO-NOT-USE-this-resource-will-never-have-a-local-simulator" - ) { - return true; - } - return "remote" in binding && binding["remote"]; - }) - ); -} +export * from "@cloudflare/remote-bindings"; type WranglerConfigObject = { - /** The path to the wrangler config file */ path: string; - /** The target environment */ environment?: string; }; -type WorkerConfigObject = { - /** The name of the worker */ - name?: string; - /** The Worker's bindings */ - bindings: NonNullable; - /** If running in a non-public compliance region, set this here. */ - complianceRegion?: Config["compliance_region"]; - /** Id of the account owning the worker */ - account_id?: Config["account_id"]; - /** - * directory used to resolve the auth profile from directory bindings. - * Falls back to `process.cwd()` when not provided. - */ - profileDir?: string; -}; - -/** - * Utility for potentially starting or updating a remote proxy session. - * - * @param wranglerOrWorkerConfigObject either a file path to a wrangler configuration file or an object containing the name of - * the target worker alongside its bindings. - * @param preExistingRemoteProxySessionData the optional data of a pre-existing remote proxy session if there was one, this - * argument can be omitted or set to null if there is no pre-existing remote proxy session - * @param auth the authentication information for establishing the remote proxy connection - * @returns null if no existing remote proxy session was provided and one should not be created (because the worker is not - * defining any remote bindings), the data associated to the created/updated remote proxy session otherwise. - */ -export async function maybeStartOrUpdateRemoteProxySession( +export function maybeStartOrUpdateRemoteProxySession( wranglerOrWorkerConfigObject: WranglerConfigObject | WorkerConfigObject, - preExistingRemoteProxySessionData?: { - session: RemoteProxySession; - remoteBindings: Record; - auth?: AsyncHook | undefined; - } | null, - auth?: AsyncHook | undefined -): Promise<{ - session: RemoteProxySession; - remoteBindings: Record; - auth?: AsyncHook | undefined; -} | null> { - let config: Config | undefined; + preExistingRemoteProxySessionData?: RemoteProxySessionData | null, + auth?: AsyncHook +) { if ("path" in wranglerOrWorkerConfigObject) { - const wranglerConfigObject = wranglerOrWorkerConfigObject; - config = readConfig({ - config: wranglerConfigObject.path, - env: wranglerConfigObject.environment, + const config = readConfig({ + config: wranglerOrWorkerConfigObject.path, + env: wranglerOrWorkerConfigObject.environment, }); - wranglerOrWorkerConfigObject = { name: config.name ?? "worker", - complianceRegion: getCloudflareComplianceRegion(config), bindings: convertConfigBindingsToStartWorkerBindings(config) ?? {}, + complianceRegion: getCloudflareComplianceRegion(config), + account_id: config.account_id, }; } - const workerConfigObject = wranglerOrWorkerConfigObject; - - const remoteBindings = pickRemoteBindings(workerConfigObject.bindings); - - const authSameAsBefore = deepStrictEqual( + return maybeStartOrUpdateRemoteProxySessionFromPackage( + wranglerOrWorkerConfigObject, + preExistingRemoteProxySessionData, auth, - preExistingRemoteProxySessionData?.auth + { logger } ); - - let remoteProxySession = preExistingRemoteProxySessionData?.session; - - if (!authSameAsBefore) { - // The auth values have changed so we do need to restart a new remote proxy session - - if (preExistingRemoteProxySessionData?.session) { - await preExistingRemoteProxySessionData.session.dispose(); - } - - remoteProxySession = await startRemoteProxySession(remoteBindings, { - workerName: workerConfigObject.name, - complianceRegion: workerConfigObject.complianceRegion, - auth: getAuthHook( - auth, - workerConfigObject.account_id - ? { - account_id: workerConfigObject.account_id, - } - : config, - workerConfigObject.profileDir - ), - }); - } else { - // The auth values haven't changed so we can reuse the pre-existing session - - const remoteBindingsAreSameAsBefore = deepStrictEqual( - remoteBindings, - preExistingRemoteProxySessionData?.remoteBindings - ); - - // We only want to perform updates on the remote proxy session if the session's remote bindings have changed - if (!remoteBindingsAreSameAsBefore) { - if (!remoteProxySession) { - if (Object.keys(remoteBindings).length > 0) { - remoteProxySession = await startRemoteProxySession(remoteBindings, { - workerName: workerConfigObject.name, - complianceRegion: workerConfigObject.complianceRegion, - auth: getAuthHook( - auth, - workerConfigObject.account_id - ? { - account_id: workerConfigObject.account_id, - } - : config, - workerConfigObject.profileDir - ), - }); - } - } else { - // Note: we always call updateBindings even when there are zero remote bindings, in these - // cases we could terminate the remote session if we wanted, that's probably - // something to consider down the line - await remoteProxySession.updateBindings(remoteBindings); - } - } - } - - await remoteProxySession?.ready; - if (!remoteProxySession) { - return null; - } - return { - session: remoteProxySession, - remoteBindings, - auth, - }; -} - -/** - * Gets the auth hook to use for the remote proxy session, this is either the user provided auth - * hook if there is one, or an ad-hoc hook created using the account_id from the user's wrangler - * config file otherwise. - * - * @param auth the auth hook provided by the user if any - * @param config the user's wrangler config if any - * @param profileDir working directory used to resolve the auth profile from directory bindings, - * falls back to `process.cwd()` when not provided - * @returns the auth hook to pass to the startRemoteProxy session function if any - */ -function getAuthHook( - auth: AsyncHook | undefined, - config: Pick | undefined, - profileDir: string | undefined -): AsyncHook | undefined { - const profile = createWranglerProfileStore({ logger }).resolve({ - cwd: profileDir ?? process.cwd(), - }); - setProfile(profile); - if (auth) { - return auth; - } - - if (config?.account_id) { - return async () => { - return { - accountId: await requireAuth(config), - apiToken: requireApiToken(), - }; - }; - } - - return undefined; -} - -function deepStrictEqual(source: unknown, target: unknown): boolean { - try { - assert.deepStrictEqual(source, target); - return true; - } catch { - return false; - } } diff --git a/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts b/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts index c0444802e35..52ecde205cd 100644 --- a/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts +++ b/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts @@ -1,247 +1 @@ -import events from "node:events"; -import path from "node:path"; -import { UserError } from "@cloudflare/workers-utils"; -import chalk from "chalk"; -import { DeferredPromise } from "miniflare"; -import remoteBindingsWorkerPath from "worker:remoteBindings/ProxyServerWorker"; -import { RemoteSessionAuthenticationError } from "../../dev/remote"; -import { logger } from "../../logger"; -import { getBasePath } from "../../paths"; -import { startWorker } from "../startDevWorker"; -import type { LoggerLevel } from "../../logger"; -import type { StartDevWorkerInput, Worker } from "../startDevWorker"; -import type { ErrorEvent } from "../startDevWorker/events"; -import type { Config } from "@cloudflare/workers-utils"; -import type { RemoteProxyConnectionString } from "miniflare"; - -export type StartRemoteProxySessionOptions = { - workerName?: string; - auth?: NonNullable["auth"]; - /** If running in a non-public compliance region, set this here. */ - complianceRegion?: Config["compliance_region"]; -}; - -function isErrorEvent(error: unknown): error is ErrorEvent { - return ( - typeof error === "object" && - error !== null && - "type" in error && - (error as { type?: string }).type === "error" && - "reason" in error && - "cause" in error - ); -} - -function getErrorMessage(error: unknown): string | undefined { - if (error instanceof Error) { - return getErrorMessage(error.cause) ?? error.message; - } - - if (typeof error === "string") { - return error; - } - - if (typeof error === "object" && error !== null) { - const maybeMessage = (error as { message?: unknown }).message; - if (typeof maybeMessage === "string") { - const maybeCause = (error as { cause?: unknown }).cause; - return getErrorMessage(maybeCause) ?? maybeMessage; - } - } - - return undefined; -} - -/** - * Walks the cause chain of an error (including {@link ErrorEvent} wrappers) - * looking for a {@link RemoteSessionAuthenticationError}. - * - * @param error - the error or ErrorEvent to inspect - * @returns the first {@link RemoteSessionAuthenticationError} found, or - * `undefined` if none exists in the chain - */ -function findRemoteSessionAuthError( - error: unknown -): RemoteSessionAuthenticationError | undefined { - if (error instanceof RemoteSessionAuthenticationError) { - return error; - } - - if (isErrorEvent(error) || (error instanceof Error && error.cause)) { - return findRemoteSessionAuthError(error.cause); - } - - return undefined; -} - -function formatRemoteProxySessionError(error: unknown): string | undefined { - if (isErrorEvent(error)) { - const causeMessage = getErrorMessage(error.cause); - return causeMessage ? `${error.reason}: ${causeMessage}` : error.reason; - } - - return getErrorMessage(error); -} - -export async function startRemoteProxySession( - bindings: StartDevWorkerInput["bindings"], - options?: StartRemoteProxySessionOptions -): Promise { - logger.log(chalk.dim("⎔ Establishing remote connection...")); - // Transform all bindings to use "raw" mode - const rawBindings = Object.fromEntries( - Object.entries(bindings ?? {}).map(([key, binding]) => [ - key, - { ...binding, raw: true }, - ]) - ); - - const proxyServerWorkerWranglerConfig = path.resolve( - getBasePath(), - "templates/remoteBindings/wrangler.jsonc" - ); - - const worker = await startWorker({ - name: options?.workerName, - entrypoint: remoteBindingsWorkerPath, - config: proxyServerWorkerWranglerConfig, - compatibilityDate: "2025-04-28", - dev: { - remote: "minimal", - auth: options?.auth, - server: { - port: 0, - }, - inspector: false, - logLevel: getStartWorkerLogLevel(logger.loggerLevel), - }, - bindings: rawBindings, - }).catch((startWorkerError) => { - // If the error is already a UserError (e.g. an auth failure from - // ConfigController), re-throw it directly so the top-level error - // handler can display the original, actionable message without - // wrapping it in a generic "Failed to start" envelope. - if (startWorkerError instanceof UserError) { - throw startWorkerError; - } - let errorMessage = startWorkerError; - if (startWorkerError instanceof Error) { - if (startWorkerError.cause instanceof Error) { - errorMessage = startWorkerError.cause.message; - } else { - errorMessage = startWorkerError.message; - } - } - throw new Error( - `Failed to start the remote proxy session, see the error details below:\n\n${errorMessage}` - ); - }); - - const maybeErrorPromise = new DeferredPromise<{ error: unknown }>(); - - worker.raw.addListener("error", (e) => - maybeErrorPromise.resolve({ error: e }) - ); - - const maybeError = await Promise.race([ - maybeErrorPromise, - worker.raw.proxy.localServerReady.promise, - ]); - - if (maybeError && maybeError.error) { - const authError = findRemoteSessionAuthError(maybeError.error); - if (authError) { - throw authError; - } - - const details = formatRemoteProxySessionError(maybeError.error); - throw new Error( - details - ? `Failed to start the remote proxy session. ${details}` - : "Failed to start the remote proxy session. There is likely additional logging output above.", - { - cause: maybeError.error, - } - ); - } - - const remoteProxyConnectionString = - (await worker.url) as RemoteProxyConnectionString; - - const updateBindings = async ( - newBindings: StartDevWorkerInput["bindings"] - ) => { - // Transform all new bindings to use "raw" mode - const rawNewBindings = Object.fromEntries( - Object.entries(newBindings ?? {}).map(([key, binding]) => [ - key, - { ...binding, raw: true }, - ]) - ); - - // `worker.patchConfig` returns as soon as the config update is dispatched - // — long before the remote worker has actually been re-uploaded with the - // new bindings and the local proxy worker has unpaused. If we returned - // here, callers issuing requests immediately afterwards would race the - // reload window, often surfacing as "WebSocket connection failed" for - // JSRPC bindings. - // - // Subscribe BEFORE patchConfig so we don't miss either event. - // `events.once()` resolves on `reloadComplete` and rejects if `error` - // is emitted first (with the event payload as the rejection value). - const reloadComplete = events.once(worker.raw, "reloadComplete"); - await worker.patchConfig({ bindings: rawNewBindings }); - try { - await reloadComplete; - } catch (errOrEvent) { - throw errOrEvent instanceof Error - ? errOrEvent - : new Error( - `RemoteProxySession.updateBindings failed during reload: ${ - (errOrEvent as { reason?: string })?.reason ?? "unknown" - }`, - { cause: errOrEvent } - ); - } - // The "play" message that resumes the local proxy worker is enqueued on - // this mutex during onReloadComplete. Wait for it to drain so the proxy - // actually unpauses before we return — matches what `worker.fetch` does. - await worker.raw.proxy.runtimeMessageMutex.drained(); - }; - - return { - ready: worker.ready, - remoteProxyConnectionString, - updateBindings, - dispose: worker.dispose, - }; -} - -export type RemoteProxySession = Pick & { - updateBindings: (bindings: StartDevWorkerInput["bindings"]) => Promise; - remoteProxyConnectionString: RemoteProxyConnectionString; -}; - -/** - * Gets the log level to use for the remote worker. - * - * @param wranglerLogLevel The log level set for the Wrangler process. - * @returns The log level to use for the remove worker. - */ -function getStartWorkerLogLevel(wranglerLogLevel: LoggerLevel): LoggerLevel { - switch (wranglerLogLevel) { - case "debug": - // If the `logLevel` is "debug" it means that the user is likely trying to debug some issue, - // so we should respect that here as well for the remote proxy session. - return "debug"; - - case "none": - // If the `logLevel` is "none" it means that the user is trying to silence all output, - // so we should respect that here as well for the remote proxy session. - return "none"; - - default: - // In any other case we want to default to "error" to avoid noisy logs - return "error"; - } -} +export * from "@cloudflare/remote-bindings"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fd52975883..a96643317f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2559,15 +2559,39 @@ importers: packages/remote-bindings: devDependencies: + '@cloudflare/cli-shared-helpers': + specifier: workspace:* + version: link:../cli + '@cloudflare/workers-auth': + specifier: workspace:* + version: link:../workers-auth '@cloudflare/workers-tsconfig': specifier: workspace:* version: link:../workers-tsconfig + '@cloudflare/workers-types': + specifier: catalog:default + version: 5.20260710.1 + '@cloudflare/workers-utils': + specifier: workspace:* + version: link:../workers-utils + capnweb: + specifier: catalog:default + version: 0.5.0 + chalk: + specifier: catalog:default + version: 5.3.0 + miniflare: + specifier: workspace:* + version: link:../miniflare tsdown: specifier: 0.16.3 version: 0.16.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3) typescript: specifier: catalog:default version: 5.8.3 + vitest: + specifier: catalog:default + version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.15.17)(@vitest/ui@4.1.0)(msw@2.12.4(@types/node@22.15.17)(typescript@5.8.3))(vite@8.0.13(@types/node@22.15.17)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.1)) packages/runtime-types: dependencies: @@ -4375,6 +4399,9 @@ importers: '@cloudflare/pages-shared': specifier: workspace:^ version: link:../pages-shared + '@cloudflare/remote-bindings': + specifier: workspace:* + version: link:../remote-bindings '@cloudflare/runtime-types': specifier: workspace:* version: link:../runtime-types From 96fb6c9b4fb2e738786ed541039f2fd381022ebb Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Wed, 15 Jul 2026 11:20:00 +0100 Subject: [PATCH 03/37] snapshot copy of DevEnv --- packages/remote-bindings/package.json | 2 + packages/remote-bindings/src/start-worker.ts | 9 +- .../src/startDevWorker/BaseController.ts | 81 ++ .../src/startDevWorker/BundlerController.ts | 479 +++++++++++ .../src/startDevWorker/ConfigController.ts | 775 ++++++++++++++++++ .../src/startDevWorker/DevEnv.ts | 275 +++++++ .../src/startDevWorker/NoOpProxyController.ts | 14 + .../src/startDevWorker/NotImplementedError.ts | 19 + .../src/startDevWorker/ProxyController.ts | 684 ++++++++++++++++ .../startDevWorker/RemoteRuntimeController.ts | 517 ++++++++++++ .../src/startDevWorker/binding-utils.ts | 54 ++ .../startDevWorker/bundle-allowed-paths.ts | 115 +++ .../src/startDevWorker/devtools.ts | 13 + .../src/startDevWorker/events.ts | 160 ++++ .../src/startDevWorker/index.ts | 16 + .../src/startDevWorker/types.ts | 106 +++ .../src/startDevWorker/utils.ts | 391 +++++++++ pnpm-lock.yaml | 6 + 18 files changed, 3714 insertions(+), 2 deletions(-) create mode 100644 packages/remote-bindings/src/startDevWorker/BaseController.ts create mode 100644 packages/remote-bindings/src/startDevWorker/BundlerController.ts create mode 100644 packages/remote-bindings/src/startDevWorker/ConfigController.ts create mode 100644 packages/remote-bindings/src/startDevWorker/DevEnv.ts create mode 100644 packages/remote-bindings/src/startDevWorker/NoOpProxyController.ts create mode 100644 packages/remote-bindings/src/startDevWorker/NotImplementedError.ts create mode 100644 packages/remote-bindings/src/startDevWorker/ProxyController.ts create mode 100644 packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts create mode 100644 packages/remote-bindings/src/startDevWorker/binding-utils.ts create mode 100644 packages/remote-bindings/src/startDevWorker/bundle-allowed-paths.ts create mode 100644 packages/remote-bindings/src/startDevWorker/devtools.ts create mode 100644 packages/remote-bindings/src/startDevWorker/events.ts create mode 100644 packages/remote-bindings/src/startDevWorker/index.ts create mode 100644 packages/remote-bindings/src/startDevWorker/types.ts create mode 100644 packages/remote-bindings/src/startDevWorker/utils.ts diff --git a/packages/remote-bindings/package.json b/packages/remote-bindings/package.json index 4c404219d91..76e328cdb14 100644 --- a/packages/remote-bindings/package.json +++ b/packages/remote-bindings/package.json @@ -33,6 +33,8 @@ }, "devDependencies": { "@cloudflare/cli-shared-helpers": "workspace:*", + "@cloudflare/containers-shared": "workspace:*", + "@cloudflare/deploy-helpers": "workspace:*", "@cloudflare/workers-auth": "workspace:*", "@cloudflare/workers-tsconfig": "workspace:*", "@cloudflare/workers-types": "catalog:default", diff --git a/packages/remote-bindings/src/start-worker.ts b/packages/remote-bindings/src/start-worker.ts index 7a21cd5ee11..c197483fd3b 100644 --- a/packages/remote-bindings/src/start-worker.ts +++ b/packages/remote-bindings/src/start-worker.ts @@ -1,3 +1,4 @@ +import { DevEnv } from "./startDevWorker"; import type { Binding, StartDevWorkerInput } from "@cloudflare/workers-utils"; import type { EventEmitter } from "node:events"; @@ -14,6 +15,10 @@ export type Worker = { }; }; -export function startWorker(_input: StartDevWorkerInput): Promise { - throw new Error("startWorker() is not implemented"); +export async function startWorker( + options: StartDevWorkerInput +): Promise { + const devEnv = new DevEnv(); + + return devEnv.startWorker(options); } diff --git a/packages/remote-bindings/src/startDevWorker/BaseController.ts b/packages/remote-bindings/src/startDevWorker/BaseController.ts new file mode 100644 index 00000000000..a0fbae6b63d --- /dev/null +++ b/packages/remote-bindings/src/startDevWorker/BaseController.ts @@ -0,0 +1,81 @@ +import { logger } from "../../logger"; +import type { + BundleCompleteEvent, + BundleStartEvent, + ConfigUpdateEvent, + DevRegistryUpdateEvent, + ErrorEvent, + PreviewTokenExpiredEvent, + ReloadCompleteEvent, + ReloadStartEvent, +} from "./events"; +import type { Miniflare } from "miniflare"; + +export type ControllerEvent = + | ErrorEvent + | ConfigUpdateEvent + | BundleStartEvent + | BundleCompleteEvent + | ReloadStartEvent + | ReloadCompleteEvent + | DevRegistryUpdateEvent + | PreviewTokenExpiredEvent; + +export interface ControllerBus { + dispatch(event: ControllerEvent): void; +} + +export abstract class Controller { + protected bus: ControllerBus; + #tearingDown = false; + + constructor(bus: ControllerBus) { + this.bus = bus; + } + + async teardown(): Promise { + this.#tearingDown = true; + } + + protected emitErrorEvent(event: ErrorEvent) { + if (this.#tearingDown) { + logger.debug("Suppressing error event during teardown"); + logger.debug(`Error in ${event.source}: ${event.reason}\n`, event.cause); + logger.debug("=> Error contextual data:", event.data); + return; + } + + this.bus.dispatch(event); + } +} + +export abstract class RuntimeController extends Controller { + // ****************** + // Event Handlers + // ****************** + + abstract onBundleStart(_: BundleStartEvent): void; + abstract onBundleComplete(_: BundleCompleteEvent): void; + abstract onPreviewTokenExpired(_: PreviewTokenExpiredEvent): void; + + // ********************* + // Runtime Accessors + // ********************* + abstract get mf(): Miniflare | undefined; + + // ********************* + // Event Dispatchers + // ********************* + + protected emitReloadStartEvent(data: ReloadStartEvent): void { + this.bus.dispatch(data); + } + + protected emitReloadCompleteEvent(data: ReloadCompleteEvent): void { + this.bus.dispatch(data); + } + + protected emitDevRegistryUpdateEvent(data: DevRegistryUpdateEvent): void { + this.bus.dispatch(data); + } +} diff --git a/packages/remote-bindings/src/startDevWorker/BundlerController.ts b/packages/remote-bindings/src/startDevWorker/BundlerController.ts new file mode 100644 index 00000000000..448bcd34807 --- /dev/null +++ b/packages/remote-bindings/src/startDevWorker/BundlerController.ts @@ -0,0 +1,479 @@ +import assert from "node:assert"; +import { readFileSync, realpathSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { extractBindingsOfType } from "@cloudflare/deploy-helpers"; +import { getWranglerTmpDir } from "@cloudflare/workers-utils"; +import { watch } from "chokidar"; +import { BuildFailure } from "../../deployment-bundle/build-failures"; +import { bundleWorker, shouldCheckFetch } from "../../deployment-bundle/bundle"; +import { getBundleType } from "../../deployment-bundle/bundle-type"; +import { + createModuleCollector, + getWrangler1xLegacyModuleReferences, +} from "../../deployment-bundle/module-collection"; +import { noBundleWorker } from "../../deployment-bundle/no-bundle-worker"; +import { runCustomBuild } from "../../deployment-bundle/run-custom-build"; +import { getAssetChangeMessage } from "../../dev"; +import { runBuild } from "../../dev/use-esbuild"; +import { logger } from "../../logger"; +import { isNavigatorDefined } from "../../navigator-user-agent"; +import { debounce } from "../../utils/debounce"; +import { isAbortError } from "../../utils/isAbortError"; +import { Controller } from "./BaseController"; +import { castErrorCause } from "./events"; +import type { BundleResult } from "../../deployment-bundle/bundle"; +import type { EsbuildBundle } from "../../dev/use-esbuild"; +import type { ConfigUpdateEvent } from "./events"; +import type { StartDevWorkerOptions } from "./types"; +import type { EphemeralDirectory, Entry } from "@cloudflare/workers-utils"; + +export class BundlerController extends Controller { + #currentBundle?: EsbuildBundle; + + #customBuildWatcher?: ReturnType; + + // Handle aborting in-flight custom builds as new ones come in from the filesystem watcher + #customBuildAborter = new AbortController(); + #activeCustomBuilds = new Set>(); + + #startCustomBuildRun(config: StartDevWorkerOptions, filePath: string) { + const buildPromise = this.#runCustomBuild(config, filePath); + this.#activeCustomBuilds.add(buildPromise); + void buildPromise + .finally(() => this.#activeCustomBuilds.delete(buildPromise)) + .catch(() => {}); + return buildPromise; + } + + async #runCustomBuild(config: StartDevWorkerOptions, filePath: string) { + // If a new custom build comes in, we need to cancel in-flight builds + this.#customBuildAborter.abort(); + this.#customBuildAborter = new AbortController(); + + // Since `this.#customBuildAborter` will change as new builds are scheduled, store the specific AbortController that will be used for this build + const buildAborter = this.#customBuildAborter; + const relativeFile = + path.relative(config.projectRoot, config.entrypoint) || "."; + logger.log(`The file ${filePath} changed, restarting build...`); + this.emitBundleStartEvent(config); + try { + await runCustomBuild( + config.entrypoint, + relativeFile, + { + cwd: config.build?.custom?.workingDirectory, + command: config.build?.custom?.command, + }, + config.config, + { wranglerCommand: "dev", signal: buildAborter.signal } + ); + if (buildAborter.signal.aborted) { + return; + } + assert(this.#tmpDir); + if (!config.build?.bundle) { + // if we're not bundling, let's just copy the entry to the destination directory + const destinationDir = this.#tmpDir.path; + writeFileSync( + path.join(destinationDir, path.basename(config.entrypoint)), + readFileSync(config.entrypoint, "utf-8") + ); + } + + const entry: Entry = { + file: config.entrypoint, + projectRoot: config.projectRoot, + configPath: config.config, + format: config.build.format, + moduleRoot: config.build.moduleRoot, + exports: config.build.exports, + }; + + const entryDirectory = path.dirname(config.entrypoint); + const moduleCollector = createModuleCollector({ + wrangler1xLegacyModuleReferences: getWrangler1xLegacyModuleReferences( + entryDirectory, + config.entrypoint + ), + entry, + // `moduleCollector` doesn't get used when `noBundle` is set, so + // `findAdditionalModules` always defaults to `false` + findAdditionalModules: config.build.findAdditionalModules ?? false, + rules: config.build.moduleRules, + }); + + const doBindings = extractBindingsOfType( + "durable_object_namespace", + config.bindings + ); + const workflowBindings = extractBindingsOfType( + "workflow", + config.bindings + ); + const bundleResult: Omit = !config.build?.bundle + ? await noBundleWorker( + entry, + config.build.moduleRules, + this.#tmpDir.path, + config.pythonModules?.exclude ?? [], + config.build.findAdditionalModules !== false + ) + : await bundleWorker(entry, this.#tmpDir.path, { + bundle: true, + additionalModules: [], + moduleCollector, + doBindings, + workflowBindings, + jsxFactory: config.build.jsxFactory, + jsxFragment: config.build.jsxFactory, + tsconfig: config.build.tsconfig, + minify: config.build.minify, + keepNames: config.build.keepNames ?? true, + nodejsCompatMode: config.build.nodejsCompatMode, + compatibilityDate: config.compatibilityDate, + compatibilityFlags: config.compatibilityFlags, + define: config.build.define, + checkFetch: shouldCheckFetch( + config.compatibilityDate, + config.compatibilityFlags + ), + alias: config.build.alias, + // We want to know if the build is for development or publishing + // This could potentially cause issues as we no longer have identical behaviour between dev and deploy? + targetConsumer: "dev", + local: !config.dev?.remote, + projectRoot: config.projectRoot, + defineNavigatorUserAgent: isNavigatorDefined( + config.compatibilityDate, + config.compatibilityFlags + ), + testScheduled: config.dev.testScheduled, + plugins: undefined, + + // Pages specific options used by wrangler pages commands + entryName: undefined, + inject: undefined, + isOutfile: undefined, + external: undefined, + + // We don't use esbuild watching for custom builds + watch: undefined, + + // sourcemap defaults to true in dev + sourcemap: undefined, + + metafile: undefined, + }); + if (buildAborter.signal.aborted) { + return; + } + const entrypointPath = realpathSync( + bundleResult?.resolvedEntryPointPath ?? config.entrypoint + ); + + this.emitBundleCompleteEvent(config, { + id: 0, + entry, + path: entrypointPath, + type: + bundleResult?.bundleType ?? + getBundleType(config.build.format, config.entrypoint), + modules: bundleResult.modules, + dependencies: bundleResult?.dependencies ?? {}, + sourceMapPath: bundleResult?.sourceMapPath, + sourceMapMetadata: bundleResult?.sourceMapMetadata, + entrypointSource: readFileSync(entrypointPath, "utf8"), + }); + } catch (err) { + if (buildAborter.signal.aborted || isAbortError(err)) { + return; + } + this.emitErrorEvent({ + type: "error", + reason: "Custom build failed", + cause: castErrorCause(err), + source: "BundlerController", + data: { config, filePath }, + }); + } + } + + async #startCustomBuild(config: StartDevWorkerOptions) { + await this.#customBuildWatcher?.close(); + this.#customBuildWatcher = undefined; + this.#customBuildAborter?.abort(); + + if (!config.build?.custom?.command) { + return; + } + + const pathsToWatch = config.build.custom.watch; + + // This is always present if a custom command is provided, defaulting to `./src` + assert(pathsToWatch, "config.build.custom.watch"); + + if (config.dev.watch === false) { + await this.#startCustomBuildRun(config, String(pathsToWatch)); + return; + } + + this.#customBuildWatcher = watch(pathsToWatch, { + persistent: true, + // The initial custom build is always done in getEntry() + ignoreInitial: true, + }); + this.#customBuildWatcher.on("ready", () => { + void this.#startCustomBuildRun(config, String(pathsToWatch)); + }); + + this.#customBuildWatcher.on( + "all", + (_event, filePath) => void this.#startCustomBuildRun(config, filePath) + ); + } + + #bundlerCleanup?: ReturnType; + #bundleBuildAborter = new AbortController(); + + async #startBundle(config: StartDevWorkerOptions) { + await this.#bundlerCleanup?.(); + // If a new bundle build comes in, we need to cancel in-flight builds + this.#bundleBuildAborter.abort(); + this.#bundleBuildAborter = new AbortController(); + + // Since `this.#customBuildAborter` will change as new builds are scheduled, store the specific AbortController that will be used for this build + const buildAborter = this.#bundleBuildAborter; + + if (config.build?.custom?.command) { + return; + } + assert(this.#tmpDir); + const entry: Entry = { + file: config.entrypoint, + projectRoot: config.projectRoot, + configPath: config.config, + format: config.build.format, + moduleRoot: config.build.moduleRoot, + exports: config.build.exports, + name: config.name, + }; + + const durableObjects = { + bindings: extractBindingsOfType( + "durable_object_namespace", + config.bindings + ), + }; + const workflows = extractBindingsOfType("workflow", config.bindings); + + this.#bundlerCleanup = runBuild( + { + entry, + destination: this.#tmpDir.path, + jsxFactory: config.build?.jsxFactory, + jsxFragment: config.build?.jsxFragment, + processEntrypoint: Boolean(config.build?.processEntrypoint), + additionalModules: config.build?.additionalModules ?? [], + rules: config.build.moduleRules, + tsconfig: config.build?.tsconfig, + minify: config.build?.minify, + keepNames: config.build?.keepNames ?? true, + nodejsCompatMode: config.build.nodejsCompatMode, + compatibilityDate: config.compatibilityDate, + compatibilityFlags: config.compatibilityFlags, + define: config.build.define, + alias: config.build.alias, + noBundle: !config.build?.bundle, + findAdditionalModules: config.build?.findAdditionalModules, + durableObjects, + workflows, + local: !config.dev?.remote, + // startDevWorker only applies to "dev" + targetConsumer: "dev", + testScheduled: Boolean(config.dev?.testScheduled), + projectRoot: config.projectRoot, + onStart: () => { + this.emitBundleStartEvent(config); + }, + onRebuildError: (errors, warnings) => { + if (!buildAborter.signal.aborted) { + // Watch-mode rebuild failures route through the same error + // path as initial-build failures, so DevEnv logs them + // (logBuildFailure) and emits `buildFailed` symmetrically. + this.emitErrorEvent({ + type: "error", + reason: "Failed to rebuild the Worker", + cause: new BuildFailure( + `Build failed with ${errors.length} error(s)`, + errors, + warnings + ), + source: "BundlerController", + data: undefined, + }); + } + }, + checkFetch: shouldCheckFetch( + config.compatibilityDate, + config.compatibilityFlags + ), + watch: config.dev.watch ?? true, + defineNavigatorUserAgent: isNavigatorDefined( + config.compatibilityDate, + config.compatibilityFlags + ), + pythonModulesExcludes: config.pythonModules?.exclude ?? [], + }, + (cb) => { + const newBundle = cb(this.#currentBundle); + if (!buildAborter.signal.aborted) { + this.emitBundleCompleteEvent(config, newBundle); + this.#currentBundle = newBundle; + } + }, + (err) => { + if (!buildAborter.signal.aborted) { + this.emitErrorEvent({ + type: "error", + reason: "Failed to construct initial bundle", + cause: castErrorCause(err), + source: "BundlerController", + data: undefined, + }); + } + } + ); + } + + #assetsWatcher?: ReturnType; + async #ensureWatchingAssets(config: StartDevWorkerOptions) { + await this.#assetsWatcher?.close(); + this.#assetsWatcher = undefined; + + const debouncedRefreshBundle = debounce(() => { + if (this.#currentBundle) { + this.emitBundleCompleteEvent(config, this.#currentBundle); + } + }); + + if (config.dev.watch !== false && config.assets?.directory) { + const assetsDir = config.assets.directory; + const watcher = watch(assetsDir, { + persistent: true, + ignoreInitial: true, + }) + .on("all", async (eventName, filePath) => { + const message = getAssetChangeMessage(eventName, filePath); + logger.debug(`🌀 ${message}...`); + debouncedRefreshBundle(); + }) + .on("error", (err) => { + const errnoError = err as NodeJS.ErrnoException; + if (errnoError.code === "EMFILE") { + logger.warn( + `Assets directory watcher hit a platform limit and has been disabled.\n` + + `Hot-reloading will not reflect changes to files in ${assetsDir}.\n` + + `This can occur when watching very large assets directory trees.\n` + + `To work around this, reduce the number of subdirectories under ${assetsDir} by flattening or restructuring the assets directory.` + ); + } else { + logger.warn( + `Assets directory watcher encountered an error and has been disabled.\n` + + `Hot-reloading will not reflect changes to files in ${assetsDir}.\n` + + `Watcher error: ${err.message}` + ); + } + void watcher.close(); + if (this.#assetsWatcher === watcher) { + this.#assetsWatcher = undefined; + } + }); + this.#assetsWatcher = watcher; + } + } + + #tmpDir?: EphemeralDirectory; + + onConfigUpdate(event: ConfigUpdateEvent) { + this.#tmpDir?.remove(); + try { + this.#tmpDir = getWranglerTmpDir(event.config.projectRoot, "dev"); + } catch (e) { + this.emitErrorEvent({ + type: "error", + reason: "Failed to create temporary directory to store built files.", + cause: castErrorCause(e), + source: "BundlerController", + data: undefined, + }); + } + + void this.#startCustomBuild(event.config).catch((err) => { + this.emitErrorEvent({ + type: "error", + reason: "Failed to run custom build", + cause: castErrorCause(err), + source: "BundlerController", + data: { config: event.config }, + }); + }); + void this.#startBundle(event.config).catch((err) => { + this.emitErrorEvent({ + type: "error", + reason: "Failed to start bundler", + cause: castErrorCause(err), + source: "BundlerController", + data: { config: event.config }, + }); + }); + void this.#ensureWatchingAssets(event.config).catch((err) => { + this.emitErrorEvent({ + type: "error", + reason: "Failed to watch assets", + cause: castErrorCause(err), + source: "BundlerController", + data: { config: event.config }, + }); + }); + } + + override async teardown() { + logger.debug("BundlerController teardown beginning..."); + await super.teardown(); + this.#customBuildAborter?.abort(); + const activeCustomBuilds = Array.from(this.#activeCustomBuilds, (build) => + build.catch(() => {}) + ); + // Abort any in-flight esbuild build so that a finishing build doesn't + // emit `bundleComplete`/`bundleStart` into a torn-down event bus. + // `Controller.#tearingDown` already suppresses error events, but not + // the bundler success events, which go straight through `bus.dispatch`. + this.#bundleBuildAborter?.abort(); + await Promise.all([ + // Must run before `#tmpDir.remove()` so that the esbuild watcher + // can dispose cleanly. Removing the directory first would make + // esbuild's watcher fail a rebuild with "Could not resolve + // ...middleware-loader.entry.ts" during teardown. + this.#bundlerCleanup?.(), + this.#customBuildWatcher?.close(), + ...activeCustomBuilds, + this.#assetsWatcher?.close(), + ]); + // Defence-in-depth: `bundle.ts`'s `stop()` normally removes the tmp + // dir on our behalf, but it may have never been assigned (e.g. when + // running a custom build, or when the initial build threw). Remove + // after esbuild cleanup to avoid the race described above. + this.#tmpDir?.remove(); + logger.debug("BundlerController teardown complete"); + } + + emitBundleStartEvent(config: StartDevWorkerOptions) { + this.bus.dispatch({ type: "bundleStart", config }); + } + emitBundleCompleteEvent( + config: StartDevWorkerOptions, + bundle: EsbuildBundle + ) { + this.bus.dispatch({ type: "bundleComplete", config, bundle }); + } +} diff --git a/packages/remote-bindings/src/startDevWorker/ConfigController.ts b/packages/remote-bindings/src/startDevWorker/ConfigController.ts new file mode 100644 index 00000000000..06284ffd5f8 --- /dev/null +++ b/packages/remote-bindings/src/startDevWorker/ConfigController.ts @@ -0,0 +1,775 @@ +import assert from "node:assert"; +import path from "node:path"; +import { resolveDockerHost } from "@cloudflare/containers-shared"; +import { extractBindingsOfType } from "@cloudflare/deploy-helpers"; +import { + configFileName, + formatConfigSnippet, + getTodaysCompatDate, + getDisableConfigWatching, + getDockerPath, + UserError, +} from "@cloudflare/workers-utils"; +import { watch } from "chokidar"; +import { getWorkerRegistry } from "miniflare"; +import { getAssetsOptions, validateAssetsArgsAndConfig } from "../../assets"; +import { fillOpenAPIConfiguration } from "../../cloudchamber/common"; +import { readConfig, readNewConfig } from "../../config"; +import { containersScope } from "../../containers"; +import { getNormalizedContainerOptions } from "../../containers/config"; +import { getEntry } from "../../deployment-bundle/entry"; +import { getBindings, getHostAndRoutes, getInferredHost } from "../../dev"; +import { getDurableObjectClassNameToUseSQLiteMap } from "../../dev/class-names-sqlite"; +import { getLocalPersistencePath } from "../../dev/get-local-persistence-path"; +import { getFlag } from "../../experimental-flags"; +import { logger, runWithLogLevel } from "../../logger"; +import { checkTypesDiff } from "../../type-generation/helpers"; +import { regenerateNewConfigTypes } from "../../type-generation/new-config"; +import { + loginOrRefreshIfRequired, + requireApiToken, + requireAuth, +} from "../../user"; +import { + DEFAULT_INSPECTOR_PORT, + DEFAULT_LOCAL_PORT, +} from "../../utils/constants"; +import { getRules } from "../../utils/getRules"; +import { getScriptName } from "../../utils/getScriptName"; +import { memoizeGetPort } from "../../utils/memoizeGetPort"; +import { printBindings } from "../../utils/print-bindings"; +import { getZoneIdForPreview } from "../../zones"; +import { Controller } from "./BaseController"; +import { castErrorCause } from "./events"; +import { unwrapHook } from "./utils"; +import type { NewConfig, ReadConfigCommandArgs } from "../../config"; +import type { DevRegistryUpdateEvent } from "./events"; +import type { + StartDevWorkerInput, + StartDevWorkerOptions, + Trigger, + WranglerStartDevWorkerInput, +} from "./types"; +import type { LoginOrRefreshFailureReason } from "@cloudflare/workers-auth"; +import type { CfUnsafe, Config } from "@cloudflare/workers-utils"; +import type { WorkerRegistry } from "miniflare"; + +const getInspectorPort = memoizeGetPort(DEFAULT_INSPECTOR_PORT, "127.0.0.1"); +const getLocalPort = memoizeGetPort(DEFAULT_LOCAL_PORT, "localhost"); + +async function resolveInspectorConfig( + config: Config, + input: WranglerStartDevWorkerInput +): Promise { + if (input.dev?.inspector === false) { + return false; + } + const hostname = + input.dev?.inspector?.hostname ?? config.dev.inspector_ip ?? "127.0.0.1"; + const port = + input.dev?.inspector?.port ?? + config.dev.inspector_port ?? + (await getInspectorPort(hostname)); + return { + hostname, + port, + }; +} + +async function resolveDevConfig( + config: Config, + input: WranglerStartDevWorkerInput +): Promise { + const auth = async () => { + if (input.dev?.remote) { + const result = await loginOrRefreshIfRequired(config); + if (!result.loggedIn) { + const errorMessage = getLoginOrRefreshFailureErrorMessage( + input.dev.remote, + result.reason + ); + throw new UserError(errorMessage, { + telemetryMessage: "api dev remote login required", + }); + } + } + + if (input.dev?.auth) { + return unwrapHook(input.dev.auth, config); + } + + return { + accountId: await requireAuth(config), + apiToken: requireApiToken(), + }; + }; + + const localPersistencePath = getLocalPersistencePath( + input.dev?.persist, + config + ); + + const { host, routes } = await getHostAndRoutes( + { + host: input.dev?.origin?.hostname, + routes: input.triggers?.filter( + (t): t is Extract => t.type === "route" + ), + assets: input?.assets, + }, + config + ); + + // TODO: Remove this hack once the React flow is removed + // This function throws if the zone ID can't be found given the provided host and routes + // However, it's called as part of initialising a preview session, which is nested deep within + // React/Ink and useEffect()s in `--no-x-dev-env` mode which swallow the error and turn it into a logged warning. + // Because it's a non-recoverable user error, we want it to exit the Wrangler process early to allow the user to fix it. + // Calling it here forces the error to be thrown where it will correctly exit the Wrangler process. + if (input.dev?.remote) { + const { accountId } = await auth(); + assert(accountId, "Account ID must be provided for remote dev"); + await getZoneIdForPreview(config, { host, routes, accountId }); + } + + const initialIp = input.dev?.server?.hostname ?? config.dev.ip; + + const initialIpListenCheck = initialIp === "*" ? "0.0.0.0" : initialIp; + + const useContainers = + config.dev.enable_containers && config.containers?.length; + + return { + auth, + remote: input.dev?.remote, + server: { + hostname: input.dev?.server?.hostname || config.dev.ip, + port: + input.dev?.server?.port ?? + config.dev.port ?? + (await getLocalPort(initialIpListenCheck)), + secure: + input.dev?.server?.secure ?? config.dev.local_protocol === "https", + httpsKeyPath: input.dev?.server?.httpsKeyPath, + httpsCertPath: input.dev?.server?.httpsCertPath, + }, + inspector: await resolveInspectorConfig(config, input), + origin: { + secure: + input.dev?.origin?.secure ?? config.dev.upstream_protocol === "https", + hostname: + host ?? + ((input.dev?.inferOriginFromRoutes ?? true) + ? getInferredHost(routes, config.configPath) + : undefined), + }, + watch: input.dev?.watch, + liveReload: input.dev?.liveReload || false, + testScheduled: input.dev?.testScheduled, + outboundService: input.dev?.outboundService, + structuredLogsHandler: input.dev?.structuredLogsHandler, + // absolute resolved path + persist: localPersistencePath, + registry: input.dev?.registry, + multiworkerPrimary: input.dev?.multiworkerPrimary, + inferOriginFromRoutes: input.dev?.inferOriginFromRoutes ?? true, + routeRequestsByRoutes: input.dev?.routeRequestsByRoutes ?? false, + enableContainers: + input.dev?.enableContainers ?? config.dev.enable_containers, + dockerPath: input.dev?.dockerPath ?? getDockerPath(), + containerEngine: useContainers + ? (input.dev?.containerEngine ?? + config.dev.container_engine ?? + resolveDockerHost(input.dev?.dockerPath ?? getDockerPath())) + : undefined, + containerBuildId: input.dev?.containerBuildId, + generateTypes: input.dev?.generateTypes ?? config.dev.generate_types, + tunnel: input.dev?.tunnel, + } satisfies StartDevWorkerOptions["dev"]; +} + +/** + * Maps a {@link LoginOrRefreshFailureReason} to a user-facing error message + * with actionable remediation steps (e.g. re-running `wrangler login`, + * setting `CLOUDFLARE_API_TOKEN`, or falling back to local dev). + * + * @param remoteMode - The remote dev mode that was requested. When + * `"minimal"` (remote-bindings mode), the suggestion to fall back to + * `--local` dev is omitted because local dev is not a useful alternative. + * @param failureReason - The specific {@link LoginOrRefreshFailureReason} + * that describes why login or token refresh could not succeed. + * @returns A formatted error message string prefixed with a generic failure + * summary, followed by reason-specific guidance and a `wrangler whoami` tip. + */ +function getLoginOrRefreshFailureErrorMessage( + remoteMode: boolean | "minimal", + failureReason: LoginOrRefreshFailureReason +) { + const errorMessagePrefix = "Could not start remote dev session."; + const localFallback = + remoteMode === "minimal" + ? "" // Remote bindings mode — local dev is not a useful fallback + : "\n - Or use `wrangler dev --local` to develop locally (remote resources like KV, D1, etc. will use local simulators instead)."; + const whoamiTip = + "\n\nYou can run `wrangler whoami` to check your current authentication status."; + const errorMessageBodies = { + "no-credentials-non-interactive": + " No credentials found, and the environment is non-interactive so browser login cannot be started.\n" + + "Either:\n" + + " - Set a CLOUDFLARE_API_TOKEN environment variable\n" + + ` - Run \`wrangler login\` in an interactive terminal first${localFallback}${whoamiTip}`, + "no-credentials-login-failed": + " No credentials found and the login attempt was unsuccessful.\n" + + "Either:\n" + + ` - Run \`wrangler login\` to try again${localFallback}${whoamiTip}`, + "token-expired-non-interactive": + " Your auth token has expired and could not be refreshed, and the environment is non-interactive so browser login cannot be started.\n" + + "Either:\n" + + " - Run `wrangler login` in an interactive terminal\n" + + ` - Set a CLOUDFLARE_API_TOKEN environment variable${localFallback}${whoamiTip}`, + "token-expired-login-failed": + " Your auth token has expired and could not be refreshed, and the login attempt was unsuccessful.\n" + + "Either:\n" + + ` - Run \`wrangler login\` to try again${localFallback}${whoamiTip}`, + }; + const errorMessageBody = errorMessageBodies[failureReason]; + const errorMessage = errorMessagePrefix + errorMessageBody; + return errorMessage; +} + +async function resolveBindings( + config: Config, + input: StartDevWorkerInput +): Promise<{ + bindings: StartDevWorkerOptions["bindings"]; + unsafe?: CfUnsafe; + printCurrentBindings: (registry: WorkerRegistry | null) => void; +}> { + const bindings = getBindings( + config, + input.env, + input.envFiles, + !input.dev?.remote, + input.bindings, + input.defaultBindings + ); + + // Create a print function that captures the current bindings context + const printCurrentBindings = (registry: WorkerRegistry | null) => { + printBindings( + bindings, + input.tailConsumers ?? config.tail_consumers, + input.streamingTailConsumers ?? config.streaming_tail_consumers, + config.containers, + { + registry, + local: !input.dev?.remote, + isMultiWorker: getFlag("MULTIWORKER"), + remoteBindingsDisabled: input.dev?.remote === false, + name: config.name, + } + ); + }; + + // Print the initial bindings table + printCurrentBindings( + input.dev?.registry ? getWorkerRegistry(input.dev.registry) : null + ); + + return { + bindings: { + ...input.bindings, + ...bindings, + }, + unsafe: { + bindings: config.unsafe.bindings, + metadata: config.unsafe.metadata, + capnp: config.unsafe.capnp, + }, + printCurrentBindings, + }; +} + +async function resolveTriggers( + config: Config, + input: StartDevWorkerInput +): Promise { + const { routes } = await getHostAndRoutes( + { + host: input.dev?.origin?.hostname, + routes: input.triggers?.filter( + (t): t is Extract => t.type === "route" + ), + assets: input?.assets, + }, + config + ); + + const devRoutes = + routes?.map>((r) => + typeof r === "string" + ? { + type: "route", + pattern: r, + } + : { type: "route", ...r } + ) ?? []; + const queueConsumers = + config.queues.consumers?.map>( + (c) => ({ + ...c, + type: "queue-consumer", + }) + ) ?? []; + + const crons = + config.triggers.crons?.map>((c) => ({ + cron: c, + type: "cron", + })) ?? []; + + return [...devRoutes, ...queueConsumers, ...crons]; +} + +async function resolveConfig( + config: Config, + input: StartDevWorkerInput, + // If the worker name was previously autogenerated, keep the same one + previousName: string | undefined, + newConfigEnabled: boolean +): Promise<{ + config: StartDevWorkerOptions; + printCurrentBindings: (registry: WorkerRegistry | null) => void; +}> { + if ( + config.pages_build_output_dir && + input.dev?.multiworkerPrimary === false + ) { + throw new UserError( + `You cannot use a Pages project as a service binding target.\nIf you are trying to develop Pages and Workers together, please use \`wrangler pages dev\`. Note the first config file specified must be for the Pages project`, + { telemetryMessage: "api dev pages service binding target invalid" } + ); + } + const legacySite = unwrapHook(input.legacy?.site, config); + + const entry = await getEntry( + { + script: input.entrypoint, + moduleRoot: input.build?.moduleRoot, + // getEntry only needs to know if assets was specified. + // The actual value is not relevant here, which is why not passing + // the entire Assets object is fine. + assets: input?.assets, + }, + config, + "dev" + ); + + const nodejsCompatMode = unwrapHook(input.build?.nodejsCompatMode, config); + + const { bindings, unsafe, printCurrentBindings } = await resolveBindings( + config, + input + ); + + const assetsOptions = getAssetsOptions({ + args: { + assets: input?.assets, + script: input.entrypoint, + }, + config, + }); + + const resolved = { + name: + getScriptName({ name: input.name, env: input.env }, config) ?? + previousName ?? + crypto.randomUUID(), + config: config.configPath, + compatibilityDate: getDevCompatibilityDate( + entry.projectRoot, + config, + input.compatibilityDate + ), + compatibilityFlags: input.compatibilityFlags ?? config.compatibility_flags, + complianceRegion: input.complianceRegion ?? config.compliance_region, + pythonModules: { + exclude: input.pythonModules?.exclude ?? config.python_modules.exclude, + }, + entrypoint: entry.file, + projectRoot: entry.projectRoot, + bindings, + migrations: input.migrations ?? config.migrations, + exports: input.exports ?? config.exports, + sendMetrics: input.sendMetrics ?? config.send_metrics, + triggers: await resolveTriggers(config, input), + env: input.env, + envFiles: input.envFiles, + build: { + alias: input.build?.alias ?? config.alias, + additionalModules: input.build?.additionalModules ?? [], + processEntrypoint: Boolean(input.build?.processEntrypoint), + bundle: input.build?.bundle ?? !config.no_bundle, + findAdditionalModules: + input.build?.findAdditionalModules ?? config.find_additional_modules, + moduleRoot: entry.moduleRoot, + moduleRules: input.build?.moduleRules ?? getRules(config), + + minify: input.build?.minify ?? config.minify, + keepNames: input.build?.keepNames ?? config.keep_names, + define: { ...config.define, ...input.build?.define }, + custom: { + command: input.build?.custom?.command ?? config.build?.command, + watch: input.build?.custom?.watch ?? config.build?.watch_dir, + workingDirectory: + input.build?.custom?.workingDirectory ?? config.build?.cwd, + }, + format: entry.format, + nodejsCompatMode: nodejsCompatMode ?? null, + jsxFactory: input.build?.jsxFactory || config.jsx_factory, + jsxFragment: input.build?.jsxFragment || config.jsx_fragment, + tsconfig: input.build?.tsconfig ?? config.tsconfig, + exports: entry.exports, + }, + containers: await getNormalizedContainerOptions(config, {}), + dev: await resolveDevConfig(config, input), + legacy: { + site: legacySite, + }, + unsafe: { + capnp: input.unsafe?.capnp ?? unsafe?.capnp, + metadata: input.unsafe?.metadata ?? unsafe?.metadata, + }, + assets: assetsOptions, + tailConsumers: config.tail_consumers ?? [], + experimental: {}, + streamingTailConsumers: config.streaming_tail_consumers ?? [], + } satisfies StartDevWorkerOptions; + + if ( + extractBindingsOfType("analytics_engine", resolved.bindings).length && + !resolved.dev.remote && + resolved.build.format === "service-worker" + ) { + logger.once.warn( + "Analytics Engine is not supported locally when using the service-worker format. Please migrate to the module worker format: https://developers.cloudflare.com/workers/reference/migrate-to-module-workers/" + ); + } + + validateAssetsArgsAndConfig(resolved); + + const services = extractBindingsOfType("service", resolved.bindings); + if (services && services.length > 0 && resolved.dev?.remote) { + logger.once.warn( + `This worker is bound to live services: ${services + .map( + (service) => + `${service.binding} (${service.service}${ + service.environment ? `@${service.environment}` : "" + }${service.entrypoint ? `#${service.entrypoint}` : ""})` + ) + .join(", ")}` + ); + } + + if (!resolved.dev?.origin?.secure && resolved.dev?.remote) { + logger.once.warn( + "Setting upstream-protocol to http is not currently supported for remote mode.\n" + + "If this is required in your project, please add your use case to the following issue:\n" + + "https://github.com/cloudflare/workers-sdk/issues/583" + ); + } + + // for pulling containers, we need to make sure the OpenAPI config for the + // container API client is properly set so that we can get the correct permissions + // from the cloudchamber API to pull from the repository. + const needsPulling = resolved.containers.some( + (c) => "image_uri" in c && c.image_uri + ); + if (needsPulling && !resolved.dev.remote) { + await fillOpenAPIConfiguration(config, containersScope); + } + + // TODO(queues) support remote wrangler dev + const queues = extractBindingsOfType("queue", resolved.bindings); + if ( + resolved.dev.remote && + (queues?.length || + resolved.triggers?.some((t) => t.type === "queue-consumer")) + ) { + logger.once.warn( + "Queues are not yet supported in wrangler dev remote mode." + ); + } + + if (resolved.dev.remote) { + // We're in remote mode (`--remote`) + + if ( + resolved.dev.enableContainers && + resolved.containers && + resolved.containers.length > 0 + ) { + logger.once.warn( + "Containers are only supported in local mode, to suppress this warning set `dev.enable_containers` to `false` or pass `--enable-containers=false` to the `wrangler dev` command" + ); + } + + // TODO(do) support remote wrangler dev + const classNameToUseSQLite = getDurableObjectClassNameToUseSQLiteMap( + resolved.migrations, + resolved.exports + ); + if ( + resolved.dev.remote && + Array.from(classNameToUseSQLite.values()).some((v) => v) + ) { + logger.once.warn( + "SQLite in Durable Objects is only supported in local mode." + ); + } + } + + // Skip the legacy `checkTypesDiff` call when `--experimental-new-config` is on. + // The new-config equivalent (`regenerateNewConfigTypes`) is invoked from + // `#updateConfig` directly using the structured `types` object returned + // by `loadNewConfig`. + if (!newConfigEnabled) { + await checkTypesDiff(config, entry); + } + + return { config: resolved, printCurrentBindings }; +} + +/** + * Returns the compatibility date to use in development. + * + * When no compatibility date is configured, uses today's date. + * + * @param config wrangler configuration + * @param compatibilityDate configured compatibility date + * @returns the compatibility date to use in development + */ +function getDevCompatibilityDate( + projectPath: string, + config: Config | undefined, + compatibilityDate = config?.compatibility_date +): string { + const todaysDate = getTodaysCompatDate(); + + if (config?.configPath && compatibilityDate === undefined) { + logger.warn( + `No compatibility_date was specified. Using today's date: ${todaysDate}.\n` + + `❯❯ Add one to your ${configFileName(config.configPath)} file: ${formatConfigSnippet({ compatibility_date: todaysDate }, config.configPath, false).trim()}, or\n` + + `❯❯ Pass it in your terminal: wrangler dev [ +`; + +/** + * Rewrite references to URLs in request/response headers. + * + * This function is used to map the URLs in headers like Origin and Access-Control-Allow-Origin + * so that this proxy is transparent to the Client Browser and User Worker. + */ +function rewriteUrlRelatedHeaders(headers: Headers, from: URL, to: URL) { + const setCookie = headers.getAll("Set-Cookie"); + headers.delete("Set-Cookie"); + headers.forEach((value, key) => { + if (typeof value === "string" && value.includes(from.host)) { + headers.set(key, rewriteUrlInHeaderValue(value, from, to)); + } + }); + for (const cookie of setCookie) { + headers.append( + "Set-Cookie", + cookie.replace( + new RegExp(`Domain=${from.hostname}($|;|,)`), + `Domain=${to.hostname}$1` + ) + ); + } +} diff --git a/packages/remote-bindings/tsdown.config.ts b/packages/remote-bindings/tsdown.config.ts index 1e5e7730763..fb142c51867 100644 --- a/packages/remote-bindings/tsdown.config.ts +++ b/packages/remote-bindings/tsdown.config.ts @@ -20,4 +20,12 @@ export default defineConfig([ dts: false, external: ["cloudflare:email", "cloudflare:workers"], }, + { + entry: { + "dev-proxy-worker": "templates/startDevWorker/ProxyWorker.ts", + }, + platform: "node", + outDir: "dist", + dts: false, + }, ]); diff --git a/packages/wrangler/templates/startDevWorker/ProxyWorker.ts b/packages/wrangler/templates/startDevWorker/ProxyWorker.ts index fc28268c008..6e90b222fe2 100644 --- a/packages/wrangler/templates/startDevWorker/ProxyWorker.ts +++ b/packages/wrangler/templates/startDevWorker/ProxyWorker.ts @@ -1,6 +1,6 @@ import { createDeferred, - DeferredPromise, + type DeferredPromise, rewriteUrlInHeaderValue, urlFromParts, } from "../../src/api/startDevWorker/utils"; From 0ad1466bf0cba3186db947d6be3698c3aa750729 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Wed, 15 Jul 2026 14:30:52 +0100 Subject: [PATCH 19/37] make proxycontroller green with minimal helpers --- .../src/startDevWorker/ProxyController.ts | 16 ++------ .../remote-bindings/src/utils/miniflare.ts | 37 +++++++++++++++++++ .../remote-bindings/src/utils/use-esbuild.ts | 12 ++++++ 3 files changed, 52 insertions(+), 13 deletions(-) create mode 100644 packages/remote-bindings/src/utils/miniflare.ts create mode 100644 packages/remote-bindings/src/utils/use-esbuild.ts diff --git a/packages/remote-bindings/src/startDevWorker/ProxyController.ts b/packages/remote-bindings/src/startDevWorker/ProxyController.ts index 66f7fd5fe75..3f87bead45a 100644 --- a/packages/remote-bindings/src/startDevWorker/ProxyController.ts +++ b/packages/remote-bindings/src/startDevWorker/ProxyController.ts @@ -4,17 +4,16 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { assertNever } from "@cloudflare/workers-utils"; import { LogLevel, Miniflare, Mutex, Response } from "miniflare"; +import { logger } from "../logger"; import { castLogLevel, handleStructuredLogs, WranglerLog, -} from "../../dev/miniflare"; -import { validateHttpsOptions } from "../../https-options"; -import { logger } from "../logger"; +} from "../utils/miniflare"; import { Controller } from "./BaseController"; import { castErrorCause } from "./events"; import { createDeferred } from "./utils"; -import type { EsbuildBundle } from "../../dev/use-esbuild"; +import type { EsbuildBundle } from "../utils/use-esbuild"; import type { BundleStartEvent, ConfigUpdateEvent, @@ -53,19 +52,10 @@ export class ProxyController extends Controller { } assert(this.latestConfig !== undefined); - const cert = this.latestConfig.dev?.server?.secure - ? validateHttpsOptions( - this.latestConfig.dev.server.httpsKeyPath, - this.latestConfig.dev.server.httpsCertPath - ) - : undefined; - const proxyWorkerOptions: MiniflareOptions = { host: this.latestConfig.dev?.server?.hostname, port: this.latestConfig.dev?.server?.port, https: this.latestConfig.dev?.server?.secure, - httpsCert: cert?.cert, - httpsKey: cert?.key, stripDisablePrettyError: false, unsafeLocalExplorer: false, workers: [ diff --git a/packages/remote-bindings/src/utils/miniflare.ts b/packages/remote-bindings/src/utils/miniflare.ts new file mode 100644 index 00000000000..c155986a928 --- /dev/null +++ b/packages/remote-bindings/src/utils/miniflare.ts @@ -0,0 +1,37 @@ +import { Log, LogLevel } from "miniflare"; +import { logger } from "../logger"; +import type { LoggerLevel } from "@cloudflare/workers-utils"; +import type { WorkerdStructuredLog } from "miniflare"; + +export class WranglerLog extends Log {} + +export function castLogLevel(level: LoggerLevel): LogLevel { + let key = level.toUpperCase() as Uppercase; + if (key === "LOG") { + key = "INFO"; + } + + return LogLevel[key]; +} + +export function handleStructuredLogs({ + level, + message, +}: WorkerdStructuredLog): void { + if (level === "warn") { + logger.warn(message); + return; + } + + if (level === "info" || level === "debug") { + logger.info(message); + return; + } + + if (level === "error") { + logger.error(message); + return; + } + + logger.log(message); +} diff --git a/packages/remote-bindings/src/utils/use-esbuild.ts b/packages/remote-bindings/src/utils/use-esbuild.ts new file mode 100644 index 00000000000..e7c151b6172 --- /dev/null +++ b/packages/remote-bindings/src/utils/use-esbuild.ts @@ -0,0 +1,12 @@ +import type { CfModule, CfModuleType, Entry } from "@cloudflare/workers-utils"; +import type { Metafile } from "esbuild"; + +export type EsbuildBundle = { + id: number; + path: string; + entrypointSource: string; + entry: Entry; + type: CfModuleType; + modules: CfModule[]; + dependencies: Metafile["outputs"][string]["inputs"]; +}; From a97268374a701fafca5b3e73cf17622d6765e7dc Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Wed, 15 Jul 2026 15:19:21 +0100 Subject: [PATCH 20/37] reduction in more things we don't cara bout --- .../startDevWorker/RemoteRuntimeController.ts | 126 ++----------- .../src/startDevWorker/types.ts | 2 +- .../src/utils/create-worker-preview.ts | 61 +++--- .../remote-bindings/src/utils/isAbortError.ts | 14 ++ packages/remote-bindings/src/utils/remote.ts | 174 +++--------------- 5 files changed, 93 insertions(+), 284 deletions(-) create mode 100644 packages/remote-bindings/src/utils/isAbortError.ts diff --git a/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts b/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts index 8f6412b4937..524427166a9 100644 --- a/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts +++ b/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts @@ -37,8 +37,8 @@ import type { ReloadCompleteEvent, ReloadStartEvent, } from "./events"; -import type { Bundle, StartDevWorkerOptions, Trigger } from "./types"; -import type { Route } from "@cloudflare/workers-utils"; +import type { Bundle, StartDevWorkerOptions } from "./types"; +import type { ComplianceConfig } from "@cloudflare/workers-utils"; type CreateRemoteWorkerInitProps = Parameters[0]; @@ -54,20 +54,20 @@ export class RemoteRuntimeController extends RuntimeController { #latestConfig?: StartDevWorkerOptions; #latestBundle?: Bundle; - #latestRoutes?: Route[]; #latestProxyData?: ProxyData; // Timer for proactive token refresh before the 1-hour expiry #refreshTimer?: ReturnType; async #previewSession( - props: Parameters[0] & { + props: CfAccount & { + complianceConfig: ComplianceConfig; name: string; } ): Promise { try { const { workerAccount, workerContext } = - await getWorkerAccountAndContext(props); + getWorkerAccountAndContext(props); return await retryOnAPIFailure( () => @@ -96,7 +96,8 @@ export class RemoteRuntimeController extends RuntimeController { async #previewToken( props: CreateRemoteWorkerInitProps & - Parameters[0] & { + CfAccount & { + complianceConfig: ComplianceConfig; bundleId: number; minimal_mode?: boolean; } @@ -109,17 +110,6 @@ export class RemoteRuntimeController extends RuntimeController { const session = this.#session; try { - /* - * Since `getWorkerAccountAndContext`, `createRemoteWorkerInit` and - * `createWorkerPreview` are all async functions, it is technically - * possible that new `bundleComplete` events are trigerred while those - * functions are still executing. In such cases we want to drop the - * current bundle and exit early, to avoid unnecessarily executing any - * further expensive API calls. - * - * For this purpose, we want perform a check before each of these - * functions, to ensure no new `bundleComplete` was triggered. - */ // If we received a new `bundleComplete` event before we were able to // dispatch a `reloadComplete` for this bundle, ignore this bundle. if (props.bundleId !== this.#currentBundleId) { @@ -129,44 +119,18 @@ export class RemoteRuntimeController extends RuntimeController { this.#activeTail?.removeAllListeners("error"); this.#activeTail?.on("error", () => {}); this.#activeTail?.terminate(); - const { workerAccount, workerContext } = await getWorkerAccountAndContext( - { - complianceConfig: props.complianceConfig, - accountId: props.accountId, - env: props.env, - host: props.host, - routes: props.routes, - sendMetrics: props.sendMetrics, - configPath: props.configPath, - } - ); - - // If we received a new `bundleComplete` event before we were able to - // dispatch a `reloadComplete` for this bundle, ignore this bundle. - if (props.bundleId !== this.#currentBundleId) { - return; - } - const init = await createRemoteWorkerInit({ - complianceConfig: props.complianceConfig, - bundle: props.bundle, - modules: props.modules, + const { workerAccount, workerContext } = getWorkerAccountAndContext({ accountId: props.accountId, + apiToken: props.apiToken, + }); + const init = createRemoteWorkerInit({ + bundle: props.bundle, name: props.name, - env: props.env, - isWorkersSite: props.isWorkersSite, - assets: props.assets, - legacyAssetPaths: props.legacyAssetPaths, - format: props.format, bindings: props.bindings, compatibilityDate: props.compatibilityDate, compatibilityFlags: props.compatibilityFlags, }); - // If we received a new `bundleComplete` event before we were able to - // dispatch a `reloadComplete` for this bundle, ignore this bundle. - if (props.bundleId !== this.#currentBundleId) { - return; - } const workerPreviewToken = await retryOnAPIFailure( () => createWorkerPreview( @@ -236,49 +200,19 @@ export class RemoteRuntimeController extends RuntimeController { } } - #getPreviewSession( - config: StartDevWorkerOptions, - auth: CfAccount, - routes: Route[] | undefined - ) { + #getPreviewSession(config: StartDevWorkerOptions, auth: CfAccount) { return this.#previewSession({ complianceConfig: { compliance_region: config.complianceRegion }, accountId: auth.accountId, apiToken: auth.apiToken, - env: config.env, - host: config.dev.origin?.hostname, - routes, - sendMetrics: config.sendMetrics, - configPath: config.config, name: config.name, }); } - #extractRoutes(config: StartDevWorkerOptions): Route[] | undefined { - return config.triggers - ?.filter( - (trigger): trigger is Extract => - trigger.type === "route" - ) - .map((trigger) => { - const { type: _, ...route } = trigger; - if ( - "custom_domain" in route || - "zone_id" in route || - "zone_name" in route - ) { - return route; - } else { - return route.pattern; - } - }); - } - async #updatePreviewToken( config: StartDevWorkerOptions, bundle: Bundle, auth: CfAccount, - routes: Route[] | undefined, bundleId: number ): Promise { // If we received a new `bundleComplete` event before we were able to @@ -289,29 +223,13 @@ export class RemoteRuntimeController extends RuntimeController { const token = await this.#previewToken({ bundle, - modules: bundle.modules, accountId: auth.accountId, + apiToken: auth.apiToken, complianceConfig: { compliance_region: config.complianceRegion }, name: config.name, - env: config.env, - isWorkersSite: config.legacy?.site !== undefined, - assets: config.assets, - legacyAssetPaths: config.legacy?.site?.bucket - ? { - baseDirectory: config.legacy?.site?.bucket, - assetDirectory: "", - excludePatterns: config.legacy?.site?.exclude ?? [], - includePatterns: config.legacy?.site?.include ?? [], - } - : undefined, - format: bundle.entry.format, bindings: config.bindings, compatibilityDate: config.compatibilityDate, compatibilityFlags: config.compatibilityFlags, - routes, - host: config.dev.origin?.hostname, - sendMetrics: config.sendMetrics, - configPath: config.config, bundleId, minimal_mode: config.dev.remote === "minimal", }); @@ -322,7 +240,7 @@ export class RemoteRuntimeController extends RuntimeController { return false; } - const accessHeaders = getAccessHeaders(token.host, { + const accessHeaders = await getAccessHeaders(token.host, { logger, }); @@ -375,8 +293,6 @@ export class RemoteRuntimeController extends RuntimeController { logger.log(chalk.dim("⎔ Starting remote preview...")); try { - const routes = this.#extractRoutes(config); - if (!config.dev?.auth) { throw new MissingConfigError("config.dev.auth"); } @@ -386,7 +302,6 @@ export class RemoteRuntimeController extends RuntimeController { this.#latestConfig = config; this.#latestBundle = bundle; - this.#latestRoutes = routes; if (this.#session) { logger.log(chalk.dim("⎔ Detected changes, restarted server.")); @@ -398,8 +313,8 @@ export class RemoteRuntimeController extends RuntimeController { this.#session = undefined; } - this.#session ??= await this.#getPreviewSession(config, auth, routes); - await this.#updatePreviewToken(config, bundle, auth, routes, id); + this.#session ??= await this.#getPreviewSession(config, auth); + await this.#updatePreviewToken(config, bundle, auth, id); } catch (error) { if (error instanceof Error && error.name == "AbortError") { return; @@ -427,17 +342,12 @@ export class RemoteRuntimeController extends RuntimeController { assert(this.#latestConfig.dev.auth); const auth = await unwrapHook(this.#latestConfig.dev.auth); - this.#session = await this.#getPreviewSession( - this.#latestConfig, - auth, - this.#latestRoutes - ); + this.#session = await this.#getPreviewSession(this.#latestConfig, auth); const refreshed = await this.#updatePreviewToken( this.#latestConfig, this.#latestBundle, auth, - this.#latestRoutes, this.#currentBundleId ); diff --git a/packages/remote-bindings/src/startDevWorker/types.ts b/packages/remote-bindings/src/startDevWorker/types.ts index 0f6af34c1d0..9c5544413dc 100644 --- a/packages/remote-bindings/src/startDevWorker/types.ts +++ b/packages/remote-bindings/src/startDevWorker/types.ts @@ -1,4 +1,4 @@ -import type { EsbuildBundle } from "../../dev/use-esbuild"; +import type { EsbuildBundle } from "../utils/use-esbuild"; import type { ConfigController } from "./ConfigController"; import type { DevEnv } from "./DevEnv"; import type { ContainerNormalizedConfig } from "@cloudflare/containers-shared"; diff --git a/packages/remote-bindings/src/utils/create-worker-preview.ts b/packages/remote-bindings/src/utils/create-worker-preview.ts index 12d7a0e588a..93236cb0998 100644 --- a/packages/remote-bindings/src/utils/create-worker-preview.ts +++ b/packages/remote-bindings/src/utils/create-worker-preview.ts @@ -1,19 +1,26 @@ import crypto from "node:crypto"; import { URL } from "node:url"; -import { getWorkersDevSubdomain } from "@cloudflare/deploy-helpers"; -import { ParseError, parseJSON, UserError } from "@cloudflare/workers-utils"; +import { + createWorkerUploadForm, + getWorkersDevSubdomain, +} from "@cloudflare/deploy-helpers"; +import { getAccessHeaders } from "@cloudflare/workers-auth"; +import { + fetchResultBase, + ParseError, + parseJSON, + UserError, +} from "@cloudflare/workers-utils"; import { fetch } from "undici"; -import { fetchResult } from "../cfetch"; -import { createWorkerUploadForm } from "../deployment-bundle/create-worker-upload-form"; +import { version as packageVersion } from "../../package.json"; import { logger } from "../logger"; -import { getAccessHeaders } from "../user/access"; import type { CfWorkerInitWithName } from "./remote"; import type { ApiCredentials, CfWorkerContext, ComplianceConfig, } from "@cloudflare/workers-utils"; -import type { HeadersInit } from "undici"; +import type { HeadersInit, RequestInit } from "undici"; /** * Maximum time (ms) to wait for an individual preview API request before @@ -22,6 +29,25 @@ import type { HeadersInit } from "undici"; */ const PREVIEW_API_TIMEOUT_MS = 30_000; +function fetchResult( + complianceConfig: ComplianceConfig, + account: CfAccount, + resource: string, + init: RequestInit = {}, + abortSignal?: AbortSignal +): Promise { + return fetchResultBase( + complianceConfig, + resource, + init, + `remote-bindings/${packageVersion}`, + logger, + undefined, + abortSignal, + account.apiToken + ); +} + /** * Combine the caller's abort signal with a per-request timeout so that a * hung Cloudflare API response doesn't block forever. @@ -138,13 +164,12 @@ async function tryExpandToken( try { const switchedExchangeUrl = switchHost(exchangeUrl, ctx.host, !!ctx.zone); - const accessHeaders = await getAccessHeaders(switchedExchangeUrl.hostname); + const accessHeaders = await getAccessHeaders(switchedExchangeUrl.hostname, { + logger, + }); const headers: HeadersInit = { ...accessHeaders }; - logger.debugWithSanitization( - "-- START EXCHANGE API REQUEST:", - ` GET ${switchedExchangeUrl.href}` - ); + logger.debug("-- START EXCHANGE API REQUEST:"); logger.debug("-- END EXCHANGE API REQUEST"); const exchangeResponse = await fetch(switchedExchangeUrl, { @@ -158,7 +183,6 @@ async function tryExpandToken( exchangeResponse.status ); logger.debug("HEADERS:", JSON.stringify(exchangeResponse.headers, null, 2)); - logger.debugWithSanitization("RESPONSE:", bodyText); logger.debug("-- END EXCHANGE API RESPONSE"); @@ -190,7 +214,7 @@ export async function createPreviewSession( abortSignal: AbortSignal, name: string | undefined ): Promise { - const { accountId, apiToken } = account; + const { accountId } = account; const initUrl = ctx.zone ? `/zones/${ctx.zone}/workers/edge-preview` : `/accounts/${accountId}/workers/subdomain/edge-preview`; @@ -198,14 +222,7 @@ export async function createPreviewSession( const { token, exchange_url } = await fetchResult<{ token: string; exchange_url?: string; - }>( - complianceConfig, - initUrl, - undefined, - undefined, - withTimeout(abortSignal), - apiToken - ); + }>(complianceConfig, account, initUrl, undefined, withTimeout(abortSignal)); const previewSessionToken = exchange_url ? ((await tryExpandToken(exchange_url, ctx, withTimeout(abortSignal))) ?? @@ -289,6 +306,7 @@ async function createPreviewToken( tail_url: string; }>( complianceConfig, + account, url, { method: "POST", @@ -297,7 +315,6 @@ async function createPreviewToken( "cf-preview-upload-config-token": value, }, }, - undefined, withTimeout(abortSignal) ); diff --git a/packages/remote-bindings/src/utils/isAbortError.ts b/packages/remote-bindings/src/utils/isAbortError.ts new file mode 100644 index 00000000000..73c89857a6c --- /dev/null +++ b/packages/remote-bindings/src/utils/isAbortError.ts @@ -0,0 +1,14 @@ +/** + * Checking whether an error is an AbortError has changed. + * There is a legacy use of `.code` + * and a (mdn status: experimental) use of `.name` + * + * See MDN for more information: + * https://developer.mozilla.org/en-US/docs/Web/API/DOMException#aborterror + */ +export function isAbortError(err: unknown) { + const legacyAbortErroCheck = (err as { code: string }).code == "ABORT_ERR"; + const abortErrorCheck = err instanceof Error && err.name == "AbortError"; + + return legacyAbortErroCheck || abortErrorCheck; +} diff --git a/packages/remote-bindings/src/utils/remote.ts b/packages/remote-bindings/src/utils/remote.ts index 53db48c1101..d8f1e88e15e 100644 --- a/packages/remote-bindings/src/utils/remote.ts +++ b/packages/remote-bindings/src/utils/remote.ts @@ -1,31 +1,14 @@ import assert from "node:assert"; import path from "node:path"; +import { getAuthFromEnv } from "@cloudflare/workers-auth"; import { APIError, UserError } from "@cloudflare/workers-utils"; -import { syncAssets } from "../assets"; -import { isAuthenticationError } from "../core/handle-errors"; -import { printBundleSize } from "../deployment-bundle/bundle-reporter"; -import { getBundleType } from "../deployment-bundle/bundle-type"; -import { withSourceURLs } from "../deployment-bundle/source-url"; -import { getInferredHost } from "../dev"; import { logger } from "../logger"; -import { syncWorkersSite } from "../sites"; -import { getAuthFromEnv, requireApiToken } from "../user"; -import { isAbortError } from "../utils/isAbortError"; -import { getZoneIdForPreview } from "../zones"; -import type { StartDevWorkerInput } from "../api"; +import { isAbortError } from "./isAbortError"; +import type { StartDevWorkerInput } from "../startDevWorker/types"; import type { CfAccount } from "./create-worker-preview"; import type { EsbuildBundle } from "./use-esbuild"; import type { ApiCredentials } from "@cloudflare/workers-utils"; -import type { - AssetsOptions, - CfModule, - CfScriptFormat, - CfWorkerContext, - CfWorkerInit, - ComplianceConfig, - LegacyAssetPaths, - Route, -} from "@cloudflare/workers-utils"; +import type { CfWorkerContext, CfWorkerInit } from "@cloudflare/workers-utils"; /** * Error thrown when a remote dev session fails due to an authentication @@ -105,17 +88,10 @@ export function handlePreviewSessionCreationError( accountId: string ) { assert(err && typeof err === "object"); - // instead of logging the raw API error to the user, - // give them friendly instructions - if (isAuthenticationError(err)) { - throw new RemoteSessionAuthenticationError(err); + if (handleUserFriendlyError(err, accountId)) { + return; } - // for error 10063 (workers.dev subdomain required) - else if ("code" in err && err.code === 10063) { - logger.error( - `You need to register a workers.dev subdomain before running the dev command in remote mode. You can either enable local mode by pressing l, or register a workers.dev subdomain here: https://dash.cloudflare.com/${accountId}/workers/onboarding` - ); - } else if ( + if ( "cause" in err && (err.cause as { code: string; hostname: string })?.code === "ENOTFOUND" ) { @@ -141,91 +117,24 @@ export type CfWorkerInitWithName = Required> & * Create remote worker init from StartDevWorkerInput["bindings"] format * (flat Record). */ -export async function createRemoteWorkerInit(props: { +export function createRemoteWorkerInit(props: { bundle: EsbuildBundle; - modules: CfModule[]; - complianceConfig: ComplianceConfig; - accountId: string; name: string; - env: string | undefined; - isWorkersSite: boolean; - assets: AssetsOptions | undefined; - legacyAssetPaths: LegacyAssetPaths | undefined; - format: CfScriptFormat; bindings: StartDevWorkerInput["bindings"]; compatibilityDate: string | undefined; compatibilityFlags: string[] | undefined; - minimal_mode?: boolean; }) { - const { entrypointSource: content, modules } = withSourceURLs( - props.bundle.path, - props.bundle.entrypointSource, - props.modules - ); - - // TODO: For Dev we could show the reporter message in the interactive box. - void printBundleSize( - { - name: path.basename(props.bundle.path), - content, - }, - props.modules - ); - - const workersSitesAssets = await syncWorkersSite( - props.complianceConfig, - props.accountId, - props.name, - props.isWorkersSite ? props.legacyAssetPaths : undefined, - true, - false, - undefined - ); // TODO: cancellable? - - if (workersSitesAssets.manifest) { - modules.push({ - name: "__STATIC_CONTENT_MANIFEST", - filePath: undefined, - content: JSON.stringify(workersSitesAssets.manifest), - type: "text", - }); - } - - const assetsUploadResult = props.assets - ? await syncAssets( - props.complianceConfig, - props.accountId, - props.assets.directory, - props.name - ) - : undefined; - const assetsJwt = assetsUploadResult?.jwt; - const bindings = { ...props.bindings }; - if (workersSitesAssets.namespace) { - bindings["__STATIC_CONTENT"] = { - type: "kv_namespace", - id: workersSitesAssets.namespace, - }; - } - - if (workersSitesAssets.manifest && props.format === "service-worker") { - bindings["__STATIC_CONTENT_MANIFEST"] = { - type: "text_blob", - source: { contents: "__STATIC_CONTENT_MANIFEST" }, - }; - } - const init: CfWorkerInitWithName = { name: props.name, main: { name: path.basename(props.bundle.path), filePath: props.bundle.path, - type: getBundleType(props.format, path.basename(props.bundle.path)), - content, + type: props.bundle.type, + content: props.bundle.entrypointSource, }, - modules, + modules: props.bundle.modules, bindings, migrations: undefined, // no migrations in dev exports: undefined, @@ -236,17 +145,7 @@ export async function createRemoteWorkerInit(props: { logpush: false, sourceMaps: undefined, containers: undefined, // Containers are not supported in remote dev mode - assets: - props.assets && assetsJwt - ? { - jwt: assetsJwt, - routerConfig: props.assets.routerConfig, - assetConfig: props.assets.assetConfig, - _redirects: props.assets._redirects, - _headers: props.assets._headers, - run_worker_first: props.assets.run_worker_first, - } - : undefined, + assets: undefined, placement: undefined, // no placement in dev tail_consumers: undefined, streaming_tail_consumers: undefined, @@ -258,34 +157,21 @@ export async function createRemoteWorkerInit(props: { return init; } -export async function getWorkerAccountAndContext(props: { - complianceConfig: ComplianceConfig; +export function getWorkerAccountAndContext(props: { accountId: string; - apiToken?: ApiCredentials | undefined; - env: string | undefined; - host: string | undefined; - routes: Route[] | undefined; - sendMetrics: boolean | undefined; - configPath: string | undefined; -}): Promise<{ workerAccount: CfAccount; workerContext: CfWorkerContext }> { + apiToken: ApiCredentials; +}): { workerAccount: CfAccount; workerContext: CfWorkerContext } { const workerAccount: CfAccount = { accountId: props.accountId, - apiToken: props.apiToken ?? requireApiToken(), + apiToken: props.apiToken, }; - // What zone should the realish preview for this Worker run on? - const zoneId = await getZoneIdForPreview(props.complianceConfig, { - host: props.host, - routes: props.routes, - accountId: props.accountId, - }); - const workerContext: CfWorkerContext = { - env: props.env, - zone: zoneId, - host: props.host ?? getInferredHost(props.routes, props.configPath), - routes: props.routes, - sendMetrics: props.sendMetrics, + env: undefined, + zone: undefined, + host: undefined, + routes: undefined, + sendMetrics: undefined, }; return { workerAccount, workerContext }; @@ -305,24 +191,6 @@ function handleUserFriendlyError(error: unknown, accountId?: string) { throw new RemoteSessionAuthenticationError(error); } - // code 10021 is a validation error - case 10021: { - // if it is the following message, give a more user friendly - // error, otherwise do not handle this error in this function - if ( - error.notes[0].text === - "binding DB of type d1 must have a valid `id` specified [code: 10021]" - ) { - logger.error( - `You must use a real database in the preview_database_id configuration. You can find your databases using 'wrangler d1 list', or read how to develop locally with D1 here: https://developers.cloudflare.com/d1/configuration/local-development` - ); - - return true; - } - - return false; - } - // for error 10063 (workers.dev subdomain required) case 10063: { const onboardingLink = accountId From be214431297fee1c098320733780ba1de149162d Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 16 Jul 2026 14:59:38 +0100 Subject: [PATCH 21/37] make remote bindings package functional --- .changeset/fix-remote-bindings-preview.md | 7 ++ packages/deploy-helpers/package.json | 4 + packages/deploy-helpers/tsup.config.ts | 2 + packages/remote-bindings/src/auth.test.ts | 1 + .../src/maybe-start-or-update-session.test.ts | 79 +++++++++++++++++++ .../src/maybe-start-or-update-session.ts | 25 +++--- .../src/start-remote-proxy-session.ts | 64 ++++++++------- packages/remote-bindings/src/start-worker.ts | 18 +---- .../src/startDevWorker/BundlerController.ts | 2 - .../src/startDevWorker/DevEnv.ts | 33 +------- .../src/startDevWorker/ProxyController.ts | 16 ++-- .../startDevWorker/RemoteRuntimeController.ts | 4 +- .../src/startDevWorker/types.ts | 13 +-- .../src/utils/create-worker-preview.ts | 52 +++++++++--- .../templates/startDevWorker/ProxyWorker.ts | 54 ------------- packages/remote-bindings/tsdown.config.ts | 5 +- .../src/__tests__/dev/remote-bindings.test.ts | 4 +- .../wrangler/src/api/remoteBindings/index.ts | 6 +- .../start-remote-proxy-session.ts | 20 ++++- 19 files changed, 235 insertions(+), 174 deletions(-) create mode 100644 .changeset/fix-remote-bindings-preview.md create mode 100644 packages/remote-bindings/src/maybe-start-or-update-session.test.ts diff --git a/.changeset/fix-remote-bindings-preview.md b/.changeset/fix-remote-bindings-preview.md new file mode 100644 index 00000000000..0f27f1f8368 --- /dev/null +++ b/.changeset/fix-remote-bindings-preview.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +Fix remote binding previews for accounts without a workers.dev subdomain + +Wrangler now automatically registers a workers.dev subdomain when one is required to start a remote binding preview. diff --git a/packages/deploy-helpers/package.json b/packages/deploy-helpers/package.json index 45e241df72a..fa42169b3a9 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 62c07fef9ef..59c6ec376aa 100644 --- a/packages/deploy-helpers/tsup.config.ts +++ b/packages/deploy-helpers/tsup.config.ts @@ -11,6 +11,8 @@ export default defineConfig(() => [ entry: { index: "src/index.ts", context: "src/shared/context.ts", + "create-worker-upload-form": + "src/deploy/helpers/create-worker-upload-form.ts", }, platform: "node", format: "esm", diff --git a/packages/remote-bindings/src/auth.test.ts b/packages/remote-bindings/src/auth.test.ts index 00f9c052968..b57b3857ca4 100644 --- a/packages/remote-bindings/src/auth.test.ts +++ b/packages/remote-bindings/src/auth.test.ts @@ -29,6 +29,7 @@ function createTestLogger(): RemoteBindingsLogger { info: vi.fn(), warn: vi.fn(), error: vi.fn(), + console: vi.fn(), once: { info: vi.fn(), log: vi.fn(), diff --git a/packages/remote-bindings/src/maybe-start-or-update-session.test.ts b/packages/remote-bindings/src/maybe-start-or-update-session.test.ts new file mode 100644 index 00000000000..3396388baf7 --- /dev/null +++ b/packages/remote-bindings/src/maybe-start-or-update-session.test.ts @@ -0,0 +1,79 @@ +import { describe, it, vi } from "vitest"; +import { maybeStartOrUpdateRemoteProxySession } from "./maybe-start-or-update-session"; +import type { RemoteBindingsLogger } from "./logger"; +import type { RemoteProxySessionData } from "./maybe-start-or-update-session"; +import type { startRemoteProxySession } from "./start-remote-proxy-session"; +import type { RemoteProxyConnectionString } from "miniflare"; + +function createTestLogger(): RemoteBindingsLogger { + return { + loggerLevel: "none", + debug: vi.fn(), + log: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + console: vi.fn(), + once: { + info: vi.fn(), + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + }; +} + +describe("maybeStartOrUpdateRemoteProxySession", () => { + it("updates an existing session when all remote bindings are removed", async ({ + expect, + }) => { + const dispose = vi.fn(); + const updateBindings = vi.fn(); + const startSession = vi.fn(); + const existingSession: RemoteProxySessionData = { + session: { + ready: Promise.resolve(), + dispose, + updateBindings, + remoteProxyConnectionString: new URL( + "http://localhost:8787" + ) as RemoteProxyConnectionString, + }, + remoteBindings: { + SERVICE: { + type: "service", + service: "worker", + remote: true, + }, + }, + }; + + const result = await maybeStartOrUpdateRemoteProxySession( + { bindings: {} }, + existingSession, + undefined, + { logger: createTestLogger() }, + startSession + ); + + expect(result?.session).toBe(existingSession.session); + expect(updateBindings).toHaveBeenCalledWith({}); + expect(dispose).not.toHaveBeenCalled(); + expect(startSession).not.toHaveBeenCalled(); + }); + + it("does not start a session without remote bindings", async ({ expect }) => { + const startSession = vi.fn(); + + const result = await maybeStartOrUpdateRemoteProxySession( + { bindings: {} }, + undefined, + undefined, + { logger: createTestLogger() }, + startSession + ); + + expect(result).toBeNull(); + expect(startSession).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/remote-bindings/src/maybe-start-or-update-session.ts b/packages/remote-bindings/src/maybe-start-or-update-session.ts index b4d7f46d319..5b7e8afa987 100644 --- a/packages/remote-bindings/src/maybe-start-or-update-session.ts +++ b/packages/remote-bindings/src/maybe-start-or-update-session.ts @@ -56,12 +56,18 @@ export type RemoteBindingsContext = { /** Potentially starts or updates a remote proxy session. */ export async function maybeStartOrUpdateRemoteProxySession( workerConfigObject: WorkerConfigObject, - preExistingRemoteProxySessionData?: RemoteProxySessionData | null, - auth?: AsyncHook, - context?: RemoteBindingsContext, + preExistingRemoteProxySessionData: RemoteProxySessionData | null | undefined, + auth: AsyncHook | undefined, + context: RemoteBindingsContext, startSession: typeof startRemoteProxySession = startRemoteProxySession ): Promise { const remoteBindings = pickRemoteBindings(workerConfigObject.bindings); + if ( + Object.keys(remoteBindings).length === 0 && + !preExistingRemoteProxySessionData?.session + ) { + return null; + } const authSameAsBefore = deepStrictEqual( auth, preExistingRemoteProxySessionData?.auth @@ -82,9 +88,9 @@ export async function maybeStartOrUpdateRemoteProxySession( ? { account_id: workerConfigObject.account_id } : undefined, workerConfigObject.profileDir, - context?.logger + context.logger ), - logger: context?.logger, + logger: context.logger, }); } else { const remoteBindingsAreSameAsBefore = deepStrictEqual( @@ -104,9 +110,9 @@ export async function maybeStartOrUpdateRemoteProxySession( ? { account_id: workerConfigObject.account_id } : undefined, workerConfigObject.profileDir, - context?.logger + context.logger ), - logger: context?.logger, + logger: context.logger, }); } } else { @@ -141,11 +147,8 @@ function getAuthHook( auth: AsyncHook | undefined, config: Pick | undefined, profileDir: string | undefined, - logger?: RemoteBindingsLogger + logger: RemoteBindingsLogger ): AsyncHook | undefined { - if (!logger) { - throw new Error("A logger is required to resolve remote binding auth"); - } const { auth: remoteBindingsAuth, useCfAuth } = createRemoteBindingsAuth(logger); const profileStore = useCfAuth diff --git a/packages/remote-bindings/src/start-remote-proxy-session.ts b/packages/remote-bindings/src/start-remote-proxy-session.ts index 49fc0daa0c9..03cd2f01e20 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.ts @@ -8,8 +8,9 @@ import { DeferredPromise } from "miniflare"; import { initLogger } from "./logger"; import { startWorker } from "./start-worker"; import type { RemoteBindingsLogger } from "./logger"; -import type { Worker } from "./start-worker"; import type { + AsyncHook, + CfAccount, Config, LoggerLevel, StartDevWorkerInput, @@ -24,10 +25,10 @@ type ErrorEvent = { export type StartRemoteProxySessionOptions = { workerName?: string; - auth?: NonNullable["auth"]; + auth?: AsyncHook; /** If running in a non-public compliance region, set this here. */ complianceRegion?: Config["compliance_region"]; - logger?: RemoteBindingsLogger; + logger: RemoteBindingsLogger; }; function isErrorEvent(error: unknown): error is ErrorEvent { @@ -68,12 +69,10 @@ function formatRemoteProxySessionError(error: unknown): string | undefined { export async function startRemoteProxySession( bindings: StartDevWorkerInput["bindings"], - options?: StartRemoteProxySessionOptions + options: StartRemoteProxySessionOptions ): Promise { - if (options?.logger) { - initLogger(options.logger); - } - options?.logger?.log(chalk.dim("⎔ Establishing remote connection...")); + initLogger(options.logger); + options.logger.log(chalk.dim("⎔ Establishing remote connection...")); const rawBindings = toRawBindings(bindings); const remoteBindingsWorkerPath = fileURLToPath( new URL("./proxy-worker.js", import.meta.url) @@ -106,7 +105,7 @@ export async function startRemoteProxySession( auth: options?.auth, server: { port: 0, secure: false }, inspector: false as const, - logLevel: getStartWorkerLogLevel(options?.logger?.loggerLevel ?? "error"), + logLevel: getStartWorkerLogLevel(options.logger.loggerLevel), persist: false as const, origin: {}, liveReload: false, @@ -132,26 +131,35 @@ export async function startRemoteProxySession( ); const maybeErrorPromise = new DeferredPromise<{ error: unknown }>(); - worker.raw.addListener("error", (error) => { + const onStartupError = (error: unknown) => { maybeErrorPromise.resolve({ error }); - }); - const maybeError = await Promise.race([ - maybeErrorPromise, - worker.raw.proxy.localServerReady.promise, - ]); + }; + worker.raw.addListener("error", onStartupError); + let remoteProxyConnectionString: RemoteProxyConnectionString; + try { + const maybeError = await Promise.race([ + maybeErrorPromise, + worker.raw.proxy.localServerReady.promise, + ]); - if (maybeError && maybeError.error) { - const details = formatRemoteProxySessionError(maybeError.error); - throw new Error( - details - ? `Failed to start the remote proxy session. ${details}` - : "Failed to start the remote proxy session. There is likely additional logging output above.", - { cause: maybeError.error } - ); - } + if (maybeError && maybeError.error) { + const details = formatRemoteProxySessionError(maybeError.error); + throw new Error( + details + ? `Failed to start the remote proxy session. ${details}` + : "Failed to start the remote proxy session. There is likely additional logging output above.", + { cause: maybeError.error } + ); + } - const remoteProxyConnectionString = - (await worker.url) as RemoteProxyConnectionString; + remoteProxyConnectionString = + (await worker.url) as RemoteProxyConnectionString; + } catch (error) { + await worker.dispose(); + throw error; + } finally { + worker.raw.removeListener("error", onStartupError); + } const updateBindings = async ( newBindings: StartDevWorkerInput["bindings"] ) => { @@ -183,7 +191,9 @@ export async function startRemoteProxySession( }; } -export type RemoteProxySession = Pick & { +export type RemoteProxySession = { + ready: Promise; + dispose(): Promise; updateBindings: (bindings: StartDevWorkerInput["bindings"]) => Promise; remoteProxyConnectionString: RemoteProxyConnectionString; }; diff --git a/packages/remote-bindings/src/start-worker.ts b/packages/remote-bindings/src/start-worker.ts index 6b3b5ca1d07..bc28c64a855 100644 --- a/packages/remote-bindings/src/start-worker.ts +++ b/packages/remote-bindings/src/start-worker.ts @@ -1,22 +1,10 @@ import { DevEnv } from "./startDevWorker/DevEnv"; -import type { Binding, StartDevWorkerInput } from "@cloudflare/workers-utils"; -import type { EventEmitter } from "node:events"; +import type { StartDevWorkerOptions, Worker } from "./startDevWorker/types"; -export type Worker = { - ready: Promise; - url: Promise; - dispose(): Promise; - patchConfig(config: { bindings: Record }): Promise; - raw: EventEmitter & { - proxy: { - localServerReady: { promise: Promise }; - runtimeMessageMutex: { drained(): Promise }; - }; - }; -}; +export type { Worker }; export async function startWorker( - options: StartDevWorkerInput + options: StartDevWorkerOptions ): Promise { const devEnv = new DevEnv(); diff --git a/packages/remote-bindings/src/startDevWorker/BundlerController.ts b/packages/remote-bindings/src/startDevWorker/BundlerController.ts index 7c7f93328e6..16685b574ce 100644 --- a/packages/remote-bindings/src/startDevWorker/BundlerController.ts +++ b/packages/remote-bindings/src/startDevWorker/BundlerController.ts @@ -29,8 +29,6 @@ export class BundlerController extends Controller { type: "esm", modules: [], dependencies: {}, - sourceMapPath: undefined, - sourceMapMetadata: undefined, }, }); } diff --git a/packages/remote-bindings/src/startDevWorker/DevEnv.ts b/packages/remote-bindings/src/startDevWorker/DevEnv.ts index 657ba49c095..6d62690321c 100644 --- a/packages/remote-bindings/src/startDevWorker/DevEnv.ts +++ b/packages/remote-bindings/src/startDevWorker/DevEnv.ts @@ -1,4 +1,3 @@ -import assert from "node:assert"; import { EventEmitter } from "node:events"; import { UserError } from "@cloudflare/workers-utils"; import { MiniflareCoreError } from "miniflare"; @@ -174,41 +173,11 @@ function createWorkerObject(devEnv: DevEnv): Worker { get url() { return devEnv.proxy.ready.promise.then((ev) => ev.url); }, - get inspectorUrl() { - return devEnv.proxy.ready.promise.then((ev) => ev.inspectorUrl); - }, - async setConfig(config) { - return devEnv.config.set(config); - }, patchConfig(config) { return devEnv.config.patch(config); }, - async fetch(...args) { - const { proxyWorker } = await devEnv.proxy.ready.promise; - await devEnv.proxy.runtimeMessageMutex.drained(); - - return proxyWorker.dispatchFetch(...args); - }, - async queue(...args) { - assert( - this.config.name, - "Worker name must be defined to use `Worker.queue()`" - ); - const { proxyWorker } = await devEnv.proxy.ready.promise; - const w = await proxyWorker.getWorker(this.config.name); - return w.queue(...args); - }, - async scheduled(...args) { - assert( - this.config.name, - "Worker name must be defined to use `Worker.scheduled()`" - ); - const { proxyWorker } = await devEnv.proxy.ready.promise; - const w = await proxyWorker.getWorker(this.config.name); - return w.scheduled(...args); - }, async dispose() { - await devEnv.proxy.ready.promise.finally(() => devEnv.teardown()); + await devEnv.teardown(); }, raw: devEnv, }; diff --git a/packages/remote-bindings/src/startDevWorker/ProxyController.ts b/packages/remote-bindings/src/startDevWorker/ProxyController.ts index 3f87bead45a..c549b9df03d 100644 --- a/packages/remote-bindings/src/startDevWorker/ProxyController.ts +++ b/packages/remote-bindings/src/startDevWorker/ProxyController.ts @@ -297,9 +297,15 @@ export class ProxyController extends Controller { }); } - emitErrorEvent(data: ErrorEvent): void; - emitErrorEvent(reason: string, cause?: Error | SerializedError): void; - emitErrorEvent(data: string | ErrorEvent, cause?: Error | SerializedError) { + override emitErrorEvent(data: ErrorEvent): void; + override emitErrorEvent( + reason: string, + cause?: Error | SerializedError + ): void; + override emitErrorEvent( + data: string | ErrorEvent, + cause?: Error | SerializedError + ) { if (typeof data === "string") { data = { type: "error", @@ -325,11 +331,11 @@ class ProxyControllerLogger extends WranglerLog { super(level, opts); } - logReady(message: string): void { + override logReady(message: string): void { this.localServerReady.then(() => super.logReady(message)).catch(() => {}); } - log(message: string) { + override log(message: string) { // filter out request logs being handled by the ProxyWorker // the requests log remaining are handled by the UserWorker // keep the ProxyWorker request logs if we're in debug mode diff --git a/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts b/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts index 524427166a9..8813684eb2b 100644 --- a/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts +++ b/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts @@ -424,10 +424,10 @@ export class RemoteRuntimeController extends RuntimeController { // Event Dispatchers // ********************* - emitReloadStartEvent(data: ReloadStartEvent) { + override emitReloadStartEvent(data: ReloadStartEvent) { this.bus.dispatch(data); } - emitReloadCompleteEvent(data: ReloadCompleteEvent) { + override emitReloadCompleteEvent(data: ReloadCompleteEvent) { this.bus.dispatch(data); } } diff --git a/packages/remote-bindings/src/startDevWorker/types.ts b/packages/remote-bindings/src/startDevWorker/types.ts index 9c5544413dc..4bb102ae8d2 100644 --- a/packages/remote-bindings/src/startDevWorker/types.ts +++ b/packages/remote-bindings/src/startDevWorker/types.ts @@ -1,5 +1,4 @@ import type { EsbuildBundle } from "../utils/use-esbuild"; -import type { ConfigController } from "./ConfigController"; import type { DevEnv } from "./DevEnv"; import type { ContainerNormalizedConfig } from "@cloudflare/containers-shared"; import type { @@ -21,11 +20,9 @@ import type { StartDevWorkerInput, Trigger, } from "@cloudflare/workers-utils"; -import type { DispatchFetch, Miniflare, WorkerdStructuredLog } from "miniflare"; +import type { WorkerdStructuredLog } from "miniflare"; import type * as undici from "undici"; -type MiniflareWorker = Awaited>; - /** * Extended StartDevWorkerInput with wrangler-specific fields that depend on miniflare types. * The base StartDevWorkerInput in workers-utils is kept dependency-free. @@ -42,13 +39,7 @@ export type WranglerStartDevWorkerInput = Omit & { export interface Worker { ready: Promise; url: Promise; - inspectorUrl: Promise; - config: StartDevWorkerOptions; - setConfig: ConfigController["set"]; - patchConfig: ConfigController["patch"]; - fetch: DispatchFetch; - scheduled: MiniflareWorker["scheduled"]; - queue: MiniflareWorker["queue"]; + patchConfig(config: StartDevWorkerOptions): void; dispose(): Promise; raw: DevEnv; } diff --git a/packages/remote-bindings/src/utils/create-worker-preview.ts b/packages/remote-bindings/src/utils/create-worker-preview.ts index 93236cb0998..abe9bbbba58 100644 --- a/packages/remote-bindings/src/utils/create-worker-preview.ts +++ b/packages/remote-bindings/src/utils/create-worker-preview.ts @@ -1,12 +1,11 @@ import crypto from "node:crypto"; import { URL } from "node:url"; -import { - createWorkerUploadForm, - getWorkersDevSubdomain, -} from "@cloudflare/deploy-helpers"; +import { createWorkerUploadForm } from "@cloudflare/deploy-helpers/create-worker-upload-form"; import { getAccessHeaders } from "@cloudflare/workers-auth"; import { + APIError, fetchResultBase, + getComplianceRegionSubdomain, ParseError, parseJSON, UserError, @@ -56,6 +55,41 @@ function withTimeout(signal: AbortSignal): AbortSignal { return AbortSignal.any([signal, AbortSignal.timeout(PREVIEW_API_TIMEOUT_MS)]); } +async function getOrRegisterWorkersDevSubdomain( + complianceConfig: ComplianceConfig, + account: CfAccount, + abortSignal: AbortSignal +): Promise { + const resource = `/accounts/${account.accountId}/workers/subdomain`; + try { + const { subdomain } = await fetchResult<{ subdomain: string }>( + complianceConfig, + account, + resource, + undefined, + abortSignal + ); + return subdomain; + } catch (error) { + if (!(error instanceof APIError) || error.code !== 10007) { + throw error; + } + } + + const subdomain = crypto.randomBytes(4).toString("hex"); + const result = await fetchResult<{ subdomain: string }>( + complianceConfig, + account, + resource, + { + method: "PUT", + body: JSON.stringify({ subdomain }), + }, + abortSignal + ); + return result.subdomain; +} + /** * A Cloudflare account. */ @@ -232,14 +266,12 @@ export async function createPreviewSession( try { let host = ctx.host; if (!host) { - const subdomain = await getWorkersDevSubdomain( + const subdomain = await getOrRegisterWorkersDevSubdomain( complianceConfig, - account.accountId, - { - abortSignal: withTimeout(abortSignal), - } + account, + withTimeout(abortSignal) ); - host = `${name ?? crypto.randomUUID()}.${subdomain}`; + host = `${name ?? crypto.randomUUID()}.${subdomain}${getComplianceRegionSubdomain(complianceConfig)}.workers.dev`; } return { value: previewSessionToken, diff --git a/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts b/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts index 4af39237696..b08850ad7ca 100644 --- a/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts +++ b/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts @@ -163,10 +163,6 @@ export class ProxyWorker implements DurableObject { await checkForPreviewTokenError(res, this.env, proxyData); - if (isHtmlResponse(res)) { - res = insertLiveReloadScript(request, res, this.env, proxyData); - } - if (isSseResponse(res)) { void sendMessageToProxyController(this.env, { type: "sseResponseDetected", @@ -236,9 +232,6 @@ export class ProxyWorker implements DurableObject { function isRequestFromProxyController(req: Request, env: Env): boolean { return req.headers.get("Authorization") === env.PROXY_CONTROLLER_AUTH_SECRET; } -function isHtmlResponse(res: Response): boolean { - return res.headers.get("content-type")?.startsWith("text/html") ?? false; -} function isSseResponse(res: Response): boolean { return ( res.headers.get("content-type")?.startsWith("text/event-stream") ?? false @@ -293,53 +286,6 @@ async function checkForPreviewTokenError( }); } } - -function insertLiveReloadScript( - request: Request, - response: Response, - env: Env, - proxyData: ProxyData -) { - const htmlRewriter = new HTMLRewriter(); - - htmlRewriter.onDocument({ - end(end) { - // if liveReload enabled, append a script tag - // TODO: compare to existing nodejs implementation - if (proxyData.liveReload) { - const websocketUrl = new URL(request.url); - websocketUrl.protocol = - websocketUrl.protocol === "http:" ? "ws:" : "wss:"; - - end.append(liveReloadScript, { html: true }); - } - }, - }); - - return htmlRewriter.transform(response); -} - -const liveReloadScript = ` - -`; - /** * Rewrite references to URLs in request/response headers. * diff --git a/packages/remote-bindings/tsdown.config.ts b/packages/remote-bindings/tsdown.config.ts index fb142c51867..6e730d98bc4 100644 --- a/packages/remote-bindings/tsdown.config.ts +++ b/packages/remote-bindings/tsdown.config.ts @@ -9,7 +9,10 @@ export default defineConfig([ outDir: "dist", dts: true, tsconfig: "tsconfig.json", - external: ["miniflare", /^@cloudflare\/workers-utils/], + define: { + __filename: "import.meta.filename", + }, + external: ["miniflare"], }, { entry: { diff --git a/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts b/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts index 1b19e833d3b..2acc90c9663 100644 --- a/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts +++ b/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts @@ -783,7 +783,7 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => { }); expect(sessionOptions).toBeDefined(); assert(sessionOptions); - const { auth, ...rest1 } = sessionOptions; + const { auth, logger: _logger, ...rest1 } = sessionOptions; expect(rest1).toEqual({ complianceRegion: undefined, workerName: "worker", @@ -827,7 +827,7 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => { expect(sessionOptions).toBeDefined(); assert(sessionOptions); - const { auth: auth2, ...rest2 } = sessionOptions; + const { auth: auth2, logger: _logger, ...rest2 } = sessionOptions; expect(rest2).toEqual({ complianceRegion: undefined, workerName: "worker", diff --git a/packages/wrangler/src/api/remoteBindings/index.ts b/packages/wrangler/src/api/remoteBindings/index.ts index 092cec0fd22..68abb047c05 100644 --- a/packages/wrangler/src/api/remoteBindings/index.ts +++ b/packages/wrangler/src/api/remoteBindings/index.ts @@ -3,6 +3,7 @@ import { getCloudflareComplianceRegion } from "@cloudflare/workers-utils"; import { readConfig } from "../../config"; import { logger } from "../../logger"; import { convertConfigBindingsToStartWorkerBindings } from "../startDevWorker"; +import { startRemoteProxySession } from "./start-remote-proxy-session"; import type { RemoteProxySessionData, WorkerConfigObject, @@ -10,6 +11,8 @@ import type { import type { AsyncHook, CfAccount } from "@cloudflare/workers-utils"; export * from "@cloudflare/remote-bindings"; +export { startRemoteProxySession } from "./start-remote-proxy-session"; +export type { StartRemoteProxySessionOptions } from "./start-remote-proxy-session"; type WranglerConfigObject = { path: string; @@ -38,6 +41,7 @@ export function maybeStartOrUpdateRemoteProxySession( wranglerOrWorkerConfigObject, preExistingRemoteProxySessionData, auth, - { logger } + { logger }, + startRemoteProxySession ); } diff --git a/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts b/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts index 52ecde205cd..cca27505677 100644 --- a/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts +++ b/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts @@ -1 +1,19 @@ -export * from "@cloudflare/remote-bindings"; +import { startRemoteProxySession as startRemoteProxySessionFromPackage } from "@cloudflare/remote-bindings"; +import { logger } from "../../logger"; +import type { + StartRemoteProxySessionOptions as PackageStartRemoteProxySessionOptions, + RemoteProxySession, +} from "@cloudflare/remote-bindings"; +import type { StartDevWorkerInput } from "@cloudflare/workers-utils"; + +export type StartRemoteProxySessionOptions = Omit< + PackageStartRemoteProxySessionOptions, + "logger" +>; + +export function startRemoteProxySession( + bindings: StartDevWorkerInput["bindings"], + options: StartRemoteProxySessionOptions = {} +): Promise { + return startRemoteProxySessionFromPackage(bindings, { ...options, logger }); +} From 3b1b6f42c689f87b06f174df2e06cca1dc7cc56e Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 16 Jul 2026 15:14:55 +0100 Subject: [PATCH 22/37] [remote-bindings] Remove dead dev runtime code --- .../src/start-remote-proxy-session.ts | 1 - .../src/startDevWorker/BaseController.ts | 12 - .../src/startDevWorker/DevEnv.ts | 1 - .../src/startDevWorker/ProxyController.ts | 17 - .../startDevWorker/RemoteRuntimeController.ts | 8 +- .../src/startDevWorker/events.ts | 27 +- .../src/startDevWorker/types.ts | 34 -- .../src/startDevWorker/utils.ts | 292 +----------------- packages/remote-bindings/src/utils/remote.ts | 9 +- .../templates/startDevWorker/ProxyWorker.ts | 49 +-- 10 files changed, 11 insertions(+), 439 deletions(-) diff --git a/packages/remote-bindings/src/start-remote-proxy-session.ts b/packages/remote-bindings/src/start-remote-proxy-session.ts index 03cd2f01e20..70126511cb6 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.ts @@ -108,7 +108,6 @@ export async function startRemoteProxySession( logLevel: getStartWorkerLogLevel(options.logger.loggerLevel), persist: false as const, origin: {}, - liveReload: false, }, }; diff --git a/packages/remote-bindings/src/startDevWorker/BaseController.ts b/packages/remote-bindings/src/startDevWorker/BaseController.ts index e3c591145ad..0913848e653 100644 --- a/packages/remote-bindings/src/startDevWorker/BaseController.ts +++ b/packages/remote-bindings/src/startDevWorker/BaseController.ts @@ -3,13 +3,11 @@ import type { BundleCompleteEvent, BundleStartEvent, ConfigUpdateEvent, - DevRegistryUpdateEvent, ErrorEvent, PreviewTokenExpiredEvent, ReloadCompleteEvent, ReloadStartEvent, } from "./events"; -import type { Miniflare } from "miniflare"; export type ControllerEvent = | ErrorEvent @@ -18,7 +16,6 @@ export type ControllerEvent = | BundleCompleteEvent | ReloadStartEvent | ReloadCompleteEvent - | DevRegistryUpdateEvent | PreviewTokenExpiredEvent; export interface ControllerBus { @@ -58,11 +55,6 @@ export abstract class RuntimeController extends Controller { abstract onBundleComplete(_: BundleCompleteEvent): void; abstract onPreviewTokenExpired(_: PreviewTokenExpiredEvent): void; - // ********************* - // Runtime Accessors - // ********************* - abstract get mf(): Miniflare | undefined; - // ********************* // Event Dispatchers // ********************* @@ -74,8 +66,4 @@ export abstract class RuntimeController extends Controller { protected emitReloadCompleteEvent(data: ReloadCompleteEvent): void { this.bus.dispatch(data); } - - protected emitDevRegistryUpdateEvent(data: DevRegistryUpdateEvent): void { - this.bus.dispatch(data); - } } diff --git a/packages/remote-bindings/src/startDevWorker/DevEnv.ts b/packages/remote-bindings/src/startDevWorker/DevEnv.ts index 6d62690321c..f56f45e285a 100644 --- a/packages/remote-bindings/src/startDevWorker/DevEnv.ts +++ b/packages/remote-bindings/src/startDevWorker/DevEnv.ts @@ -74,7 +74,6 @@ export class DevEnv extends EventEmitter implements ControllerBus { * - BundlerController emits bundleComplete → RuntimeControllers * - RuntimeController emits reloadStart → ProxyController * - RuntimeController emits reloadComplete → ProxyController - * - RuntimeController emits devRegistryUpdate → ConfigController * - ProxyController emits previewTokenExpired → RuntimeControllers * - Any controller emits error → DevEnv error handler * diff --git a/packages/remote-bindings/src/startDevWorker/ProxyController.ts b/packages/remote-bindings/src/startDevWorker/ProxyController.ts index c549b9df03d..ccd86865d77 100644 --- a/packages/remote-bindings/src/startDevWorker/ProxyController.ts +++ b/packages/remote-bindings/src/startDevWorker/ProxyController.ts @@ -109,7 +109,6 @@ export class ProxyController extends Controller { this.localServerReady.promise ), handleStructuredLogs, - liveReload: false, }; const proxyWorkerOptionsChanged = didMiniflareOptionsChange( @@ -237,22 +236,6 @@ export class ProxyController extends Controller { case "error": this.emitErrorEvent("Error inside ProxyWorker", message.error); - break; - case "debug-log": - logger.debug("[ProxyWorker]", ...message.args); - - break; - case "sseResponseDetected": - // Only warn about SSE if a quick tunnel is active - if ( - this.latestConfig?.dev?.tunnel?.enabled && - this.latestConfig.dev.tunnel.name === undefined - ) { - logger.once.warn( - "Quick tunnels do not support Server-Sent Events (SSE). Use a named Cloudflare Tunnel if you need SSE over a public URL." - ); - } - break; default: assertNever(message); diff --git a/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts b/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts index 8813684eb2b..baa438abeac 100644 --- a/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts +++ b/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts @@ -5,7 +5,7 @@ import { retryOnAPIFailure, } from "@cloudflare/workers-utils"; import chalk from "chalk"; -import { Mutex, type Miniflare } from "miniflare"; +import { Mutex } from "miniflare"; import { WebSocket } from "ws"; import { version as packageVersion } from "../../package.json"; import { logger } from "../logger"; @@ -255,8 +255,6 @@ export class RemoteRuntimeController extends RuntimeController { ...accessHeaders, "cf-connecting-ip": "", }, - liveReload: config.dev.liveReload, - proxyLogsToController: true, }; this.#latestProxyData = proxyData; @@ -400,10 +398,6 @@ export class RemoteRuntimeController extends RuntimeController { void this.#mutex.runWith(() => this.#refreshPreviewToken()); } - override get mf(): Miniflare | undefined { - return undefined; - } - override async teardown() { await super.teardown(); if (this.#session) { diff --git a/packages/remote-bindings/src/startDevWorker/events.ts b/packages/remote-bindings/src/startDevWorker/events.ts index a41e778155c..2027f6e4c44 100644 --- a/packages/remote-bindings/src/startDevWorker/events.ts +++ b/packages/remote-bindings/src/startDevWorker/events.ts @@ -1,5 +1,5 @@ import type { Bundle, StartDevWorkerOptions } from "./types"; -import type { Miniflare, WorkerRegistry } from "miniflare"; +import type { Miniflare } from "miniflare"; export type ErrorEvent = | BaseErrorEvent< @@ -72,12 +72,6 @@ export type ReloadCompleteEvent = { bundle: Bundle; proxyData: ProxyData; }; -export type DevRegistryUpdateEvent = { - type: "devRegistryUpdate"; - - registry: WorkerRegistry; -}; - // ProxyController export type PreviewTokenExpiredEvent = { type: "previewTokenExpired"; @@ -98,9 +92,7 @@ export type ProxyWorkerIncomingRequestBody = | { type: "pause" }; export type ProxyWorkerOutgoingRequestBody = | { type: "error"; error: SerializedError } - | { type: "sseResponseDetected" } - | { type: "previewTokenExpired"; proxyData: ProxyData } - | { type: "debug-log"; args: Parameters }; + | { type: "previewTokenExpired"; proxyData: ProxyData }; export type SerializedError = { message: string; @@ -108,19 +100,6 @@ export type SerializedError = { stack?: string | undefined; cause?: unknown; }; -export function serialiseError(e: unknown): SerializedError { - if (e instanceof Error) { - return { - message: e.message, - name: e.name, - stack: e.stack, - cause: e.cause && serialiseError(e.cause), - }; - } else { - return { message: String(e) }; - } -} - export type UrlOriginParts = Pick; export type UrlOriginAndPathnameParts = Pick< URL, @@ -132,6 +111,4 @@ export type ProxyData = { userWorkerInspectorUrl?: UrlOriginAndPathnameParts; userWorkerInnerUrlOverrides?: Partial; headers: Record; - liveReload?: boolean; - proxyLogsToController?: boolean; }; diff --git a/packages/remote-bindings/src/startDevWorker/types.ts b/packages/remote-bindings/src/startDevWorker/types.ts index 4bb102ae8d2..d4c6d90104e 100644 --- a/packages/remote-bindings/src/startDevWorker/types.ts +++ b/packages/remote-bindings/src/startDevWorker/types.ts @@ -4,38 +4,17 @@ import type { ContainerNormalizedConfig } from "@cloudflare/containers-shared"; import type { AsyncHook, AssetsOptions, - BinaryFile, - Binding, CfAccount, CfModule, CfScriptFormat, Config, - File, - Hook, - HookValues, - LogLevel, NodeJSCompatMode, Rule, - ServiceFetch, StartDevWorkerInput, - Trigger, } from "@cloudflare/workers-utils"; import type { WorkerdStructuredLog } from "miniflare"; import type * as undici from "undici"; -/** - * Extended StartDevWorkerInput with wrangler-specific fields that depend on miniflare types. - * The base StartDevWorkerInput in workers-utils is kept dependency-free. - */ -export type WranglerStartDevWorkerInput = Omit & { - dev?: StartDevWorkerInput["dev"] & { - /** Handles structured runtime logs. */ - structuredLogsHandler?: (log: WorkerdStructuredLog) => void; - /** An undici MockAgent to declaratively mock fetch calls to particular resources. */ - mockFetch?: undici.MockAgent; - }; -}; - export interface Worker { ready: Promise; url: Promise; @@ -82,16 +61,3 @@ export type StartDevWorkerOptions = Omit< }; export type Bundle = EsbuildBundle; - -export type { - StartDevWorkerInput, - Trigger, - Binding, - File, - BinaryFile, - ServiceFetch, - HookValues, - Hook, - AsyncHook, - LogLevel, -}; diff --git a/packages/remote-bindings/src/startDevWorker/utils.ts b/packages/remote-bindings/src/startDevWorker/utils.ts index 9be8f5c6849..1bc9704c3d4 100644 --- a/packages/remote-bindings/src/startDevWorker/utils.ts +++ b/packages/remote-bindings/src/startDevWorker/utils.ts @@ -1,15 +1,5 @@ import assert from "node:assert"; -import { readFile } from "node:fs/promises"; -import type { - Binding, - File, - Hook, - HookValues, - StartDevWorkerOptions, -} from "./types"; -import type { WorkerMetadataBinding } from "@cloudflare/workers-utils"; - -export function assertNever(_value: never) {} +import type { Hook, HookValues } from "@cloudflare/workers-utils"; /** * When to proactively refresh the preview token. @@ -109,283 +99,3 @@ export function unwrapHook< >(hook: UnwrapHook, ...args: Args): T { return typeof hook === "function" ? hook(...args) : hook; } - -export async function getBinaryFileContents(file: File) { - if ("contents" in file) { - if (file.contents instanceof Buffer) { - return file.contents; - } - return Buffer.from(file.contents); - } - return readFile(file.path); -} - -/** - * Convert WorkerMetadataBinding[] (API format) to flat bindings format (Record) - * - * WorkerMetadataBinding uses different field names than Binding: - * - KV: namespace_id -> id - * - D1: id -> database_id - * - plain_text/json: text/json -> value - * - dispatch_namespace: outbound.worker.service -> outbound.service - */ -export function convertWorkerMetadataBindingsToFlatBindings( - bindings: WorkerMetadataBinding[] -): StartDevWorkerOptions["bindings"] { - const output: StartDevWorkerOptions["bindings"] = {}; - - for (const binding of bindings) { - const { name, type } = binding; - - switch (type) { - case "plain_text": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "plain_text" } - >; - output[name] = { type: "plain_text", value: b.text }; - break; - } - case "secret_text": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "secret_text" } - >; - output[name] = { type: "secret_text", value: b.text }; - break; - } - case "json": { - const b = binding as Extract; - output[name] = { type: "json", value: b.json }; - break; - } - case "kv_namespace": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "kv_namespace" } - >; - output[name] = { type: "kv_namespace", id: b.namespace_id, raw: b.raw }; - break; - } - case "d1": { - const b = binding as Extract; - output[name] = { - type: "d1", - database_id: b.id, - database_internal_env: b.internalEnv, - raw: b.raw, - }; - break; - } - case "dispatch_namespace": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "dispatch_namespace" } - >; - output[name] = { - type: "dispatch_namespace", - namespace: b.namespace, - outbound: b.outbound - ? { - service: b.outbound.worker.service, - environment: b.outbound.worker.environment, - parameters: b.outbound.params?.map((p) => p.name), - } - : undefined, - }; - break; - } - case "durable_object_namespace": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "durable_object_namespace" } - >; - output[name] = { - type: "durable_object_namespace", - class_name: b.class_name, - script_name: b.script_name, - environment: b.environment, - }; - break; - } - case "workflow": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "workflow" } - >; - output[name] = { - type: "workflow", - name: b.workflow_name, - class_name: b.class_name, - script_name: b.script_name, - raw: b.raw, - }; - break; - } - case "queue": { - const b = binding as Extract; - output[name] = { - type: "queue", - queue_name: b.queue_name, - delivery_delay: b.delivery_delay, - raw: b.raw, - }; - break; - } - case "r2_bucket": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "r2_bucket" } - >; - output[name] = { - type: "r2_bucket", - bucket_name: b.bucket_name, - jurisdiction: b.jurisdiction, - raw: b.raw, - }; - break; - } - case "service": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "service" } - >; - output[name] = { - type: "service", - service: b.service, - environment: b.environment, - entrypoint: b.entrypoint, - cross_account_grant: b.cross_account_grant, - }; - break; - } - case "analytics_engine": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "analytics_engine" } - >; - output[name] = { type: "analytics_engine", dataset: b.dataset }; - break; - } - case "vectorize": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "vectorize" } - >; - output[name] = { - type: "vectorize", - index_name: b.index_name, - raw: b.raw, - }; - break; - } - case "ai_search_namespace": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "ai_search_namespace" } - >; - output[name] = { - type: "ai_search_namespace", - namespace: b.namespace, - }; - break; - } - case "ai_search": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "ai_search" } - >; - output[name] = { - type: "ai_search", - instance_name: b.instance_name, - }; - break; - } - case "agent_memory": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "agent_memory" } - >; - output[name] = { - type: "agent_memory", - namespace: b.namespace, - }; - break; - } - case "hyperdrive": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "hyperdrive" } - >; - output[name] = { type: "hyperdrive", id: b.id }; - break; - } - case "send_email": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "send_email" } - >; - // CfSendEmailBindings uses a discriminated union, pass through the relevant fields - const emailBinding: Record = { type: "send_email" }; - if ("destination_address" in b && b.destination_address) { - emailBinding.destination_address = b.destination_address; - } - if ( - "allowed_destination_addresses" in b && - b.allowed_destination_addresses - ) { - emailBinding.allowed_destination_addresses = - b.allowed_destination_addresses; - } - if ("allowed_sender_addresses" in b && b.allowed_sender_addresses) { - emailBinding.allowed_sender_addresses = b.allowed_sender_addresses; - } - output[name] = emailBinding as Binding; - break; - } - case "mtls_certificate": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "mtls_certificate" } - >; - output[name] = { - type: "mtls_certificate", - certificate_id: b.certificate_id, - }; - break; - } - case "pipelines": { - const b = binding as Extract< - WorkerMetadataBinding, - { type: "pipelines" } - >; - output[name] = { - type: "pipeline", - stream: b.stream, - pipeline: b.pipeline, - }; - break; - } - case "browser": - case "ai": - case "images": - case "stream": - case "version_metadata": - case "media": - case "websearch": - case "inherit": { - // These have the same structure (just type and possibly some flags) - const { name: _name, ...rest } = binding; - output[name] = rest as Binding; - break; - } - default: { - // For any other binding types, pass through as-is - const { name: _name, ...rest } = binding; - output[name] = rest as Binding; - } - } - } - - return output; -} diff --git a/packages/remote-bindings/src/utils/remote.ts b/packages/remote-bindings/src/utils/remote.ts index d8f1e88e15e..bbc68e96cd6 100644 --- a/packages/remote-bindings/src/utils/remote.ts +++ b/packages/remote-bindings/src/utils/remote.ts @@ -4,11 +4,14 @@ import { getAuthFromEnv } from "@cloudflare/workers-auth"; import { APIError, UserError } from "@cloudflare/workers-utils"; import { logger } from "../logger"; import { isAbortError } from "./isAbortError"; -import type { StartDevWorkerInput } from "../startDevWorker/types"; import type { CfAccount } from "./create-worker-preview"; import type { EsbuildBundle } from "./use-esbuild"; -import type { ApiCredentials } from "@cloudflare/workers-utils"; -import type { CfWorkerContext, CfWorkerInit } from "@cloudflare/workers-utils"; +import type { + ApiCredentials, + CfWorkerContext, + CfWorkerInit, + StartDevWorkerInput, +} from "@cloudflare/workers-utils"; /** * Error thrown when a remote dev session fails due to an authentication diff --git a/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts b/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts index b08850ad7ca..70beec0cce4 100644 --- a/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts +++ b/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts @@ -23,8 +23,6 @@ type Request = Parameters< > >[0]; -const LIVE_RELOAD_PROTOCOL = "WRANGLER_PROXYWORKER_LIVE_RELOAD_PROTOCOL"; -const LIVE_RELOAD_PATHNAME = "/cdn-cgi/live-reload"; export default { fetch(req, env) { const singleton = env.DURABLE_OBJECT.idFromName(""); @@ -36,7 +34,7 @@ export default { export class ProxyWorker implements DurableObject { constructor( - readonly state: DurableObjectState, + _state: DurableObjectState, readonly env: Env ) {} @@ -45,12 +43,6 @@ export class ProxyWorker implements DurableObject { requestRetryQueue = new Map>(); fetch(request: Request) { - if (isRequestForLiveReloadWebsocket(request)) { - // requests for live-reload websocket - - return this.handleLiveReloadWebSocket(request); - } - if (isRequestFromProxyController(request, this.env)) { // requests from ProxyController @@ -66,20 +58,6 @@ export class ProxyWorker implements DurableObject { return deferred.promise; } - handleLiveReloadWebSocket(request: Request) { - const { 0: response, 1: liveReload } = new WebSocketPair(); - const websocketProtocol = - request.headers.get("Sec-WebSocket-Protocol") ?? ""; - - this.state.acceptWebSocket(liveReload, ["live-reload"]); - - return new Response(null, { - status: 101, - webSocket: response, - headers: { "Sec-WebSocket-Protocol": websocketProtocol }, - }); - } - processProxyControllerRequest(request: Request) { const event = request.cf?.hostMetadata; switch (event?.type) { @@ -90,9 +68,6 @@ export class ProxyWorker implements DurableObject { case "play": this.proxyData = event.proxyData; this.processQueue(); - this.state - .getWebSockets("live-reload") - .forEach((ws) => ws.send("reload")); break; } @@ -163,12 +138,6 @@ export class ProxyWorker implements DurableObject { await checkForPreviewTokenError(res, this.env, proxyData); - if (isSseResponse(res)) { - void sendMessageToProxyController(this.env, { - type: "sseResponseDetected", - }); - } - deferredResponse.resolve(res); }) .catch((error: Error) => { @@ -232,22 +201,6 @@ export class ProxyWorker implements DurableObject { function isRequestFromProxyController(req: Request, env: Env): boolean { return req.headers.get("Authorization") === env.PROXY_CONTROLLER_AUTH_SECRET; } -function isSseResponse(res: Response): boolean { - return ( - res.headers.get("content-type")?.startsWith("text/event-stream") ?? false - ); -} -function isRequestForLiveReloadWebsocket(req: Request): boolean { - if (new URL(req.url).pathname !== LIVE_RELOAD_PATHNAME) { - return false; - } - - const websocketProtocol = req.headers.get("Sec-WebSocket-Protocol"); - const isWebSocketUpgrade = req.headers.get("Upgrade") === "websocket"; - - return isWebSocketUpgrade && websocketProtocol === LIVE_RELOAD_PROTOCOL; -} - function sendMessageToProxyController( env: Env, message: ProxyWorkerOutgoingRequestBody From 4d5c38771b912433f38da332ee81010d95af2904 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 16 Jul 2026 15:32:33 +0100 Subject: [PATCH 23/37] [remote-bindings] Narrow remote runtime internals --- packages/remote-bindings/package.json | 1 - .../src/start-remote-proxy-session.ts | 39 +--------- .../src/startDevWorker/BundlerController.ts | 13 ---- .../src/startDevWorker/DevEnv.ts | 50 +++--------- .../src/startDevWorker/ProxyController.ts | 78 ++++--------------- .../src/startDevWorker/events.ts | 20 +---- .../src/startDevWorker/types.ts | 63 +++++---------- packages/remote-bindings/src/utils/remote.ts | 4 +- .../remote-bindings/src/utils/use-esbuild.ts | 12 --- pnpm-lock.yaml | 3 - 10 files changed, 54 insertions(+), 229 deletions(-) delete mode 100644 packages/remote-bindings/src/utils/use-esbuild.ts diff --git a/packages/remote-bindings/package.json b/packages/remote-bindings/package.json index bbf50aac8ce..c5c7cc3474c 100644 --- a/packages/remote-bindings/package.json +++ b/packages/remote-bindings/package.json @@ -33,7 +33,6 @@ }, "devDependencies": { "@cloudflare/cli-shared-helpers": "workspace:*", - "@cloudflare/containers-shared": "workspace:*", "@cloudflare/deploy-helpers": "workspace:*", "@cloudflare/workers-auth": "workspace:*", "@cloudflare/workers-tsconfig": "workspace:*", diff --git a/packages/remote-bindings/src/start-remote-proxy-session.ts b/packages/remote-bindings/src/start-remote-proxy-session.ts index 70126511cb6..8fcee763881 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.ts @@ -1,6 +1,5 @@ import { randomUUID } from "node:crypto"; import events from "node:events"; -import path from "node:path"; import { fileURLToPath } from "node:url"; import { UserError } from "@cloudflare/workers-utils"; import chalk from "chalk"; @@ -12,7 +11,6 @@ import type { AsyncHook, CfAccount, Config, - LoggerLevel, StartDevWorkerInput, } from "@cloudflare/workers-utils"; import type { RemoteProxyConnectionString } from "miniflare"; @@ -77,37 +75,17 @@ export async function startRemoteProxySession( const remoteBindingsWorkerPath = fileURLToPath( new URL("./proxy-worker.js", import.meta.url) ); - const moduleRoot = path.dirname(remoteBindingsWorkerPath); const workerConfig = { - name: options?.workerName ?? randomUUID(), + name: options.workerName ?? randomUUID(), entrypoint: remoteBindingsWorkerPath, - projectRoot: moduleRoot, compatibilityDate: "2025-04-28", compatibilityFlags: [], - complianceRegion: options?.complianceRegion, + complianceRegion: options.complianceRegion, bindings: rawBindings, - triggers: [], - build: { - bundle: false, - additionalModules: [], - processEntrypoint: false, - findAdditionalModules: false, - moduleRoot, - moduleRules: [], - define: {}, - format: "modules" as const, - nodejsCompatMode: null, - exports: [], - }, - legacy: {}, dev: { remote: "minimal" as const, - auth: options?.auth, + auth: options.auth, server: { port: 0, secure: false }, - inspector: false as const, - logLevel: getStartWorkerLogLevel(options.logger.loggerLevel), - persist: false as const, - origin: {}, }, }; @@ -205,14 +183,3 @@ function toRawBindings(bindings: StartDevWorkerInput["bindings"]) { ]) ); } - -function getStartWorkerLogLevel(wranglerLogLevel: LoggerLevel): LoggerLevel { - switch (wranglerLogLevel) { - case "debug": - return "debug"; - case "none": - return "none"; - default: - return "error"; - } -} diff --git a/packages/remote-bindings/src/startDevWorker/BundlerController.ts b/packages/remote-bindings/src/startDevWorker/BundlerController.ts index 16685b574ce..f6f286f6007 100644 --- a/packages/remote-bindings/src/startDevWorker/BundlerController.ts +++ b/packages/remote-bindings/src/startDevWorker/BundlerController.ts @@ -1,5 +1,4 @@ import { readFileSync } from "node:fs"; -import path from "node:path"; import { Controller } from "./BaseController"; import type { ConfigUpdateEvent } from "./events"; @@ -8,27 +7,15 @@ export class BundlerController extends Controller { this.bus.dispatch({ type: "bundleStart", config }); const entrypointSource = readFileSync(config.entrypoint, "utf8"); - const moduleRoot = path.dirname(config.entrypoint); this.bus.dispatch({ type: "bundleComplete", config, bundle: { - id: 0, path: config.entrypoint, entrypointSource, - entry: { - file: config.entrypoint, - projectRoot: moduleRoot, - configPath: undefined, - format: "modules", - moduleRoot, - name: config.name, - exports: [], - }, type: "esm", modules: [], - dependencies: {}, }, }); } diff --git a/packages/remote-bindings/src/startDevWorker/DevEnv.ts b/packages/remote-bindings/src/startDevWorker/DevEnv.ts index f56f45e285a..425076ae08b 100644 --- a/packages/remote-bindings/src/startDevWorker/DevEnv.ts +++ b/packages/remote-bindings/src/startDevWorker/DevEnv.ts @@ -6,21 +6,14 @@ import { BundlerController } from "./BundlerController"; import { ConfigController } from "./ConfigController"; import { ProxyController } from "./ProxyController"; import { RemoteRuntimeController } from "./RemoteRuntimeController"; -import type { - Controller, - ControllerBus, - ControllerEvent, - RuntimeController, -} from "./BaseController"; +import type { ControllerBus, ControllerEvent } from "./BaseController"; import type { ErrorEvent } from "./events"; import type { StartDevWorkerOptions, Worker } from "./types"; -type ControllerFactory = (devEnv: DevEnv) => C; - export class DevEnv extends EventEmitter implements ControllerBus { config: ConfigController; bundler: BundlerController; - runtimes: RuntimeController[]; + runtime: RemoteRuntimeController; proxy: ProxyController; async startWorker(options: StartDevWorkerOptions): Promise { @@ -40,23 +33,13 @@ export class DevEnv extends EventEmitter implements ControllerBus { return worker; } - constructor({ - configFactory = (devEnv) => new ConfigController(devEnv), - bundlerFactory = (devEnv) => new BundlerController(devEnv), - runtimeFactories = [(devEnv) => new RemoteRuntimeController(devEnv)], - proxyFactory = (devEnv) => new ProxyController(devEnv), - }: { - configFactory?: ControllerFactory; - bundlerFactory?: ControllerFactory; - runtimeFactories?: ControllerFactory[]; - proxyFactory?: ControllerFactory; - } = {}) { + constructor() { super(); - this.config = configFactory(this); - this.bundler = bundlerFactory(this); - this.runtimes = runtimeFactories.map((factory) => factory(this)); - this.proxy = proxyFactory(this); + this.config = new ConfigController(this); + this.bundler = new BundlerController(this); + this.runtime = new RemoteRuntimeController(this); + this.proxy = new ProxyController(this); this.on("error", (event: ErrorEvent) => { logger.debug(`Error in ${event.source}: ${event.reason}\n`, event.cause); @@ -94,15 +77,11 @@ export class DevEnv extends EventEmitter implements ControllerBus { case "bundleStart": this.proxy.onBundleStart(event); - this.runtimes.forEach((runtime) => { - runtime.onBundleStart(event); - }); + this.runtime.onBundleStart(event); break; case "bundleComplete": - this.runtimes.forEach((runtime) => { - runtime.onBundleComplete(event); - }); + this.runtime.onBundleComplete(event); break; case "reloadStart": @@ -115,9 +94,7 @@ export class DevEnv extends EventEmitter implements ControllerBus { break; case "previewTokenExpired": - this.runtimes.forEach((runtime) => { - runtime.onPreviewTokenExpired(event); - }); + this.runtime.onPreviewTokenExpired(event); break; } } @@ -135,8 +112,7 @@ export class DevEnv extends EventEmitter implements ControllerBus { ); } else if ( event.source === "ProxyController" && - (event.reason.startsWith("Failed to send message to") || - event.reason.startsWith("Could not connect to InspectorProxyWorker")) + event.reason.startsWith("Failed to send message to") ) { logger.debug(`Error in ${event.source}: ${event.reason}\n`, event.cause); logger.debug("=> Error contextual data:", event.data); @@ -154,12 +130,10 @@ export class DevEnv extends EventEmitter implements ControllerBus { await Promise.all([ this.config.teardown(), this.bundler.teardown(), - ...this.runtimes.map((runtime) => runtime.teardown()), + this.runtime.teardown(), this.proxy.teardown(), ]); - this.emit("teardown"); - logger.debug("DevEnv teardown complete"); } } diff --git a/packages/remote-bindings/src/startDevWorker/ProxyController.ts b/packages/remote-bindings/src/startDevWorker/ProxyController.ts index ccd86865d77..9a256ab189c 100644 --- a/packages/remote-bindings/src/startDevWorker/ProxyController.ts +++ b/packages/remote-bindings/src/startDevWorker/ProxyController.ts @@ -13,7 +13,6 @@ import { import { Controller } from "./BaseController"; import { castErrorCause } from "./events"; import { createDeferred } from "./utils"; -import type { EsbuildBundle } from "../utils/use-esbuild"; import type { BundleStartEvent, ConfigUpdateEvent, @@ -26,7 +25,7 @@ import type { ReloadStartEvent, SerializedError, } from "./events"; -import type { StartDevWorkerOptions } from "./types"; +import type { Bundle, StartDevWorkerOptions } from "./types"; import type { LogOptions, MiniflareOptions } from "miniflare"; const proxyWorkerPath = fileURLToPath( @@ -39,15 +38,14 @@ export class ProxyController extends Controller { public localServerReady = createDeferred(); public proxyWorker?: Miniflare; - proxyWorkerOptions?: MiniflareOptions; protected latestConfig?: StartDevWorkerOptions; - protected latestBundle?: EsbuildBundle; + protected latestBundle?: Bundle; secret = randomUUID(); protected createProxyWorker() { - if (this._torndown) { + if (this._torndown || this.proxyWorker) { return; } assert(this.latestConfig !== undefined); @@ -111,44 +109,20 @@ export class ProxyController extends Controller { handleStructuredLogs, }; - const proxyWorkerOptionsChanged = didMiniflareOptionsChange( - this.proxyWorkerOptions, - proxyWorkerOptions - ); - - const willInstantiateMiniflareInstance = - !this.proxyWorker || proxyWorkerOptionsChanged; - this.proxyWorker ??= new Miniflare(proxyWorkerOptions); - this.proxyWorkerOptions = proxyWorkerOptions; - - if (proxyWorkerOptionsChanged) { - logger.debug("ProxyWorker miniflare options changed, reinstantiating..."); + const proxyWorker = new Miniflare(proxyWorkerOptions); + this.proxyWorker = proxyWorker; - void this.proxyWorker.setOptions(proxyWorkerOptions).catch((error) => { + void proxyWorker.ready + .then((url) => { + assert(url); + this.emitReadyEvent(proxyWorker, url); + }) + .catch((error) => { + if (this._torndown) { + return; + } this.emitErrorEvent("Failed to start ProxyWorker", error); }); - - // this creates a new .ready promise that will be resolved when both ProxyWorkers are ready - // it also respects any await-ers of the existing .ready promise - this.ready = createDeferred(this.ready); - } - - // store the non-null versions for callbacks - const { proxyWorker } = this; - - if (willInstantiateMiniflareInstance) { - void proxyWorker.ready - .then((url) => { - assert(url); - this.emitReadyEvent(proxyWorker, url, undefined); - }) - .catch((error) => { - if (this._torndown) { - return; - } - this.emitErrorEvent("Failed to start ProxyWorker", error); - }); - } } runtimeMessageMutex = new Mutex(); @@ -259,16 +233,11 @@ export class ProxyController extends Controller { // Event Dispatchers // ********************* - emitReadyEvent( - proxyWorker: Miniflare, - url: URL, - inspectorUrl: URL | undefined - ) { + emitReadyEvent(proxyWorker: Miniflare, url: URL) { const data: ReadyEvent = { type: "ready", proxyWorker, url, - inspectorUrl, }; this.ready.resolve(data); @@ -328,20 +297,3 @@ class ProxyControllerLogger extends WranglerLog { super.log(message); } } - -function deepEquality(a: unknown, b: unknown): boolean { - // could be more efficient, but this is fine for now - return JSON.stringify(a) === JSON.stringify(b); -} - -function didMiniflareOptionsChange( - prev: MiniflareOptions | undefined, - next: MiniflareOptions -) { - if (prev === undefined) { - return false; - } // first time, so 'no change' - - // otherwise, if they're not deeply equal, they've changed - return !deepEquality(prev, next); -} diff --git a/packages/remote-bindings/src/startDevWorker/events.ts b/packages/remote-bindings/src/startDevWorker/events.ts index 2027f6e4c44..2edb8f0a286 100644 --- a/packages/remote-bindings/src/startDevWorker/events.ts +++ b/packages/remote-bindings/src/startDevWorker/events.ts @@ -2,22 +2,10 @@ import type { Bundle, StartDevWorkerOptions } from "./types"; import type { Miniflare } from "miniflare"; export type ErrorEvent = - | BaseErrorEvent< - | "ConfigController" - | "BundlerController" - | "LocalRuntimeController" - | "RemoteRuntimeController" - | "ProxyWorker" - | "InspectorProxyWorker" - | "MultiworkerRuntimeController" - > + | BaseErrorEvent<"RemoteRuntimeController"> | BaseErrorEvent< "ProxyController", { config?: StartDevWorkerOptions; bundle?: Bundle } - > - | BaseErrorEvent< - "BundlerController", - { config?: StartDevWorkerOptions; filePath?: string } >; type BaseErrorEvent = { type: "error"; @@ -83,7 +71,6 @@ export type ReadyEvent = { type: "ready"; proxyWorker: Miniflare; url: URL; - inspectorUrl: URL | undefined; }; // ProxyWorker @@ -101,14 +88,9 @@ export type SerializedError = { cause?: unknown; }; export type UrlOriginParts = Pick; -export type UrlOriginAndPathnameParts = Pick< - URL, - "protocol" | "hostname" | "port" | "pathname" ->; export type ProxyData = { userWorkerUrl: UrlOriginParts; - userWorkerInspectorUrl?: UrlOriginAndPathnameParts; userWorkerInnerUrlOverrides?: Partial; headers: Record; }; diff --git a/packages/remote-bindings/src/startDevWorker/types.ts b/packages/remote-bindings/src/startDevWorker/types.ts index d4c6d90104e..7d43da937fb 100644 --- a/packages/remote-bindings/src/startDevWorker/types.ts +++ b/packages/remote-bindings/src/startDevWorker/types.ts @@ -1,19 +1,12 @@ -import type { EsbuildBundle } from "../utils/use-esbuild"; import type { DevEnv } from "./DevEnv"; -import type { ContainerNormalizedConfig } from "@cloudflare/containers-shared"; import type { AsyncHook, - AssetsOptions, CfAccount, CfModule, - CfScriptFormat, + CfModuleType, Config, - NodeJSCompatMode, - Rule, StartDevWorkerInput, } from "@cloudflare/workers-utils"; -import type { WorkerdStructuredLog } from "miniflare"; -import type * as undici from "undici"; export interface Worker { ready: Promise; @@ -23,41 +16,27 @@ export interface Worker { raw: DevEnv; } -export type StartDevWorkerOptions = Omit< - StartDevWorkerInput, - "assets" | "config" | "containers" | "dev" -> & { - /** The configuration path of the worker */ - config?: string; - /** A worker's directory. Usually where the Wrangler configuration file is located */ - projectRoot: string; - build: StartDevWorkerInput["build"] & { - nodejsCompatMode: NodeJSCompatMode; - format: CfScriptFormat; - moduleRoot: string; - moduleRules: Rule[]; - define: Record; - additionalModules: CfModule[]; - exports: string[]; - - processEntrypoint: boolean; - }; - legacy: StartDevWorkerInput["legacy"] & { - site?: Config["site"]; - }; - dev: StartDevWorkerInput["dev"] & { - persist: string | false; - auth?: AsyncHook; // redefine without config.account_id hook param (can only be provided by ConfigController with access to the Wrangler configuration file, not by other controllers eg RemoteRuntimeContoller) - /** Handles structured runtime logs. */ - structuredLogsHandler?: (log: WorkerdStructuredLog) => void; - /** An undici MockAgent to declaratively mock fetch calls to particular resources. */ - mockFetch?: undici.MockAgent; - }; - entrypoint: string; - assets?: AssetsOptions; - containers?: ContainerNormalizedConfig[]; +export type StartDevWorkerOptions = { name: string; + entrypoint: string; + bindings: NonNullable; + compatibilityDate: StartDevWorkerInput["compatibilityDate"]; + compatibilityFlags: StartDevWorkerInput["compatibilityFlags"]; complianceRegion: Config["compliance_region"]; + dev: { + remote: "minimal"; + auth?: AsyncHook; + server: { + hostname?: string; + port: number; + secure: boolean; + }; + }; }; -export type Bundle = EsbuildBundle; +export type Bundle = { + path: string; + entrypointSource: string; + type: CfModuleType; + modules: CfModule[]; +}; diff --git a/packages/remote-bindings/src/utils/remote.ts b/packages/remote-bindings/src/utils/remote.ts index bbc68e96cd6..cf6158c2966 100644 --- a/packages/remote-bindings/src/utils/remote.ts +++ b/packages/remote-bindings/src/utils/remote.ts @@ -4,8 +4,8 @@ import { getAuthFromEnv } from "@cloudflare/workers-auth"; import { APIError, UserError } from "@cloudflare/workers-utils"; import { logger } from "../logger"; import { isAbortError } from "./isAbortError"; +import type { Bundle } from "../startDevWorker/types"; import type { CfAccount } from "./create-worker-preview"; -import type { EsbuildBundle } from "./use-esbuild"; import type { ApiCredentials, CfWorkerContext, @@ -121,7 +121,7 @@ export type CfWorkerInitWithName = Required> & * (flat Record). */ export function createRemoteWorkerInit(props: { - bundle: EsbuildBundle; + bundle: Bundle; name: string; bindings: StartDevWorkerInput["bindings"]; compatibilityDate: string | undefined; diff --git a/packages/remote-bindings/src/utils/use-esbuild.ts b/packages/remote-bindings/src/utils/use-esbuild.ts deleted file mode 100644 index e7c151b6172..00000000000 --- a/packages/remote-bindings/src/utils/use-esbuild.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { CfModule, CfModuleType, Entry } from "@cloudflare/workers-utils"; -import type { Metafile } from "esbuild"; - -export type EsbuildBundle = { - id: number; - path: string; - entrypointSource: string; - entry: Entry; - type: CfModuleType; - modules: CfModule[]; - dependencies: Metafile["outputs"][string]["inputs"]; -}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dae702285e..6b6abfbd7f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2562,9 +2562,6 @@ importers: '@cloudflare/cli-shared-helpers': specifier: workspace:* version: link:../cli - '@cloudflare/containers-shared': - specifier: workspace:* - version: link:../containers-shared '@cloudflare/deploy-helpers': specifier: workspace:* version: link:../deploy-helpers From 05eba50b6ce234a856904ee282fe9fd410e0fcb5 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 16 Jul 2026 15:32:55 +0100 Subject: [PATCH 24/37] [remote-bindings] Restore default account selection --- packages/remote-bindings/src/auth.test.ts | 106 +++++++++++++++--- packages/remote-bindings/src/auth.ts | 39 ++++++- .../src/maybe-start-or-update-session.ts | 58 +--------- .../src/start-remote-proxy-session.ts | 8 +- 4 files changed, 140 insertions(+), 71 deletions(-) diff --git a/packages/remote-bindings/src/auth.test.ts b/packages/remote-bindings/src/auth.test.ts index b57b3857ca4..89950b36177 100644 --- a/packages/remote-bindings/src/auth.test.ts +++ b/packages/remote-bindings/src/auth.test.ts @@ -1,22 +1,46 @@ +import assert from "node:assert"; import { afterEach, beforeEach, describe, it, vi } from "vitest"; -import { createRemoteBindingsAuth } from "./auth"; +import { createRemoteBindingsAuth, getRemoteBindingsAuthHook } from "./auth"; import type { RemoteBindingsLogger } from "./logger"; +import type { CfAccount } from "@cloudflare/workers-utils"; const mocks = vi.hoisted(() => ({ - cfAuth: { source: "cf" }, - wranglerAuth: { source: "wrangler" }, + cfAuth: { + source: "cf", + setProfile: vi.fn(), + requireAuth: vi.fn().mockResolvedValue("selected-account-id"), + requireApiToken: vi.fn().mockReturnValue({ apiToken: "test-token" }), + }, + wranglerAuth: { + source: "wrangler", + setProfile: vi.fn(), + requireAuth: vi.fn().mockResolvedValue("selected-account-id"), + requireApiToken: vi.fn().mockReturnValue({ apiToken: "test-token" }), + }, createCfAuth: vi.fn(), createWranglerAuth: vi.fn(), + cfProfileStore: { resolve: vi.fn().mockReturnValue({ name: "cf-profile" }) }, + wranglerProfileStore: { + resolve: vi.fn().mockReturnValue({ name: "wrangler-profile" }), + }, + createCfProfileStore: vi.fn(), + createWranglerProfileStore: vi.fn(), })); vi.mock("@cloudflare/workers-auth/cf", () => ({ createCfAuth: mocks.createCfAuth.mockReturnValue(mocks.cfAuth), + createCfProfileStore: mocks.createCfProfileStore.mockReturnValue( + mocks.cfProfileStore + ), })); vi.mock("@cloudflare/workers-auth/wrangler", () => ({ createWranglerAuth: mocks.createWranglerAuth.mockReturnValue( mocks.wranglerAuth ), + createWranglerProfileStore: mocks.createWranglerProfileStore.mockReturnValue( + mocks.wranglerProfileStore + ), })); const originalCfAuth = process.env.CLOUDFLARE_CF_AUTH; @@ -39,20 +63,20 @@ function createTestLogger(): RemoteBindingsLogger { }; } -describe("createRemoteBindingsAuth", () => { - beforeEach(() => { - delete process.env.CLOUDFLARE_CF_AUTH; - }); +beforeEach(() => { + delete process.env.CLOUDFLARE_CF_AUTH; +}); - afterEach(() => { - vi.clearAllMocks(); - if (originalCfAuth === undefined) { - delete process.env.CLOUDFLARE_CF_AUTH; - } else { - process.env.CLOUDFLARE_CF_AUTH = originalCfAuth; - } - }); +afterEach(() => { + vi.clearAllMocks(); + if (originalCfAuth === undefined) { + delete process.env.CLOUDFLARE_CF_AUTH; + } else { + process.env.CLOUDFLARE_CF_AUTH = originalCfAuth; + } +}); +describe("createRemoteBindingsAuth", () => { it("uses Wrangler auth by default", ({ expect }) => { const result = createRemoteBindingsAuth(createTestLogger()); @@ -71,3 +95,55 @@ describe("createRemoteBindingsAuth", () => { expect(mocks.createWranglerAuth).not.toHaveBeenCalled(); }); }); + +describe("getRemoteBindingsAuthHook", () => { + it("uses provided auth without resolving a profile", ({ expect }) => { + const auth: CfAccount = { + accountId: "provided-account-id", + apiToken: { apiToken: "provided-token" }, + }; + + const result = getRemoteBindingsAuthHook( + auth, + undefined, + undefined, + createTestLogger() + ); + + expect(result).toBe(auth); + expect(mocks.createWranglerProfileStore).not.toHaveBeenCalled(); + }); + + it("allows auth to select an account when none is configured", async ({ + expect, + }) => { + const hook = getRemoteBindingsAuthHook( + undefined, + undefined, + undefined, + createTestLogger() + ); + assert(typeof hook === "function"); + + await expect(hook()).resolves.toEqual({ + accountId: "selected-account-id", + apiToken: { apiToken: "test-token" }, + }); + expect(mocks.wranglerAuth.requireAuth).toHaveBeenCalledWith({}); + }); + + it("uses the configured account when provided", async ({ expect }) => { + const hook = getRemoteBindingsAuthHook( + undefined, + "configured-account-id", + undefined, + createTestLogger() + ); + assert(typeof hook === "function"); + + await hook(); + expect(mocks.wranglerAuth.requireAuth).toHaveBeenCalledWith({ + account_id: "configured-account-id", + }); + }); +}); diff --git a/packages/remote-bindings/src/auth.ts b/packages/remote-bindings/src/auth.ts index 4b4a23ba8aa..8e0cb855389 100644 --- a/packages/remote-bindings/src/auth.ts +++ b/packages/remote-bindings/src/auth.ts @@ -1,9 +1,16 @@ import { inputPrompt } from "@cloudflare/cli-shared-helpers/interactive"; -import { createCfAuth } from "@cloudflare/workers-auth/cf"; -import { createWranglerAuth } from "@cloudflare/workers-auth/wrangler"; +import { + createCfAuth, + createCfProfileStore, +} from "@cloudflare/workers-auth/cf"; +import { + createWranglerAuth, + createWranglerProfileStore, +} from "@cloudflare/workers-auth/wrangler"; import { isNonInteractiveOrCI, UserError } from "@cloudflare/workers-utils"; import { version as packageVersion } from "../package.json"; import type { RemoteBindingsLogger } from "./logger"; +import type { AsyncHook, CfAccount, Config } from "@cloudflare/workers-utils"; class NoDefaultValueProvided extends UserError { constructor() { @@ -55,3 +62,31 @@ export function createRemoteBindingsAuth(logger: RemoteBindingsLogger) { useCfAuth, }; } + +export function getRemoteBindingsAuthHook( + auth: AsyncHook | undefined, + accountId: Config["account_id"] | undefined, + profileDir: string | undefined, + logger: RemoteBindingsLogger +): AsyncHook { + if (auth) { + return auth; + } + + const { auth: remoteBindingsAuth, useCfAuth } = + createRemoteBindingsAuth(logger); + const profileStore = useCfAuth + ? createCfProfileStore({ logger }) + : createWranglerProfileStore({ logger }); + const profile = profileStore.resolve({ + cwd: profileDir ?? process.cwd(), + }); + remoteBindingsAuth.setProfile(profile); + + return async () => ({ + accountId: await remoteBindingsAuth.requireAuth( + accountId ? { account_id: accountId } : {} + ), + apiToken: remoteBindingsAuth.requireApiToken(), + }); +} diff --git a/packages/remote-bindings/src/maybe-start-or-update-session.ts b/packages/remote-bindings/src/maybe-start-or-update-session.ts index 5b7e8afa987..a06232f4926 100644 --- a/packages/remote-bindings/src/maybe-start-or-update-session.ts +++ b/packages/remote-bindings/src/maybe-start-or-update-session.ts @@ -1,8 +1,6 @@ import assert from "node:assert"; -import { createCfProfileStore } from "@cloudflare/workers-auth/cf"; -import { createWranglerProfileStore } from "@cloudflare/workers-auth/wrangler"; import { getBindingLocalSupport } from "@cloudflare/workers-utils"; -import { createRemoteBindingsAuth } from "./auth"; +import { getRemoteBindingsAuthHook } from "./auth"; import { startRemoteProxySession } from "./start-remote-proxy-session"; import type { RemoteBindingsLogger } from "./logger"; import type { RemoteProxySession } from "./start-remote-proxy-session"; @@ -82,11 +80,9 @@ export async function maybeStartOrUpdateRemoteProxySession( remoteProxySession = await startSession(remoteBindings, { workerName: workerConfigObject.name, complianceRegion: workerConfigObject.complianceRegion, - auth: getAuthHook( + auth: getRemoteBindingsAuthHook( auth, - workerConfigObject.account_id - ? { account_id: workerConfigObject.account_id } - : undefined, + workerConfigObject.account_id, workerConfigObject.profileDir, context.logger ), @@ -104,11 +100,9 @@ export async function maybeStartOrUpdateRemoteProxySession( remoteProxySession = await startSession(remoteBindings, { workerName: workerConfigObject.name, complianceRegion: workerConfigObject.complianceRegion, - auth: getAuthHook( + auth: getRemoteBindingsAuthHook( auth, - workerConfigObject.account_id - ? { account_id: workerConfigObject.account_id } - : undefined, + workerConfigObject.account_id, workerConfigObject.profileDir, context.logger ), @@ -132,48 +126,6 @@ export async function maybeStartOrUpdateRemoteProxySession( }; } -/** - * Gets the auth hook to use for the remote proxy session, this is either the user provided auth - * hook if there is one, or an ad-hoc hook created using the account_id from the user's wrangler - * config file otherwise. - * - * @param auth the auth hook provided by the user if any - * @param config the user's wrangler config if any - * @param profileDir working directory used to resolve the auth profile from directory bindings, - * falls back to `process.cwd()` when not provided - * @returns the auth hook to pass to the startRemoteProxy session function if any - */ -function getAuthHook( - auth: AsyncHook | undefined, - config: Pick | undefined, - profileDir: string | undefined, - logger: RemoteBindingsLogger -): AsyncHook | undefined { - const { auth: remoteBindingsAuth, useCfAuth } = - createRemoteBindingsAuth(logger); - const profileStore = useCfAuth - ? createCfProfileStore({ logger }) - : createWranglerProfileStore({ logger }); - const profile = profileStore.resolve({ - cwd: profileDir ?? process.cwd(), - }); - remoteBindingsAuth.setProfile(profile); - if (auth) { - return auth; - } - - if (config?.account_id) { - return async () => { - return { - accountId: await remoteBindingsAuth.requireAuth(config), - apiToken: remoteBindingsAuth.requireApiToken(), - }; - }; - } - - return undefined; -} - function deepStrictEqual(source: unknown, target: unknown): boolean { try { assert.deepStrictEqual(source, target); diff --git a/packages/remote-bindings/src/start-remote-proxy-session.ts b/packages/remote-bindings/src/start-remote-proxy-session.ts index 8fcee763881..71ce4fd6133 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import { UserError } from "@cloudflare/workers-utils"; import chalk from "chalk"; import { DeferredPromise } from "miniflare"; +import { getRemoteBindingsAuthHook } from "./auth"; import { initLogger } from "./logger"; import { startWorker } from "./start-worker"; import type { RemoteBindingsLogger } from "./logger"; @@ -84,7 +85,12 @@ export async function startRemoteProxySession( bindings: rawBindings, dev: { remote: "minimal" as const, - auth: options.auth, + auth: getRemoteBindingsAuthHook( + options.auth, + undefined, + undefined, + options.logger + ), server: { port: 0, secure: false }, }, }; From 235afa1b0fb33e2e308bc021d5acee4bd16860ca Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 16 Jul 2026 15:43:40 +0100 Subject: [PATCH 25/37] chore: fix remote bindings lockfile --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b6abfbd7f5..002ccebfdab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2573,7 +2573,7 @@ importers: version: link:../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 5.20260710.1 + version: 5.20260714.1 '@cloudflare/workers-utils': specifier: workspace:* version: link:../workers-utils From dab662b44888e0a984aff713d4548bef25265152 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 16 Jul 2026 16:22:36 +0100 Subject: [PATCH 26/37] [remote-bindings] Collapse remote runtime controllers --- .../src/start-remote-proxy-session.ts | 76 +++++----- packages/remote-bindings/src/start-worker.ts | 12 -- .../src/startDevWorker/BaseController.ts | 69 --------- .../src/startDevWorker/BundlerController.ts | 22 --- .../src/startDevWorker/ConfigController.ts | 16 -- .../src/startDevWorker/DevEnv.ts | 140 +++++------------- .../src/startDevWorker/ProxyController.ts | 72 ++++----- .../startDevWorker/RemoteRuntimeController.ts | 99 ++++--------- .../src/startDevWorker/events.ts | 27 ---- .../src/startDevWorker/types.ts | 22 +-- .../src/utils/create-worker-preview.ts | 139 +++-------------- packages/remote-bindings/src/utils/remote.ts | 23 --- 12 files changed, 156 insertions(+), 561 deletions(-) delete mode 100644 packages/remote-bindings/src/start-worker.ts delete mode 100644 packages/remote-bindings/src/startDevWorker/BaseController.ts delete mode 100644 packages/remote-bindings/src/startDevWorker/BundlerController.ts delete mode 100644 packages/remote-bindings/src/startDevWorker/ConfigController.ts diff --git a/packages/remote-bindings/src/start-remote-proxy-session.ts b/packages/remote-bindings/src/start-remote-proxy-session.ts index 71ce4fd6133..30f772b6c81 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.ts @@ -6,7 +6,7 @@ import chalk from "chalk"; import { DeferredPromise } from "miniflare"; import { getRemoteBindingsAuthHook } from "./auth"; import { initLogger } from "./logger"; -import { startWorker } from "./start-worker"; +import { DevEnv } from "./startDevWorker/DevEnv"; import type { RemoteBindingsLogger } from "./logger"; import type { AsyncHook, @@ -83,46 +83,46 @@ export async function startRemoteProxySession( compatibilityFlags: [], complianceRegion: options.complianceRegion, bindings: rawBindings, - dev: { - remote: "minimal" as const, - auth: getRemoteBindingsAuthHook( - options.auth, - undefined, - undefined, - options.logger - ), - server: { port: 0, secure: false }, - }, + auth: getRemoteBindingsAuthHook( + options.auth, + undefined, + undefined, + options.logger + ), + server: { port: 0, secure: false }, }; - const worker = await startWorker(workerConfig).catch( - (startWorkerError: unknown) => { - if (startWorkerError instanceof UserError) { - throw startWorkerError; - } - let errorMessage = startWorkerError; - if (startWorkerError instanceof Error) { - errorMessage = - startWorkerError.cause instanceof Error - ? startWorkerError.cause.message - : startWorkerError.message; - } - throw new Error( - `Failed to start the remote proxy session, see the error details below:\n\n${errorMessage}` - ); + let devEnv: DevEnv | undefined; + try { + devEnv = new DevEnv(workerConfig); + devEnv.start(); + } catch (startWorkerError: unknown) { + await devEnv?.teardown(); + if (startWorkerError instanceof UserError) { + throw startWorkerError; } - ); + let errorMessage = startWorkerError; + if (startWorkerError instanceof Error) { + errorMessage = + startWorkerError.cause instanceof Error + ? startWorkerError.cause.message + : startWorkerError.message; + } + throw new Error( + `Failed to start the remote proxy session, see the error details below:\n\n${errorMessage}` + ); + } const maybeErrorPromise = new DeferredPromise<{ error: unknown }>(); const onStartupError = (error: unknown) => { maybeErrorPromise.resolve({ error }); }; - worker.raw.addListener("error", onStartupError); + devEnv.addListener("error", onStartupError); let remoteProxyConnectionString: RemoteProxyConnectionString; try { const maybeError = await Promise.race([ maybeErrorPromise, - worker.raw.proxy.localServerReady.promise, + devEnv.proxy.localServerReady.promise, ]); if (maybeError && maybeError.error) { @@ -135,19 +135,19 @@ export async function startRemoteProxySession( ); } - remoteProxyConnectionString = - (await worker.url) as RemoteProxyConnectionString; + remoteProxyConnectionString = (await devEnv.proxy.ready.promise) + .url as RemoteProxyConnectionString; } catch (error) { - await worker.dispose(); + await devEnv.teardown(); throw error; } finally { - worker.raw.removeListener("error", onStartupError); + devEnv.removeListener("error", onStartupError); } const updateBindings = async ( newBindings: StartDevWorkerInput["bindings"] ) => { - const reloadComplete = events.once(worker.raw, "reloadComplete"); - await worker.patchConfig({ + const reloadComplete = events.once(devEnv, "reloadComplete"); + devEnv.update({ ...workerConfig, bindings: toRawBindings(newBindings), }); @@ -163,14 +163,14 @@ export async function startRemoteProxySession( { cause: errorOrEvent } ); } - await worker.raw.proxy.runtimeMessageMutex.drained(); + await devEnv.proxy.runtimeMessageMutex.drained(); }; return { - ready: worker.ready, + ready: Promise.resolve(), remoteProxyConnectionString, updateBindings, - dispose: worker.dispose, + dispose: () => devEnv.teardown(), }; } diff --git a/packages/remote-bindings/src/start-worker.ts b/packages/remote-bindings/src/start-worker.ts deleted file mode 100644 index bc28c64a855..00000000000 --- a/packages/remote-bindings/src/start-worker.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { DevEnv } from "./startDevWorker/DevEnv"; -import type { StartDevWorkerOptions, Worker } from "./startDevWorker/types"; - -export type { Worker }; - -export async function startWorker( - options: StartDevWorkerOptions -): Promise { - const devEnv = new DevEnv(); - - return devEnv.startWorker(options); -} diff --git a/packages/remote-bindings/src/startDevWorker/BaseController.ts b/packages/remote-bindings/src/startDevWorker/BaseController.ts deleted file mode 100644 index 0913848e653..00000000000 --- a/packages/remote-bindings/src/startDevWorker/BaseController.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { logger } from "../logger"; -import type { - BundleCompleteEvent, - BundleStartEvent, - ConfigUpdateEvent, - ErrorEvent, - PreviewTokenExpiredEvent, - ReloadCompleteEvent, - ReloadStartEvent, -} from "./events"; - -export type ControllerEvent = - | ErrorEvent - | ConfigUpdateEvent - | BundleStartEvent - | BundleCompleteEvent - | ReloadStartEvent - | ReloadCompleteEvent - | PreviewTokenExpiredEvent; - -export interface ControllerBus { - dispatch(event: ControllerEvent): void; -} - -export abstract class Controller { - protected bus: ControllerBus; - #tearingDown = false; - - constructor(bus: ControllerBus) { - this.bus = bus; - } - - async teardown(): Promise { - this.#tearingDown = true; - } - - protected emitErrorEvent(event: ErrorEvent) { - if (this.#tearingDown) { - logger.debug("Suppressing error event during teardown"); - logger.debug(`Error in ${event.source}: ${event.reason}\n`, event.cause); - logger.debug("=> Error contextual data:", event.data); - return; - } - - this.bus.dispatch(event); - } -} - -export abstract class RuntimeController extends Controller { - // ****************** - // Event Handlers - // ****************** - - abstract onBundleStart(_: BundleStartEvent): void; - abstract onBundleComplete(_: BundleCompleteEvent): void; - abstract onPreviewTokenExpired(_: PreviewTokenExpiredEvent): void; - - // ********************* - // Event Dispatchers - // ********************* - - protected emitReloadStartEvent(data: ReloadStartEvent): void { - this.bus.dispatch(data); - } - - protected emitReloadCompleteEvent(data: ReloadCompleteEvent): void { - this.bus.dispatch(data); - } -} diff --git a/packages/remote-bindings/src/startDevWorker/BundlerController.ts b/packages/remote-bindings/src/startDevWorker/BundlerController.ts deleted file mode 100644 index f6f286f6007..00000000000 --- a/packages/remote-bindings/src/startDevWorker/BundlerController.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { readFileSync } from "node:fs"; -import { Controller } from "./BaseController"; -import type { ConfigUpdateEvent } from "./events"; - -export class BundlerController extends Controller { - onConfigUpdate({ config }: ConfigUpdateEvent) { - this.bus.dispatch({ type: "bundleStart", config }); - - const entrypointSource = readFileSync(config.entrypoint, "utf8"); - - this.bus.dispatch({ - type: "bundleComplete", - config, - bundle: { - path: config.entrypoint, - entrypointSource, - type: "esm", - modules: [], - }, - }); - } -} diff --git a/packages/remote-bindings/src/startDevWorker/ConfigController.ts b/packages/remote-bindings/src/startDevWorker/ConfigController.ts deleted file mode 100644 index c36cdc9cf89..00000000000 --- a/packages/remote-bindings/src/startDevWorker/ConfigController.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Controller } from "./BaseController"; -import type { StartDevWorkerOptions } from "./types"; - -export class ConfigController extends Controller { - public set(options: StartDevWorkerOptions) { - this.emitConfigUpdateEvent(options); - } - - public patch(options: StartDevWorkerOptions) { - this.emitConfigUpdateEvent(options); - } - - emitConfigUpdateEvent(config: StartDevWorkerOptions) { - this.bus.dispatch({ type: "configUpdate", config }); - } -} diff --git a/packages/remote-bindings/src/startDevWorker/DevEnv.ts b/packages/remote-bindings/src/startDevWorker/DevEnv.ts index 425076ae08b..b61c635199c 100644 --- a/packages/remote-bindings/src/startDevWorker/DevEnv.ts +++ b/packages/remote-bindings/src/startDevWorker/DevEnv.ts @@ -1,45 +1,53 @@ import { EventEmitter } from "node:events"; +import { readFileSync } from "node:fs"; import { UserError } from "@cloudflare/workers-utils"; import { MiniflareCoreError } from "miniflare"; import { logger } from "../logger"; -import { BundlerController } from "./BundlerController"; -import { ConfigController } from "./ConfigController"; import { ProxyController } from "./ProxyController"; import { RemoteRuntimeController } from "./RemoteRuntimeController"; -import type { ControllerBus, ControllerEvent } from "./BaseController"; -import type { ErrorEvent } from "./events"; -import type { StartDevWorkerOptions, Worker } from "./types"; +import type { ErrorEvent, ReloadCompleteEvent } from "./events"; +import type { Bundle, StartDevWorkerOptions } from "./types"; -export class DevEnv extends EventEmitter implements ControllerBus { - config: ConfigController; - bundler: BundlerController; +export class DevEnv extends EventEmitter { runtime: RemoteRuntimeController; proxy: ProxyController; + #bundle: Bundle; + #config: StartDevWorkerOptions; - async startWorker(options: StartDevWorkerOptions): Promise { - const worker = createWorkerObject(this); - - try { - await this.config.set(options); - } catch (e) { - const error = new Error("An error occurred when starting the server", { - cause: e, - }); - this.proxy.ready.reject(error); - await worker.dispose(); - throw e; - } + start() { + this.proxy.start(this.#config); + this.update(this.#config); + } - return worker; + update(config: StartDevWorkerOptions) { + this.#config = config; + this.proxy.pause(config); + this.runtime.onUpdateStart(); + this.runtime.onBundleComplete({ + type: "bundleComplete", + config, + bundle: this.#bundle, + }); } - constructor() { + constructor(config: StartDevWorkerOptions) { super(); - this.config = new ConfigController(this); - this.bundler = new BundlerController(this); - this.runtime = new RemoteRuntimeController(this); - this.proxy = new ProxyController(this); + this.#config = config; + this.#bundle = { + path: config.entrypoint, + entrypointSource: readFileSync(config.entrypoint, "utf8"), + type: "esm", + modules: [], + }; + this.proxy = new ProxyController( + (event) => this.handleErrorEvent(event), + () => this.runtime.onPreviewTokenExpired() + ); + this.runtime = new RemoteRuntimeController( + (event) => this.handleErrorEvent(event), + (event) => this.handleReloadComplete(event) + ); this.on("error", (event: ErrorEvent) => { logger.debug(`Error in ${event.source}: ${event.reason}\n`, event.cause); @@ -47,56 +55,9 @@ export class DevEnv extends EventEmitter implements ControllerBus { }); } - /** - * Central message bus dispatch method. - * All events from controllers flow through here, making the event routing explicit and traceable. - * - * Event flow: - * - ConfigController emits configUpdate → BundlerController, ProxyController - * - BundlerController emits bundleStart → ProxyController, RuntimeControllers - * - BundlerController emits bundleComplete → RuntimeControllers - * - RuntimeController emits reloadStart → ProxyController - * - RuntimeController emits reloadComplete → ProxyController - * - ProxyController emits previewTokenExpired → RuntimeControllers - * - Any controller emits error → DevEnv error handler - * - * `reloadComplete` is also re-emitted as an external EventEmitter event - * (`devEnv.on("reloadComplete", ...)`) so callers like - * `RemoteProxySession.updateBindings` can wait for the reload to finish. - */ - dispatch(event: ControllerEvent): void { - switch (event.type) { - case "error": - this.handleErrorEvent(event); - break; - - case "configUpdate": - this.bundler.onConfigUpdate(event); - this.proxy.onConfigUpdate(event); - break; - - case "bundleStart": - this.proxy.onBundleStart(event); - this.runtime.onBundleStart(event); - break; - - case "bundleComplete": - this.runtime.onBundleComplete(event); - break; - - case "reloadStart": - this.proxy.onReloadStart(event); - break; - - case "reloadComplete": - this.proxy.onReloadComplete(event); - this.emit("reloadComplete", event); - break; - - case "previewTokenExpired": - this.runtime.onPreviewTokenExpired(event); - break; - } + private handleReloadComplete(event: ReloadCompleteEvent) { + this.proxy.play(event); + this.emit("reloadComplete", event); } private handleErrorEvent(event: ErrorEvent): void { @@ -127,31 +88,8 @@ export class DevEnv extends EventEmitter implements ControllerBus { async teardown() { logger.debug("DevEnv teardown beginning..."); - await Promise.all([ - this.config.teardown(), - this.bundler.teardown(), - this.runtime.teardown(), - this.proxy.teardown(), - ]); + await Promise.all([this.runtime.teardown(), this.proxy.teardown()]); logger.debug("DevEnv teardown complete"); } } - -function createWorkerObject(devEnv: DevEnv): Worker { - return { - get ready() { - return devEnv.proxy.ready.promise.then(() => undefined); - }, - get url() { - return devEnv.proxy.ready.promise.then((ev) => ev.url); - }, - patchConfig(config) { - return devEnv.config.patch(config); - }, - async dispose() { - await devEnv.teardown(); - }, - raw: devEnv, - }; -} diff --git a/packages/remote-bindings/src/startDevWorker/ProxyController.ts b/packages/remote-bindings/src/startDevWorker/ProxyController.ts index 9a256ab189c..5ecc62f937b 100644 --- a/packages/remote-bindings/src/startDevWorker/ProxyController.ts +++ b/packages/remote-bindings/src/startDevWorker/ProxyController.ts @@ -10,19 +10,14 @@ import { handleStructuredLogs, WranglerLog, } from "../utils/miniflare"; -import { Controller } from "./BaseController"; import { castErrorCause } from "./events"; import { createDeferred } from "./utils"; import type { - BundleStartEvent, - ConfigUpdateEvent, ErrorEvent, - ProxyData, ProxyWorkerIncomingRequestBody, ProxyWorkerOutgoingRequestBody, ReadyEvent, ReloadCompleteEvent, - ReloadStartEvent, SerializedError, } from "./events"; import type { Bundle, StartDevWorkerOptions } from "./types"; @@ -32,7 +27,7 @@ const proxyWorkerPath = fileURLToPath( new URL("./dev-proxy-worker.mjs", import.meta.url) ); -export class ProxyController extends Controller { +export class ProxyController { public ready = createDeferred(); public localServerReady = createDeferred(); @@ -44,6 +39,11 @@ export class ProxyController extends Controller { secret = randomUUID(); + constructor( + private onError: (event: ErrorEvent) => void, + private onPreviewTokenExpired: () => void + ) {} + protected createProxyWorker() { if (this._torndown || this.proxyWorker) { return; @@ -51,9 +51,9 @@ export class ProxyController extends Controller { assert(this.latestConfig !== undefined); const proxyWorkerOptions: MiniflareOptions = { - host: this.latestConfig.dev?.server?.hostname, - port: this.latestConfig.dev?.server?.port, - https: this.latestConfig.dev?.server?.secure, + host: this.latestConfig.server.hostname, + port: this.latestConfig.server.port, + https: this.latestConfig.server.secure, stripDisablePrettyError: false, unsafeLocalExplorer: false, workers: [ @@ -170,27 +170,15 @@ export class ProxyController extends Controller { ); } } - // ****************** - // Event Handlers - // ****************** - - onConfigUpdate(data: ConfigUpdateEvent) { - this.latestConfig = data.config; + start(config: StartDevWorkerOptions) { + this.latestConfig = config; this.createProxyWorker(); - - void this.sendMessageToProxyWorker({ type: "pause" }); } - onBundleStart(data: BundleStartEvent) { - this.latestConfig = data.config; - + pause(config: StartDevWorkerOptions) { + this.latestConfig = config; void this.sendMessageToProxyWorker({ type: "pause" }); } - onReloadStart(data: ReloadStartEvent) { - this.latestConfig = data.config; - - void this.sendMessageToProxyWorker({ type: "pause" }); - } - onReloadComplete(data: ReloadCompleteEvent) { + play(data: ReloadCompleteEvent) { this.localServerReady.resolve(); this.latestConfig = data.config; @@ -204,7 +192,7 @@ export class ProxyController extends Controller { onProxyWorkerMessage(message: ProxyWorkerOutgoingRequestBody) { switch (message.type) { case "previewTokenExpired": - this.emitPreviewTokenExpiredEvent(message.proxyData); + this.onPreviewTokenExpired(); break; case "error": @@ -216,8 +204,7 @@ export class ProxyController extends Controller { } } _torndown = false; - override async teardown() { - await super.teardown(); + async teardown() { logger.debug("ProxyController teardown beginning..."); this._torndown = true; @@ -242,22 +229,9 @@ export class ProxyController extends Controller { this.ready.resolve(data); } - emitPreviewTokenExpiredEvent(proxyData: ProxyData) { - this.bus.dispatch({ - type: "previewTokenExpired", - proxyData, - }); - } - - override emitErrorEvent(data: ErrorEvent): void; - override emitErrorEvent( - reason: string, - cause?: Error | SerializedError - ): void; - override emitErrorEvent( - data: string | ErrorEvent, - cause?: Error | SerializedError - ) { + emitErrorEvent(data: ErrorEvent): void; + emitErrorEvent(reason: string, cause?: Error | SerializedError): void; + emitErrorEvent(data: string | ErrorEvent, cause?: Error | SerializedError) { if (typeof data === "string") { data = { type: "error", @@ -270,7 +244,13 @@ export class ProxyController extends Controller { }, }; } - super.emitErrorEvent(data); + if (this._torndown) { + logger.debug("Suppressing error event during teardown"); + logger.debug(`Error in ${data.source}: ${data.reason}\n`, data.cause); + logger.debug("=> Error contextual data:", data.data); + return; + } + this.onError(data); } } diff --git a/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts b/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts index baa438abeac..461e8ae1d04 100644 --- a/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts +++ b/packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts @@ -1,9 +1,5 @@ -import assert from "node:assert"; import { getAccessHeaders } from "@cloudflare/workers-auth"; -import { - MissingConfigError, - retryOnAPIFailure, -} from "@cloudflare/workers-utils"; +import { retryOnAPIFailure } from "@cloudflare/workers-utils"; import chalk from "chalk"; import { Mutex } from "miniflare"; import { WebSocket } from "ws"; @@ -17,11 +13,9 @@ import { import { realishPrintLogs } from "../utils/printing"; import { createRemoteWorkerInit, - getWorkerAccountAndContext, handlePreviewSessionCreationError, handlePreviewSessionUploadError, } from "../utils/remote"; -import { RuntimeController } from "./BaseController"; import { castErrorCause } from "./events"; import { PREVIEW_TOKEN_REFRESH_INTERVAL, unwrapHook } from "./utils"; import type { @@ -31,18 +25,16 @@ import type { } from "../utils/create-worker-preview"; import type { BundleCompleteEvent, - BundleStartEvent, - PreviewTokenExpiredEvent, + ErrorEvent, ProxyData, ReloadCompleteEvent, - ReloadStartEvent, } from "./events"; import type { Bundle, StartDevWorkerOptions } from "./types"; import type { ComplianceConfig } from "@cloudflare/workers-utils"; type CreateRemoteWorkerInitProps = Parameters[0]; -export class RemoteRuntimeController extends RuntimeController { +export class RemoteRuntimeController { #abortController = new AbortController(); #currentBundleId = 0; @@ -58,6 +50,12 @@ export class RemoteRuntimeController extends RuntimeController { // Timer for proactive token refresh before the 1-hour expiry #refreshTimer?: ReturnType; + #tearingDown = false; + + constructor( + private onError: (event: ErrorEvent) => void, + private onReloadComplete: (event: ReloadCompleteEvent) => void + ) {} async #previewSession( props: CfAccount & { @@ -66,15 +64,11 @@ export class RemoteRuntimeController extends RuntimeController { } ): Promise { try { - const { workerAccount, workerContext } = - getWorkerAccountAndContext(props); - return await retryOnAPIFailure( () => createPreviewSession( props.complianceConfig, - workerAccount, - workerContext, + props, this.#abortController.signal, props.name ), @@ -99,7 +93,6 @@ export class RemoteRuntimeController extends RuntimeController { CfAccount & { complianceConfig: ComplianceConfig; bundleId: number; - minimal_mode?: boolean; } ): Promise { if (!this.#session) { @@ -119,10 +112,6 @@ export class RemoteRuntimeController extends RuntimeController { this.#activeTail?.removeAllListeners("error"); this.#activeTail?.on("error", () => {}); this.#activeTail?.terminate(); - const { workerAccount, workerContext } = getWorkerAccountAndContext({ - accountId: props.accountId, - apiToken: props.apiToken, - }); const init = createRemoteWorkerInit({ bundle: props.bundle, name: props.name, @@ -136,11 +125,9 @@ export class RemoteRuntimeController extends RuntimeController { createWorkerPreview( props.complianceConfig, init, - workerAccount, - workerContext, + props, session, - this.#abortController.signal, - props.minimal_mode + this.#abortController.signal ), logger, undefined, @@ -164,7 +151,7 @@ export class RemoteRuntimeController extends RuntimeController { this.#activeTail.on("message", realishPrintLogs); // Best-effort log streaming: ignore errors instead of letting them // propagate as unhandled exceptions. The signal we pass to the `ws` - // constructor is shared with `onBundleStart`'s abort, which destroys + // constructor is shared with update cancellation, which destroys // the underlying upgrade request with `AbortError` every time a new // bundle starts. The existing `terminate` paths in `#previewToken` // and `teardown()` re-install no-op listeners before shutting the @@ -231,7 +218,6 @@ export class RemoteRuntimeController extends RuntimeController { compatibilityDate: config.compatibilityDate, compatibilityFlags: config.compatibilityFlags, bundleId, - minimal_mode: config.dev.remote === "minimal", }); // If we received a new `bundleComplete` event before we were able to // dispatch a `reloadComplete` for this bundle, ignore this bundle. @@ -259,7 +245,7 @@ export class RemoteRuntimeController extends RuntimeController { this.#latestProxyData = proxyData; - this.emitReloadCompleteEvent({ + this.onReloadComplete({ type: "reloadComplete", bundle, config, @@ -274,10 +260,7 @@ export class RemoteRuntimeController extends RuntimeController { clearTimeout(this.#refreshTimer); this.#refreshTimer = setTimeout(() => { if (this.#latestProxyData) { - this.onPreviewTokenExpired({ - type: "previewTokenExpired", - proxyData: this.#latestProxyData, - }); + this.onPreviewTokenExpired(); } }, interval); } @@ -291,12 +274,7 @@ export class RemoteRuntimeController extends RuntimeController { logger.log(chalk.dim("⎔ Starting remote preview...")); try { - if (!config.dev?.auth) { - throw new MissingConfigError("config.dev.auth"); - } - - assert(config.dev.auth); - const auth = await unwrapHook(config.dev.auth); + const auth = await unwrapHook(config.auth); this.#latestConfig = config; this.#latestBundle = bundle; @@ -305,12 +283,6 @@ export class RemoteRuntimeController extends RuntimeController { logger.log(chalk.dim("⎔ Detected changes, restarted server.")); } - // Recreate session if the worker name changed, since the session - // host bakes in the name from creation time. - if (this.#session && config.name !== this.#session.name) { - this.#session = undefined; - } - this.#session ??= await this.#getPreviewSession(config, auth); await this.#updatePreviewToken(config, bundle, auth, id); } catch (error) { @@ -337,8 +309,7 @@ export class RemoteRuntimeController extends RuntimeController { } try { - assert(this.#latestConfig.dev.auth); - const auth = await unwrapHook(this.#latestConfig.dev.auth); + const auth = await unwrapHook(this.#latestConfig.auth); this.#session = await this.#getPreviewSession(this.#latestConfig, auth); @@ -371,7 +342,7 @@ export class RemoteRuntimeController extends RuntimeController { // Event Handlers // ****************** - onBundleStart(_: BundleStartEvent) { + onUpdateStart() { // Abort any previous operations when a new bundle is started this.#abortController.abort(); this.#abortController = new AbortController(); @@ -380,26 +351,15 @@ export class RemoteRuntimeController extends RuntimeController { onBundleComplete(ev: BundleCompleteEvent) { const id = ++this.#currentBundleId; - if (!ev.config.dev?.remote) { - void this.#mutex.runWith(() => this.teardown()); - return; - } - - this.emitReloadStartEvent({ - type: "reloadStart", - config: ev.config, - bundle: ev.bundle, - }); - void this.#mutex.runWith(() => this.#onBundleComplete(ev, id)); } - onPreviewTokenExpired(_: PreviewTokenExpiredEvent): void { + onPreviewTokenExpired(): void { logger.log(chalk.dim("⎔ Refreshing preview token...")); void this.#mutex.runWith(() => this.#refreshPreviewToken()); } - override async teardown() { - await super.teardown(); + async teardown() { + this.#tearingDown = true; if (this.#session) { logger.log(chalk.dim("⎔ Shutting down remote preview...")); } @@ -414,14 +374,13 @@ export class RemoteRuntimeController extends RuntimeController { logger.debug("RemoteRuntimeController teardown complete"); } - // ********************* - // Event Dispatchers - // ********************* - - override emitReloadStartEvent(data: ReloadStartEvent) { - this.bus.dispatch(data); - } - override emitReloadCompleteEvent(data: ReloadCompleteEvent) { - this.bus.dispatch(data); + private emitErrorEvent(event: ErrorEvent) { + if (this.#tearingDown) { + logger.debug("Suppressing error event during teardown"); + logger.debug(`Error in ${event.source}: ${event.reason}\n`, event.cause); + logger.debug("=> Error contextual data:", event.data); + return; + } + this.onError(event); } } diff --git a/packages/remote-bindings/src/startDevWorker/events.ts b/packages/remote-bindings/src/startDevWorker/events.ts index 2edb8f0a286..533a260eea0 100644 --- a/packages/remote-bindings/src/startDevWorker/events.ts +++ b/packages/remote-bindings/src/startDevWorker/events.ts @@ -26,19 +26,6 @@ export function castErrorCause(cause: unknown) { return error; } -// ConfigController -export type ConfigUpdateEvent = { - type: "configUpdate"; - - config: StartDevWorkerOptions; -}; - -// BundlerController -export type BundleStartEvent = { - type: "bundleStart"; - - config: StartDevWorkerOptions; -}; export type BundleCompleteEvent = { type: "bundleComplete"; @@ -46,13 +33,6 @@ export type BundleCompleteEvent = { bundle: Bundle; }; -// RuntimeController -export type ReloadStartEvent = { - type: "reloadStart"; - - config: StartDevWorkerOptions; - bundle: Bundle; -}; export type ReloadCompleteEvent = { type: "reloadComplete"; @@ -60,13 +40,6 @@ export type ReloadCompleteEvent = { bundle: Bundle; proxyData: ProxyData; }; -// ProxyController -export type PreviewTokenExpiredEvent = { - type: "previewTokenExpired"; - - proxyData: ProxyData; - // ... other details of failed request/response -}; export type ReadyEvent = { type: "ready"; proxyWorker: Miniflare; diff --git a/packages/remote-bindings/src/startDevWorker/types.ts b/packages/remote-bindings/src/startDevWorker/types.ts index 7d43da937fb..6c7791c8534 100644 --- a/packages/remote-bindings/src/startDevWorker/types.ts +++ b/packages/remote-bindings/src/startDevWorker/types.ts @@ -1,4 +1,3 @@ -import type { DevEnv } from "./DevEnv"; import type { AsyncHook, CfAccount, @@ -8,14 +7,6 @@ import type { StartDevWorkerInput, } from "@cloudflare/workers-utils"; -export interface Worker { - ready: Promise; - url: Promise; - patchConfig(config: StartDevWorkerOptions): void; - dispose(): Promise; - raw: DevEnv; -} - export type StartDevWorkerOptions = { name: string; entrypoint: string; @@ -23,14 +14,11 @@ export type StartDevWorkerOptions = { compatibilityDate: StartDevWorkerInput["compatibilityDate"]; compatibilityFlags: StartDevWorkerInput["compatibilityFlags"]; complianceRegion: Config["compliance_region"]; - dev: { - remote: "minimal"; - auth?: AsyncHook; - server: { - hostname?: string; - port: number; - secure: boolean; - }; + auth: AsyncHook; + server: { + hostname?: string; + port: number; + secure: boolean; }; }; diff --git a/packages/remote-bindings/src/utils/create-worker-preview.ts b/packages/remote-bindings/src/utils/create-worker-preview.ts index abe9bbbba58..d65417febaa 100644 --- a/packages/remote-bindings/src/utils/create-worker-preview.ts +++ b/packages/remote-bindings/src/utils/create-worker-preview.ts @@ -16,7 +16,6 @@ import { logger } from "../logger"; import type { CfWorkerInitWithName } from "./remote"; import type { ApiCredentials, - CfWorkerContext, ComplianceConfig, } from "@cloudflare/workers-utils"; import type { HeadersInit, RequestInit } from "undici"; @@ -119,33 +118,8 @@ export interface CfPreviewSession { * The host where the session is available. */ host: string; - /** - * The worker name used when the session was created. - * Used to detect when the session needs to be recreated. - */ - name: string | undefined; } -/** - * Session configuration for realish preview. This is sent to the API as the - * `wrangler-session-config` form data part. - * - * Only one of `workers_dev` and `routes` can be specified: - * * If `workers_dev` is set, the preview will run using a `workers.dev` subdomain. - * * If `routes` is set, the preview will run using the list of routes provided, which must be under a single zone - * - * `minimal_mode` is a flag to tell the API to enable "raw" mode bindings in this session - */ -type CfPreviewMode = - | { - workers_dev: true; - minimal_mode?: boolean; - } - | { - routes: string[]; - minimal_mode?: boolean; - }; - /** * A preview token. */ @@ -170,21 +144,6 @@ export interface CfPreviewToken { tailUrl?: string; } -// URLs are often relative to the zone. Sometimes the base zone -// will be grey-clouded, and so the host must be swapped out for -// the worker route host, which is more likely to be orange-clouded. -// However, this switching should only happen if we're running a zone preview -// rather than a workers.dev preview -function switchHost( - originalUrl: string, - host: string | undefined, - zonePreview: boolean -): URL { - const url = new URL(originalUrl); - url.hostname = zonePreview ? (host ?? url.hostname) : url.hostname; - return url; -} - /** * Try and get a re-encoded token from the edge. Returns null if the exchange * fails for any reason (expected with particular zone settings). @@ -192,11 +151,10 @@ function switchHost( */ async function tryExpandToken( exchangeUrl: string, - ctx: CfWorkerContext, abortSignal: AbortSignal ): Promise { try { - const switchedExchangeUrl = switchHost(exchangeUrl, ctx.host, !!ctx.zone); + const switchedExchangeUrl = new URL(exchangeUrl); const accessHeaders = await getAccessHeaders(switchedExchangeUrl.hostname, { logger, @@ -244,14 +202,11 @@ async function tryExpandToken( export async function createPreviewSession( complianceConfig: ComplianceConfig, account: CfAccount, - ctx: CfWorkerContext, abortSignal: AbortSignal, - name: string | undefined + name: string ): Promise { const { accountId } = account; - const initUrl = ctx.zone - ? `/zones/${ctx.zone}/workers/edge-preview` - : `/accounts/${accountId}/workers/subdomain/edge-preview`; + const initUrl = `/accounts/${accountId}/workers/subdomain/edge-preview`; const { token, exchange_url } = await fetchResult<{ token: string; @@ -259,35 +214,26 @@ export async function createPreviewSession( }>(complianceConfig, account, initUrl, undefined, withTimeout(abortSignal)); const previewSessionToken = exchange_url - ? ((await tryExpandToken(exchange_url, ctx, withTimeout(abortSignal))) ?? - token) + ? ((await tryExpandToken(exchange_url, withTimeout(abortSignal))) ?? token) : token; try { - let host = ctx.host; - if (!host) { - const subdomain = await getOrRegisterWorkersDevSubdomain( - complianceConfig, - account, - withTimeout(abortSignal) - ); - host = `${name ?? crypto.randomUUID()}.${subdomain}${getComplianceRegionSubdomain(complianceConfig)}.workers.dev`; - } + const subdomain = await getOrRegisterWorkersDevSubdomain( + complianceConfig, + account, + withTimeout(abortSignal) + ); + const host = `${name}.${subdomain}${getComplianceRegionSubdomain(complianceConfig)}.workers.dev`; return { value: previewSessionToken, - host: host, - name, + host, }; } catch (e) { if (!(e instanceof ParseError)) { throw e; } else { throw new UserError( - `Could not create remote preview session on ${ - ctx.zone - ? ` host \`${ctx.host}\` on zone \`${ctx.zone}\`` - : `your account` - }.`, + "Could not create remote preview session on your account.", { telemetryMessage: "remote preview session creation failed" } ); } @@ -297,41 +243,22 @@ export async function createPreviewSession( /** * Creates a preview token. */ -async function createPreviewToken( +export async function createWorkerPreview( complianceConfig: ComplianceConfig, - account: CfAccount, worker: CfWorkerInitWithName, - ctx: CfWorkerContext, + account: CfAccount, session: CfPreviewSession, - abortSignal: AbortSignal, - minimal_mode?: boolean + abortSignal: AbortSignal ): Promise { const { value, host } = session; const { accountId } = account; const url = `/accounts/${accountId}/workers/scripts/${worker.name}/edge-preview`; - const mode: CfPreviewMode = ctx.zone - ? { - routes: - ctx.routes && ctx.routes.length > 0 - ? // extract all the route patterns - ctx.routes.map((route) => { - if (typeof route === "string") { - return route; - } - if (route.custom_domain) { - return `${route.pattern}/*`; - } - return route.pattern; - }) - : // if there aren't any patterns, then just match on all routes - ["*/*"], - minimal_mode, - } - : { workers_dev: true, minimal_mode }; - const formData = createWorkerUploadForm(worker, worker.bindings); - formData.set("wrangler-session-config", JSON.stringify(mode)); + formData.set( + "wrangler-session-config", + JSON.stringify({ workers_dev: true, minimal_mode: true }) + ); const { preview_token, tail_url } = await fetchResult<{ preview_token: string; @@ -356,31 +283,3 @@ async function createPreviewToken( tailUrl: tail_url, }; } - -/** - * A stub to create a Cloudflare Worker preview. - * - * @example - * const {value, host} = await createWorker(init, acct); - */ -export async function createWorkerPreview( - complianceConfig: ComplianceConfig, - init: CfWorkerInitWithName, - account: CfAccount, - ctx: CfWorkerContext, - session: CfPreviewSession, - abortSignal: AbortSignal, - minimal_mode?: boolean -): Promise { - const token = await createPreviewToken( - complianceConfig, - account, - init, - ctx, - session, - abortSignal, - minimal_mode - ); - - return token; -} diff --git a/packages/remote-bindings/src/utils/remote.ts b/packages/remote-bindings/src/utils/remote.ts index cf6158c2966..97cef16bf71 100644 --- a/packages/remote-bindings/src/utils/remote.ts +++ b/packages/remote-bindings/src/utils/remote.ts @@ -5,10 +5,7 @@ import { APIError, UserError } from "@cloudflare/workers-utils"; import { logger } from "../logger"; import { isAbortError } from "./isAbortError"; import type { Bundle } from "../startDevWorker/types"; -import type { CfAccount } from "./create-worker-preview"; import type { - ApiCredentials, - CfWorkerContext, CfWorkerInit, StartDevWorkerInput, } from "@cloudflare/workers-utils"; @@ -160,26 +157,6 @@ export function createRemoteWorkerInit(props: { return init; } -export function getWorkerAccountAndContext(props: { - accountId: string; - apiToken: ApiCredentials; -}): { workerAccount: CfAccount; workerContext: CfWorkerContext } { - const workerAccount: CfAccount = { - accountId: props.accountId, - apiToken: props.apiToken, - }; - - const workerContext: CfWorkerContext = { - env: undefined, - zone: undefined, - host: undefined, - routes: undefined, - sendMetrics: undefined, - }; - - return { workerAccount, workerContext }; -} - /** * A switch for handling thrown error mappings to user friendly * messages, does not perform any logic other than logging errors. From 2b56c5ae7efc654aeb98a920efaa67c1c8ba8319 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 16 Jul 2026 17:19:29 +0100 Subject: [PATCH 27/37] [remote-bindings] Embed workers for direct consumers --- packages/remote-bindings/package.json | 4 +- .../remote-bindings/scripts/embed-workers.ts | 39 +++++++++++ packages/remote-bindings/src/auth.test.ts | 6 -- packages/remote-bindings/src/logger.ts | 1 - .../src/maybe-start-or-update-session.test.ts | 6 -- .../src/start-remote-proxy-session.ts | 7 +- .../src/startDevWorker/DevEnv.ts | 5 +- .../src/startDevWorker/ProxyController.ts | 16 ++--- .../src/startDevWorker/types.ts | 2 +- packages/remote-bindings/src/worker.d.ts | 4 ++ packages/remote-bindings/tsdown.config.ts | 42 ++++-------- packages/remote-bindings/vitest.config.mts | 10 +++ packages/vite-plugin-cloudflare/package.json | 1 + .../src/miniflare-options.ts | 64 +++++++++++++++---- packages/vitest-pool-workers/package.json | 1 + .../vitest-pool-workers/src/pool/config.ts | 40 +++++++++--- .../key-providers/lazy-installer.ts | 9 +-- pnpm-lock.yaml | 9 +++ 18 files changed, 173 insertions(+), 93 deletions(-) create mode 100644 packages/remote-bindings/scripts/embed-workers.ts create mode 100644 packages/remote-bindings/src/worker.d.ts create mode 100644 packages/remote-bindings/vitest.config.mts diff --git a/packages/remote-bindings/package.json b/packages/remote-bindings/package.json index c5c7cc3474c..bb80785ca6d 100644 --- a/packages/remote-bindings/package.json +++ b/packages/remote-bindings/package.json @@ -13,8 +13,7 @@ "directory": "packages/remote-bindings" }, "files": [ - "dist", - "templates" + "dist" ], "type": "module", "sideEffects": false, @@ -41,6 +40,7 @@ "@types/ws": "^8.5.13", "capnweb": "catalog:default", "chalk": "catalog:default", + "esbuild": "catalog:default", "miniflare": "workspace:*", "tsdown": "0.16.3", "typescript": "catalog:default", diff --git a/packages/remote-bindings/scripts/embed-workers.ts b/packages/remote-bindings/scripts/embed-workers.ts new file mode 100644 index 00000000000..9dbc1d747e1 --- /dev/null +++ b/packages/remote-bindings/scripts/embed-workers.ts @@ -0,0 +1,39 @@ +import path from "node:path"; +import { build } from "esbuild"; + +const WORKER_PREFIX = "\0worker:"; +const templatesDir = path.resolve(import.meta.dirname, "../templates"); + +export function embedWorkersPlugin() { + return { + name: "embed-workers", + resolveId(id: string) { + if (!id.startsWith("worker:")) { + return; + } + return `${WORKER_PREFIX}${id.slice("worker:".length)}`; + }, + async load(id: string) { + if (!id.startsWith(WORKER_PREFIX)) { + return; + } + const result = await build({ + entryPoints: [ + path.resolve(templatesDir, `${id.slice(WORKER_PREFIX.length)}.ts`), + ], + platform: "node", + conditions: ["workerd", "worker", "browser"], + format: "esm", + target: "esnext", + bundle: true, + write: false, + external: ["cloudflare:email", "cloudflare:workers"], + }); + const source = result.outputFiles[0]?.text; + if (source === undefined) { + throw new Error(`Failed to bundle Worker ${id}`); + } + return `export default ${JSON.stringify(source)};`; + }, + }; +} diff --git a/packages/remote-bindings/src/auth.test.ts b/packages/remote-bindings/src/auth.test.ts index 89950b36177..f4c5f6a0757 100644 --- a/packages/remote-bindings/src/auth.test.ts +++ b/packages/remote-bindings/src/auth.test.ts @@ -54,12 +54,6 @@ function createTestLogger(): RemoteBindingsLogger { warn: vi.fn(), error: vi.fn(), console: vi.fn(), - once: { - info: vi.fn(), - log: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, }; } diff --git a/packages/remote-bindings/src/logger.ts b/packages/remote-bindings/src/logger.ts index 412e12c2561..a6c57dad9b6 100644 --- a/packages/remote-bindings/src/logger.ts +++ b/packages/remote-bindings/src/logger.ts @@ -2,7 +2,6 @@ import type { Logger, LoggerLevel } from "@cloudflare/workers-utils"; export type RemoteBindingsLogger = Logger & { loggerLevel: LoggerLevel; - once: NonNullable; }; export let logger: RemoteBindingsLogger; diff --git a/packages/remote-bindings/src/maybe-start-or-update-session.test.ts b/packages/remote-bindings/src/maybe-start-or-update-session.test.ts index 3396388baf7..8a51a6b06d8 100644 --- a/packages/remote-bindings/src/maybe-start-or-update-session.test.ts +++ b/packages/remote-bindings/src/maybe-start-or-update-session.test.ts @@ -14,12 +14,6 @@ function createTestLogger(): RemoteBindingsLogger { warn: vi.fn(), error: vi.fn(), console: vi.fn(), - once: { - info: vi.fn(), - log: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, }; } diff --git a/packages/remote-bindings/src/start-remote-proxy-session.ts b/packages/remote-bindings/src/start-remote-proxy-session.ts index 30f772b6c81..9e00d410ea7 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.ts @@ -1,9 +1,9 @@ import { randomUUID } from "node:crypto"; import events from "node:events"; -import { fileURLToPath } from "node:url"; import { UserError } from "@cloudflare/workers-utils"; import chalk from "chalk"; import { DeferredPromise } from "miniflare"; +import remoteBindingsWorkerSource from "worker:remoteBindings/ProxyServerWorker"; import { getRemoteBindingsAuthHook } from "./auth"; import { initLogger } from "./logger"; import { DevEnv } from "./startDevWorker/DevEnv"; @@ -73,12 +73,9 @@ export async function startRemoteProxySession( initLogger(options.logger); options.logger.log(chalk.dim("⎔ Establishing remote connection...")); const rawBindings = toRawBindings(bindings); - const remoteBindingsWorkerPath = fileURLToPath( - new URL("./proxy-worker.js", import.meta.url) - ); const workerConfig = { name: options.workerName ?? randomUUID(), - entrypoint: remoteBindingsWorkerPath, + entrypointSource: remoteBindingsWorkerSource, compatibilityDate: "2025-04-28", compatibilityFlags: [], complianceRegion: options.complianceRegion, diff --git a/packages/remote-bindings/src/startDevWorker/DevEnv.ts b/packages/remote-bindings/src/startDevWorker/DevEnv.ts index b61c635199c..71a180acaa0 100644 --- a/packages/remote-bindings/src/startDevWorker/DevEnv.ts +++ b/packages/remote-bindings/src/startDevWorker/DevEnv.ts @@ -1,5 +1,4 @@ import { EventEmitter } from "node:events"; -import { readFileSync } from "node:fs"; import { UserError } from "@cloudflare/workers-utils"; import { MiniflareCoreError } from "miniflare"; import { logger } from "../logger"; @@ -35,8 +34,8 @@ export class DevEnv extends EventEmitter { this.#config = config; this.#bundle = { - path: config.entrypoint, - entrypointSource: readFileSync(config.entrypoint, "utf8"), + path: "proxy-worker.js", + entrypointSource: config.entrypointSource, type: "esm", modules: [], }; diff --git a/packages/remote-bindings/src/startDevWorker/ProxyController.ts b/packages/remote-bindings/src/startDevWorker/ProxyController.ts index 5ecc62f937b..8154e1fd3b8 100644 --- a/packages/remote-bindings/src/startDevWorker/ProxyController.ts +++ b/packages/remote-bindings/src/startDevWorker/ProxyController.ts @@ -1,9 +1,8 @@ import assert from "node:assert"; import { randomUUID } from "node:crypto"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; import { assertNever } from "@cloudflare/workers-utils"; import { LogLevel, Miniflare, Mutex, Response } from "miniflare"; +import proxyWorkerSource from "worker:startDevWorker/ProxyWorker"; import { logger } from "../logger"; import { castLogLevel, @@ -23,10 +22,6 @@ import type { import type { Bundle, StartDevWorkerOptions } from "./types"; import type { LogOptions, MiniflareOptions } from "miniflare"; -const proxyWorkerPath = fileURLToPath( - new URL("./dev-proxy-worker.mjs", import.meta.url) -); - export class ProxyController { public ready = createDeferred(); @@ -61,8 +56,13 @@ export class ProxyController { name: "ProxyWorker", compatibilityDate: "2023-12-18", compatibilityFlags: ["nodejs_compat"], - modulesRoot: path.dirname(proxyWorkerPath), - modules: [{ type: "ESModule", path: proxyWorkerPath }], + modules: [ + { + type: "ESModule", + path: "dev-proxy-worker.mjs", + contents: proxyWorkerSource, + }, + ], durableObjects: { DURABLE_OBJECT: { className: "ProxyWorker", diff --git a/packages/remote-bindings/src/startDevWorker/types.ts b/packages/remote-bindings/src/startDevWorker/types.ts index 6c7791c8534..68a33a27b06 100644 --- a/packages/remote-bindings/src/startDevWorker/types.ts +++ b/packages/remote-bindings/src/startDevWorker/types.ts @@ -9,7 +9,7 @@ import type { export type StartDevWorkerOptions = { name: string; - entrypoint: string; + entrypointSource: string; bindings: NonNullable; compatibilityDate: StartDevWorkerInput["compatibilityDate"]; compatibilityFlags: StartDevWorkerInput["compatibilityFlags"]; diff --git a/packages/remote-bindings/src/worker.d.ts b/packages/remote-bindings/src/worker.d.ts new file mode 100644 index 00000000000..2f7abc72cd1 --- /dev/null +++ b/packages/remote-bindings/src/worker.d.ts @@ -0,0 +1,4 @@ +declare module "worker:*" { + const source: string; + export default source; +} diff --git a/packages/remote-bindings/tsdown.config.ts b/packages/remote-bindings/tsdown.config.ts index 6e730d98bc4..8aac64aff59 100644 --- a/packages/remote-bindings/tsdown.config.ts +++ b/packages/remote-bindings/tsdown.config.ts @@ -1,34 +1,14 @@ import { defineConfig } from "tsdown"; +import { embedWorkersPlugin } from "./scripts/embed-workers.ts"; -export default defineConfig([ - { - entry: { - index: "src/index.ts", - }, - platform: "node", - outDir: "dist", - dts: true, - tsconfig: "tsconfig.json", - define: { - __filename: "import.meta.filename", - }, - external: ["miniflare"], +export default defineConfig({ + entry: { + index: "src/index.ts", }, - { - entry: { - "proxy-worker": "templates/remoteBindings/ProxyServerWorker.ts", - }, - platform: "neutral", - outDir: "dist", - dts: false, - external: ["cloudflare:email", "cloudflare:workers"], - }, - { - entry: { - "dev-proxy-worker": "templates/startDevWorker/ProxyWorker.ts", - }, - platform: "node", - outDir: "dist", - dts: false, - }, -]); + platform: "node", + outDir: "dist", + dts: true, + tsconfig: "tsconfig.json", + external: [/^(?!(?:\0)?worker:)[^./]/], + plugins: [embedWorkersPlugin()], +}); diff --git a/packages/remote-bindings/vitest.config.mts b/packages/remote-bindings/vitest.config.mts new file mode 100644 index 00000000000..0c3c424439b --- /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 be7c0897655..e2107bc8cf3 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 5b9eef7644a..ee8551ac8ca 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 61566dca876..955d2102c6c 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 a6ee42ca974..9ce9f0ab7c3 100644 --- a/packages/vitest-pool-workers/src/pool/config.ts +++ b/packages/vitest-pool-workers/src/pool/config.ts @@ -1,4 +1,6 @@ import path from "node:path"; +import { maybeStartOrUpdateRemoteProxySession } from "@cloudflare/remote-bindings"; +import { getCloudflareComplianceRegion } from "@cloudflare/workers-utils"; import { formatZodError, getRootPath, @@ -14,9 +16,12 @@ import { getRelativeProjectConfigPath, getRelativeProjectPath, } from "./helpers"; +import type { + RemoteBindingsLogger, + RemoteProxySessionData, +} from "@cloudflare/remote-bindings"; import type { ModuleRule, WorkerOptions } from "miniflare"; import type { TestProject } from "vitest/node"; -import type { Binding, RemoteProxySession } from "wrangler"; import type { ParseParams, ZodError } from "zod"; export interface WorkersConfigPluginAPI { @@ -151,6 +156,18 @@ function parseWorkerOptions( const log = new Log(LogLevel.WARN, { prefix: "vpw" }); +const remoteBindingsLogger: RemoteBindingsLogger = { + loggerLevel: "log", + debug: console.debug, + log: console.log, + info: console.info, + warn: console.warn, + error: console.error, + console(method, ...args) { + Reflect.apply(console[method], console, args); + }, +}; + function filterTails( tails: WorkerOptions["tails"], userWorkers?: { name?: string }[] @@ -186,10 +203,7 @@ function filterTails( /** Map that maps worker configPaths to their existing remote proxy session data (if any) */ export const remoteProxySessionsDataMap = new Map< string, - { - session: RemoteProxySession; - remoteBindings: Record; - } | null + RemoteProxySessionData | null >(); async function parseCustomPoolOptions( @@ -267,12 +281,20 @@ async function parseCustomPoolOptions( : undefined; const remoteProxySessionData = options.remoteBindings - ? await wrangler.maybeStartOrUpdateRemoteProxySession( + ? await maybeStartOrUpdateRemoteProxySession( { - path: options.wrangler.configPath, - environment: options.wrangler.environment, + name: wranglerConfig.name ?? "worker", + bindings: + wrangler.unstable_convertConfigBindingsToStartWorkerBindings( + wranglerConfig + ) ?? {}, + complianceRegion: getCloudflareComplianceRegion(wranglerConfig), + account_id: wranglerConfig.account_id, + profileDir: path.dirname(configPath), }, - preExistingRemoteProxySessionData ?? null + preExistingRemoteProxySessionData ?? null, + undefined, + { logger: remoteBindingsLogger } ) : null; diff --git a/packages/workers-auth/src/credential-store/key-providers/lazy-installer.ts b/packages/workers-auth/src/credential-store/key-providers/lazy-installer.ts index 61333ed16bb..17169822cc7 100644 --- a/packages/workers-auth/src/credential-store/key-providers/lazy-installer.ts +++ b/packages/workers-auth/src/credential-store/key-providers/lazy-installer.ts @@ -258,13 +258,6 @@ interface KeyringModule { Entry: new (service: string, account: string) => KeyringEntry; } -// `createRequire` is used to load the lazy-installed binding from an -// absolute path computed at runtime, defeating esbuild's static analysis -// of `require(...)`. The anchor `__filename` is provided in both the -// bundled CJS output and the source-loaded test environment. -// eslint-disable-next-line no-restricted-globals -- runtime resolution requires a CJS anchor -const dynamicRequire = createRequire(__filename); - /** * Resolve the active keyring entry factory, loading the lazy-installed * binding from `installDir` when no test override is registered. @@ -281,6 +274,6 @@ export function resolveKeyringEntryFactory( "`@napi-rs/keyring` binding not found. Call `installKeyringBindingSync()` first." ); } - const mod = dynamicRequire(bindingPath) as KeyringModule; + const mod = createRequire(bindingPath)(bindingPath) as KeyringModule; return (service, account) => new mod.Entry(service, account); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 002ccebfdab..65319cf5d0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2586,6 +2586,9 @@ importers: chalk: specifier: catalog:default version: 5.3.0 + esbuild: + specifier: catalog:default + version: 0.28.1 miniflare: specifier: workspace:* version: link:../miniflare @@ -2703,6 +2706,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 @@ -3947,6 +3953,9 @@ importers: '@cloudflare/mock-npm-registry': specifier: workspace:* version: link:../mock-npm-registry + '@cloudflare/remote-bindings': + specifier: workspace:* + version: link:../remote-bindings '@cloudflare/workers-tsconfig': specifier: workspace:* version: link:../workers-tsconfig From 99d736ae5454afcca0a7862932d5e415d96fefe3 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 16 Jul 2026 18:29:29 +0100 Subject: [PATCH 28/37] [remote-bindings] Fix direct consumer integration --- packages/remote-bindings/src/logger.ts | 1 + .../src/start-remote-proxy-session.ts | 27 ++++++++- .../remoteBindings/ProxyServerWorker.ts | 21 +++++-- .../templates/startDevWorker/ProxyWorker.ts | 12 +++- packages/remote-bindings/tsdown.config.ts | 9 ++- packages/remote-bindings/turbo.json | 3 + packages/workers-auth/src/access.ts | 3 +- packages/workers-utils/src/logger.ts | 2 +- .../dev/remote-bindings-errors.test.ts | 60 +++++++++---------- .../src/__tests__/dev/remote-bindings.test.ts | 7 ++- packages/wrangler/src/user/access.ts | 1 + 11 files changed, 99 insertions(+), 47 deletions(-) diff --git a/packages/remote-bindings/src/logger.ts b/packages/remote-bindings/src/logger.ts index a6c57dad9b6..d24891633bc 100644 --- a/packages/remote-bindings/src/logger.ts +++ b/packages/remote-bindings/src/logger.ts @@ -2,6 +2,7 @@ import type { Logger, LoggerLevel } from "@cloudflare/workers-utils"; export type RemoteBindingsLogger = Logger & { loggerLevel: LoggerLevel; + console: NonNullable; }; export let logger: RemoteBindingsLogger; diff --git a/packages/remote-bindings/src/start-remote-proxy-session.ts b/packages/remote-bindings/src/start-remote-proxy-session.ts index 9e00d410ea7..cbf789e11cd 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.ts @@ -7,6 +7,7 @@ import remoteBindingsWorkerSource from "worker:remoteBindings/ProxyServerWorker" import { getRemoteBindingsAuthHook } from "./auth"; import { initLogger } from "./logger"; import { DevEnv } from "./startDevWorker/DevEnv"; +import { RemoteSessionAuthenticationError } from "./utils/remote"; import type { RemoteBindingsLogger } from "./logger"; import type { AsyncHook, @@ -70,8 +71,8 @@ export async function startRemoteProxySession( bindings: StartDevWorkerInput["bindings"], options: StartRemoteProxySessionOptions ): Promise { - initLogger(options.logger); options.logger.log(chalk.dim("⎔ Establishing remote connection...")); + initLogger(getInternalLogger(options.logger)); const rawBindings = toRawBindings(bindings); const workerConfig = { name: options.workerName ?? randomUUID(), @@ -123,6 +124,12 @@ export async function startRemoteProxySession( ]); if (maybeError && maybeError.error) { + if ( + isErrorEvent(maybeError.error) && + maybeError.error.cause instanceof RemoteSessionAuthenticationError + ) { + throw maybeError.error.cause; + } const details = formatRemoteProxySessionError(maybeError.error); throw new Error( details @@ -186,3 +193,21 @@ function toRawBindings(bindings: StartDevWorkerInput["bindings"]) { ]) ); } + +function getInternalLogger(logger: RemoteBindingsLogger): RemoteBindingsLogger { + if (logger.loggerLevel === "debug") { + return logger; + } + + const disabled = () => {}; + const loggerLevel = logger.loggerLevel === "none" ? "none" : "error"; + return { + loggerLevel, + debug: disabled, + log: disabled, + info: disabled, + warn: disabled, + error: loggerLevel === "none" ? disabled : logger.error.bind(logger), + console: disabled, + }; +} diff --git a/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts b/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts index 4a43f4518cd..0ca9c5b8a6f 100644 --- a/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts +++ b/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts @@ -1,7 +1,15 @@ import { newWorkersRpcResponse } from "capnweb"; import { EmailMessage } from "cloudflare:email"; -interface Env extends Record {} +type Env = Record; + +type SendEmailInput = + | Parameters[0] + | { + from: string; + to: string; + "EmailMessage::raw": ReadableStream; + }; class BindingNotFoundError extends Error { constructor(name?: string) { @@ -42,7 +50,7 @@ function getExposedJSRPCBinding(request: Request, env: Env) { if (targetBinding.constructor.name === "SendEmail") { return { - async send(e: any) { + async send(e: SendEmailInput) { // Check if this is an EmailMessage (has EmailMessage::raw property) or MessageBuilder if ("EmailMessage::raw" in e) { // EmailMessage API - reconstruct the EmailMessage object @@ -60,10 +68,11 @@ function getExposedJSRPCBinding(request: Request, env: Env) { }; } - if (url.searchParams.has("MF-Dispatch-Namespace-Options")) { - const { name, args, options } = JSON.parse( - url.searchParams.get("MF-Dispatch-Namespace-Options")! - ); + const dispatchNamespaceOptions = url.searchParams.get( + "MF-Dispatch-Namespace-Options" + ); + if (dispatchNamespaceOptions) { + const { name, args, options } = JSON.parse(dispatchNamespaceOptions); return (targetBinding as DispatchNamespace).get(name, args, options); } diff --git a/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts b/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts index 70beec0cce4..fb89a87794d 100644 --- a/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts +++ b/packages/remote-bindings/templates/startDevWorker/ProxyWorker.ts @@ -87,7 +87,9 @@ export class ProxyWorker implements DurableObject { processQueue() { const { proxyData } = this; // store proxyData at the moment this function was called - if (proxyData === undefined) return; + if (proxyData === undefined) { + return; + } for (const [request, deferredResponse] of this.getOrderedQueue()) { this.requestRetryQueue.delete(request); @@ -109,7 +111,9 @@ export class ProxyWorker implements DurableObject { // Preserve client `Accept-Encoding`, rather than using Worker's default // of `Accept-Encoding: br, gzip` const encoding = request.cf?.clientAcceptEncoding; - if (encoding !== undefined) headers.set("Accept-Encoding", encoding); + if (encoding !== undefined) { + headers.set("Accept-Encoding", encoding); + } rewriteUrlRelatedHeaders(headers, outerUrl, innerUrl); @@ -120,7 +124,9 @@ export class ProxyWorker implements DurableObject { // merge proxyData headers with the request headers for (const [key, value] of Object.entries(proxyData.headers ?? {})) { - if (value === undefined) continue; + if (value === undefined) { + continue; + } if (key.toLowerCase() === "cookie") { const existing = request.headers.get("cookie") ?? ""; diff --git a/packages/remote-bindings/tsdown.config.ts b/packages/remote-bindings/tsdown.config.ts index 8aac64aff59..ba6944b7c81 100644 --- a/packages/remote-bindings/tsdown.config.ts +++ b/packages/remote-bindings/tsdown.config.ts @@ -9,6 +9,13 @@ export default defineConfig({ outDir: "dist", dts: true, tsconfig: "tsconfig.json", - external: [/^(?!(?:\0)?worker:)[^./]/], + external: (id) => { + const unprefixedId = id.charCodeAt(0) === 0 ? id.slice(1) : id; + return ( + !unprefixedId.startsWith("worker:") && + !unprefixedId.startsWith(".") && + !unprefixedId.startsWith("/") + ); + }, plugins: [embedWorkersPlugin()], }); diff --git a/packages/remote-bindings/turbo.json b/packages/remote-bindings/turbo.json index 6556dcf3e5e..e1ecb712513 100644 --- a/packages/remote-bindings/turbo.json +++ b/packages/remote-bindings/turbo.json @@ -4,6 +4,9 @@ "tasks": { "build": { "outputs": ["dist/**"] + }, + "test:ci": { + "env": ["CLOUDFLARE_CF_AUTH"] } } } diff --git a/packages/workers-auth/src/access.ts b/packages/workers-auth/src/access.ts index c7e8dee7d58..5888e66480d 100644 --- a/packages/workers-auth/src/access.ts +++ b/packages/workers-auth/src/access.ts @@ -89,6 +89,7 @@ export async function getAccessHeaders( domain: string, options: { logger: OAuthFlowLogger; + isNonInteractiveOrCI?: () => boolean; } ): Promise> { const logger = options.logger; @@ -137,7 +138,7 @@ export async function getAccessHeaders( } // 2. If non-interactive (CI), error with actionable message - if (isNonInteractiveOrCI()) { + if ((options.isNonInteractiveOrCI ?? isNonInteractiveOrCI)()) { throw new UserError( `The domain "${domain}" is behind Cloudflare Access, but no Access Service Token credentials were found ` + `and the current environment is non-interactive.\n` + diff --git a/packages/workers-utils/src/logger.ts b/packages/workers-utils/src/logger.ts index c134bef460d..1e398cd0838 100644 --- a/packages/workers-utils/src/logger.ts +++ b/packages/workers-utils/src/logger.ts @@ -23,7 +23,7 @@ export type Logger = { warn: typeof console.warn; error: typeof console.error; }; - console>( + console?>( method: M, ...args: Parameters ): void; diff --git a/packages/wrangler/src/__tests__/dev/remote-bindings-errors.test.ts b/packages/wrangler/src/__tests__/dev/remote-bindings-errors.test.ts index 919391c220e..eaadda4e532 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 2acc90c9663..a92bda092ae 100644 --- a/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts +++ b/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts @@ -21,8 +21,9 @@ import { mswZoneHandlers, } from "../helpers/msw"; import { runWrangler } from "../helpers/run-wrangler"; -import type { Binding, StartRemoteProxySessionOptions } from "../../api"; +import type { Binding } from "../../api"; import type { StartDevOptions } from "../../dev"; +import type { StartRemoteProxySessionOptions } from "@cloudflare/remote-bindings"; import type { RawConfig } from "@cloudflare/workers-utils"; import type { RemoteProxyConnectionString, WorkerOptions } from "miniflare"; @@ -789,7 +790,7 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => { workerName: "worker", }); assert(auth); - expect(await unwrapHook(auth, { account_id: undefined })).toEqual({ + expect(await unwrapHook(auth)).toEqual({ accountId: "some-account-id", apiToken: { apiToken: "some-api-token" }, }); @@ -833,7 +834,7 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => { workerName: "worker", }); assert(auth2); - expect(await unwrapHook(auth2, { account_id: undefined })).toEqual({ + expect(await unwrapHook(auth2)).toEqual({ accountId: "mock-account-id", apiToken: { apiToken: "some-api-token" }, }); diff --git a/packages/wrangler/src/user/access.ts b/packages/wrangler/src/user/access.ts index ade9a3582d3..1959773c7a2 100644 --- a/packages/wrangler/src/user/access.ts +++ b/packages/wrangler/src/user/access.ts @@ -22,5 +22,6 @@ export async function getAccessHeaders( ): Promise> { return packageGetAccessHeaders(domain, { logger, + isNonInteractiveOrCI, }); } From d32d57efbe7842f607ed96bcb1a46568f1a71f03 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 16 Jul 2026 20:58:37 +0100 Subject: [PATCH 29/37] [remote-bindings] Fix Windows worker embedding build --- packages/remote-bindings/tsdown.config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/remote-bindings/tsdown.config.ts b/packages/remote-bindings/tsdown.config.ts index ba6944b7c81..d11d1f8cd25 100644 --- a/packages/remote-bindings/tsdown.config.ts +++ b/packages/remote-bindings/tsdown.config.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { defineConfig } from "tsdown"; import { embedWorkersPlugin } from "./scripts/embed-workers.ts"; @@ -14,7 +15,7 @@ export default defineConfig({ return ( !unprefixedId.startsWith("worker:") && !unprefixedId.startsWith(".") && - !unprefixedId.startsWith("/") + !path.isAbsolute(unprefixedId) ); }, plugins: [embedWorkersPlugin()], From 5e85425378cfdba4ba91032d22d4002a9cb8695b Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 16 Jul 2026 20:58:37 +0100 Subject: [PATCH 30/37] [wrangler] Update invalid account error snapshot --- fixtures/get-platform-proxy-remote-bindings/tests/index.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fixtures/get-platform-proxy-remote-bindings/tests/index.test.ts b/fixtures/get-platform-proxy-remote-bindings/tests/index.test.ts index c0d8a508e9a..b6914ab21ce 100644 --- a/fixtures/get-platform-proxy-remote-bindings/tests/index.test.ts +++ b/fixtures/get-platform-proxy-remote-bindings/tests/index.test.ts @@ -259,6 +259,8 @@ if (auth) { ).toMatchInlineSnapshot(` "X [ERROR] A request to the Cloudflare API (/accounts/NOT a valid account id/workers/subdomain/edge-preview) failed. + Could not route to /client/v4/accounts/NOT%20a%20valid%20account%20id/workers/subdomain/edge-preview, perhaps your object identifier is invalid? [code: 7003] + " `); }); From 4deab4fbe3d4f304898a3e1ce5406212e8cafdbb Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Fri, 17 Jul 2026 11:49:17 +0100 Subject: [PATCH 31/37] [remote-bindings] Prevent stale preview bindings --- .../src/startDevWorker/DevEnv.test.ts | 48 +++++++++++++++++++ .../src/startDevWorker/DevEnv.ts | 7 ++- 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 packages/remote-bindings/src/startDevWorker/DevEnv.test.ts diff --git a/packages/remote-bindings/src/startDevWorker/DevEnv.test.ts b/packages/remote-bindings/src/startDevWorker/DevEnv.test.ts new file mode 100644 index 00000000000..183c75102d4 --- /dev/null +++ b/packages/remote-bindings/src/startDevWorker/DevEnv.test.ts @@ -0,0 +1,48 @@ +import { describe, it, vi } from "vitest"; +import { DevEnv } from "./DevEnv"; +import type { ProxyController } from "./ProxyController"; +import type { RemoteRuntimeController } from "./RemoteRuntimeController"; +import type { StartDevWorkerOptions } from "./types"; + +const config: StartDevWorkerOptions = { + name: "remote-bindings-proxy", + entrypointSource: "export default {};", + bindings: {}, + compatibilityDate: "2026-07-17", + compatibilityFlags: [], + complianceRegion: undefined, + auth: () => ({ + accountId: "account-id", + apiToken: { apiToken: "api-token" }, + }), + server: { port: 0, secure: false }, +}; + +describe("DevEnv", () => { + it("changes the uploaded source on every update", ({ expect }) => { + const devEnv = new DevEnv(config); + const onBundleComplete = + vi.fn(); + devEnv.proxy = { pause: vi.fn() } as unknown as ProxyController; + devEnv.runtime = { + onUpdateStart: vi.fn(), + onBundleComplete, + } as unknown as RemoteRuntimeController; + + devEnv.update(config); + devEnv.update(config); + + const [firstCall, secondCall] = onBundleComplete.mock.calls; + if (!firstCall || !secondCall) { + throw new Error("Expected two bundle updates"); + } + const firstBundle = firstCall[0].bundle; + const secondBundle = secondCall[0].bundle; + expect(firstBundle.entrypointSource).toBe( + "export default {};\n// remote-bindings-update:1" + ); + expect(secondBundle.entrypointSource).toBe( + "export default {};\n// remote-bindings-update:2" + ); + }); +}); diff --git a/packages/remote-bindings/src/startDevWorker/DevEnv.ts b/packages/remote-bindings/src/startDevWorker/DevEnv.ts index 71a180acaa0..00c66ad5633 100644 --- a/packages/remote-bindings/src/startDevWorker/DevEnv.ts +++ b/packages/remote-bindings/src/startDevWorker/DevEnv.ts @@ -11,6 +11,7 @@ export class DevEnv extends EventEmitter { runtime: RemoteRuntimeController; proxy: ProxyController; #bundle: Bundle; + #bundleVersion = 0; #config: StartDevWorkerOptions; start() { @@ -25,7 +26,11 @@ export class DevEnv extends EventEmitter { this.runtime.onBundleComplete({ type: "bundleComplete", config, - bundle: this.#bundle, + bundle: { + ...this.#bundle, + // Ensure binding-only updates cannot reuse the previous edge-preview artifact. + entrypointSource: `${this.#bundle.entrypointSource}\n// remote-bindings-update:${++this.#bundleVersion}`, + }, }); } From 25c68669306c1e6a4236f96045b67a80d2d9e4c2 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Fri, 26 Jun 2026 17:48:22 +0100 Subject: [PATCH 32/37] [miniflare] Route storage/remote bindings through shared services via props Move the per-binding configuration for storage and remote (mixed-mode) bindings out of per-resource workerd services and into runtime `ctx.props`, so a single shared service can serve any number of bindings. - Local KV namespaces now share one entry service; the namespace id is passed via props and resolved in object-entry.worker.ts (idFromName). - remoteProxyClientWorker() is now script-only; the connection string, binding name and trace id travel via props (buildRemoteProxyProps), read in remote-proxy-client.worker.ts and the dispatch-namespace proxy. - All remote-binding plugins emit one shared remote-proxy service instead of one per resource. - explorer.ts reads the KV namespace id from binding props rather than parsing it out of the service name. --- .../src/plugins/agent-memory/index.ts | 30 +++---- .../miniflare/src/plugins/ai-search/index.ts | 74 ++++----------- packages/miniflare/src/plugins/ai/index.ts | 22 ++--- .../miniflare/src/plugins/artifacts/index.ts | 30 +++---- .../src/plugins/browser-rendering/index.ts | 90 +++++++++++-------- .../miniflare/src/plugins/core/explorer.ts | 21 ++++- packages/miniflare/src/plugins/core/index.ts | 8 +- packages/miniflare/src/plugins/d1/index.ts | 67 ++++++++------ .../src/plugins/dispatch-namespace/index.ts | 41 ++++----- packages/miniflare/src/plugins/email/index.ts | 72 +++++++++------ .../miniflare/src/plugins/flagship/index.ts | 31 +++---- .../miniflare/src/plugins/images/index.ts | 41 +++++---- packages/miniflare/src/plugins/kv/index.ts | 89 ++++++++++++------ packages/miniflare/src/plugins/media/index.ts | 22 ++--- packages/miniflare/src/plugins/mtls/index.ts | 35 ++++---- .../miniflare/src/plugins/pipelines/index.ts | 54 +++++++---- packages/miniflare/src/plugins/r2/index.ts | 65 +++++++++----- .../miniflare/src/plugins/shared/constants.ts | 65 +++++++------- .../miniflare/src/plugins/stream/index.ts | 37 ++++---- .../miniflare/src/plugins/vectorize/index.ts | 31 +++---- .../src/plugins/vpc-networks/index.ts | 36 +++----- .../src/plugins/vpc-services/index.ts | 32 +++---- .../miniflare/src/plugins/websearch/index.ts | 28 +++--- .../dispatch-namespace-proxy.worker.ts | 20 +++-- .../src/workers/shared/object-entry.worker.ts | 23 ++++- .../workers/shared/remote-bindings-utils.ts | 17 +++- .../shared/remote-proxy-client.worker.ts | 31 ++++--- 27 files changed, 588 insertions(+), 524 deletions(-) diff --git a/packages/miniflare/src/plugins/agent-memory/index.ts b/packages/miniflare/src/plugins/agent-memory/index.ts index b9630e26a48..5a04bab0210 100644 --- a/packages/miniflare/src/plugins/agent-memory/index.ts +++ b/packages/miniflare/src/plugins/agent-memory/index.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { - getUserBindingServiceName, + buildRemoteProxyProps, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; @@ -20,6 +20,7 @@ export const AgentMemoryOptionsSchema = z.object({ export const AGENT_MEMORY_PLUGIN_NAME = "agent-memory"; const AGENT_MEMORY_SCOPE = "agent-memory"; +const AGENT_MEMORY_REMOTE_SERVICE_NAME = `${AGENT_MEMORY_SCOPE}:remote`; export const AGENT_MEMORY_PLUGIN: Plugin = { options: AgentMemoryOptionsSchema, @@ -32,10 +33,10 @@ export const AGENT_MEMORY_PLUGIN: Plugin = { return Object.entries(options.agentMemory).map(([bindingName, entry]) => ({ name: bindingName, service: { - name: getUserBindingServiceName( - AGENT_MEMORY_SCOPE, - bindingName, - entry.remoteProxyConnectionString + name: AGENT_MEMORY_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + entry.remoteProxyConnectionString, + bindingName ), }, })); @@ -53,20 +54,15 @@ export const AGENT_MEMORY_PLUGIN: Plugin = { ); }, async getServices({ options }) { - if (!options.agentMemory) { + if (!options.agentMemory || Object.keys(options.agentMemory).length === 0) { return []; } - return Object.entries(options.agentMemory).map(([bindingName, entry]) => ({ - name: getUserBindingServiceName( - AGENT_MEMORY_SCOPE, - bindingName, - entry.remoteProxyConnectionString - ), - worker: remoteProxyClientWorker( - entry.remoteProxyConnectionString, - bindingName - ), - })); + return [ + { + name: AGENT_MEMORY_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), + }, + ]; }, }; diff --git a/packages/miniflare/src/plugins/ai-search/index.ts b/packages/miniflare/src/plugins/ai-search/index.ts index 27ed2d69be0..0c40ab13529 100644 --- a/packages/miniflare/src/plugins/ai-search/index.ts +++ b/packages/miniflare/src/plugins/ai-search/index.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { - getUserBindingServiceName, + buildRemoteProxyProps, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; @@ -21,10 +21,8 @@ export const AISearchOptionsSchema = z.object({ export const AI_SEARCH_PLUGIN_NAME = "ai-search"; -// Distinct scopes for service name generation to avoid collisions -// between namespace and instance bindings with the same binding name. -const AI_SEARCH_NS_SCOPE = "ai-search-ns"; -const AI_SEARCH_INST_SCOPE = "ai-search-inst"; +// One shared remote-proxy service for all AI Search bindings (config via props). +const AI_SEARCH_REMOTE_SERVICE_NAME = `${AI_SEARCH_PLUGIN_NAME}:remote`; export const AI_SEARCH_PLUGIN: Plugin = { options: AISearchOptionsSchema, @@ -32,34 +30,20 @@ export const AI_SEARCH_PLUGIN: Plugin = { async getBindings(options) { const bindings: { name: string; - service: { name: string }; + service: { name: string; props?: { json: string } }; }[] = []; - for (const [bindingName, entry] of Object.entries( - options.aiSearchNamespaces ?? {} - )) { + for (const [bindingName, entry] of [ + ...Object.entries(options.aiSearchNamespaces ?? {}), + ...Object.entries(options.aiSearchInstances ?? {}), + ]) { bindings.push({ name: bindingName, service: { - name: getUserBindingServiceName( - AI_SEARCH_NS_SCOPE, - bindingName, - entry.remoteProxyConnectionString - ), - }, - }); - } - - for (const [bindingName, entry] of Object.entries( - options.aiSearchInstances ?? {} - )) { - bindings.push({ - name: bindingName, - service: { - name: getUserBindingServiceName( - AI_SEARCH_INST_SCOPE, - bindingName, - entry.remoteProxyConnectionString + name: AI_SEARCH_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + entry.remoteProxyConnectionString, + bindingName ), }, }); @@ -85,35 +69,13 @@ export const AI_SEARCH_PLUGIN: Plugin = { worker: ReturnType; }[] = []; - for (const [bindingName, entry] of Object.entries( - options.aiSearchNamespaces ?? {} - )) { - services.push({ - name: getUserBindingServiceName( - AI_SEARCH_NS_SCOPE, - bindingName, - entry.remoteProxyConnectionString - ), - worker: remoteProxyClientWorker( - entry.remoteProxyConnectionString, - bindingName - ), - }); - } - - for (const [bindingName, entry] of Object.entries( - options.aiSearchInstances ?? {} - )) { + const hasAny = + Object.keys(options.aiSearchNamespaces ?? {}).length > 0 || + Object.keys(options.aiSearchInstances ?? {}).length > 0; + if (hasAny) { services.push({ - name: getUserBindingServiceName( - AI_SEARCH_INST_SCOPE, - bindingName, - entry.remoteProxyConnectionString - ), - worker: remoteProxyClientWorker( - entry.remoteProxyConnectionString, - bindingName - ), + name: AI_SEARCH_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), }); } diff --git a/packages/miniflare/src/plugins/ai/index.ts b/packages/miniflare/src/plugins/ai/index.ts index 649d943583a..b24616f49f4 100644 --- a/packages/miniflare/src/plugins/ai/index.ts +++ b/packages/miniflare/src/plugins/ai/index.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { - getUserBindingServiceName, + buildRemoteProxyProps, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; @@ -18,6 +18,7 @@ export const AIOptionsSchema = z.object({ }); export const AI_PLUGIN_NAME = "ai"; +const AI_REMOTE_SERVICE_NAME = `${AI_PLUGIN_NAME}:remote`; export const AI_PLUGIN: Plugin = { options: AIOptionsSchema, @@ -36,10 +37,10 @@ export const AI_PLUGIN: Plugin = { { name: "fetcher", service: { - name: getUserBindingServiceName( - AI_PLUGIN_NAME, - options.ai.binding, - options.ai.remoteProxyConnectionString + name: AI_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + options.ai.remoteProxyConnectionString, + options.ai.binding ), }, }, @@ -63,15 +64,8 @@ export const AI_PLUGIN: Plugin = { return [ { - name: getUserBindingServiceName( - AI_PLUGIN_NAME, - options.ai.binding, - options.ai.remoteProxyConnectionString - ), - worker: remoteProxyClientWorker( - options.ai.remoteProxyConnectionString, - options.ai.binding - ), + name: AI_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), }, ]; }, diff --git a/packages/miniflare/src/plugins/artifacts/index.ts b/packages/miniflare/src/plugins/artifacts/index.ts index 881ddfd217e..79231159fa3 100644 --- a/packages/miniflare/src/plugins/artifacts/index.ts +++ b/packages/miniflare/src/plugins/artifacts/index.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { - getUserBindingServiceName, + buildRemoteProxyProps, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; @@ -18,6 +18,9 @@ export const ArtifactsOptionsSchema = z.object({ }); export const ARTIFACTS_PLUGIN_NAME = "artifacts"; +// One shared remote-proxy service for every artifacts binding; per-binding +// config travels via props. +const ARTIFACTS_REMOTE_SERVICE_NAME = `${ARTIFACTS_PLUGIN_NAME}:remote`; export const ARTIFACTS_PLUGIN: Plugin = { options: ArtifactsOptionsSchema, @@ -30,11 +33,8 @@ export const ARTIFACTS_PLUGIN: Plugin = { return Object.entries(options.artifacts).map(([name, config]) => ({ name, service: { - name: getUserBindingServiceName( - ARTIFACTS_PLUGIN_NAME, - name, - config.remoteProxyConnectionString - ), + name: ARTIFACTS_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps(config.remoteProxyConnectionString, name), }, })); }, @@ -50,19 +50,15 @@ export const ARTIFACTS_PLUGIN: Plugin = { ); }, async getServices({ options }) { - if (!options.artifacts) { + if (!options.artifacts || Object.keys(options.artifacts).length === 0) { return []; } - return Object.entries(options.artifacts).map( - ([name, { remoteProxyConnectionString }]) => ({ - name: getUserBindingServiceName( - ARTIFACTS_PLUGIN_NAME, - name, - remoteProxyConnectionString - ), - worker: remoteProxyClientWorker(remoteProxyConnectionString, name), - }) - ); + return [ + { + name: ARTIFACTS_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), + }, + ]; }, }; diff --git a/packages/miniflare/src/plugins/browser-rendering/index.ts b/packages/miniflare/src/plugins/browser-rendering/index.ts index 3bd0fff4757..8ac011b0b28 100644 --- a/packages/miniflare/src/plugins/browser-rendering/index.ts +++ b/packages/miniflare/src/plugins/browser-rendering/index.ts @@ -18,6 +18,7 @@ import BROWSER_RENDERING_WORKER from "worker:browser-rendering/binding"; import { z } from "zod"; import { kVoid } from "../../runtime"; import { + buildRemoteProxyProps, getUserBindingServiceName, ProxyNodeBinding, remoteProxyClientWorker, @@ -40,6 +41,7 @@ export const BrowserRenderingOptionsSchema = z.object({ }); export const BROWSER_RENDERING_PLUGIN_NAME = "browser-rendering"; +const BROWSER_RENDERING_REMOTE_SERVICE_NAME = `${BROWSER_RENDERING_PLUGIN_NAME}:remote`; export const BROWSER_RENDERING_PLUGIN: Plugin< typeof BrowserRenderingOptionsSchema @@ -54,13 +56,20 @@ export const BROWSER_RENDERING_PLUGIN: Plugin< return [ { name: options.browserRendering.binding, - service: { - name: getUserBindingServiceName( - BROWSER_RENDERING_PLUGIN_NAME, - "service", - options.browserRendering.remoteProxyConnectionString - ), - }, + service: options.browserRendering.remoteProxyConnectionString + ? { + name: BROWSER_RENDERING_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + options.browserRendering.remoteProxyConnectionString, + options.browserRendering.binding + ), + } + : { + name: getUserBindingServiceName( + BROWSER_RENDERING_PLUGIN_NAME, + "service" + ), + }, }, ]; }, @@ -77,44 +86,47 @@ export const BROWSER_RENDERING_PLUGIN: Plugin< return []; } + if (options.browserRendering.remoteProxyConnectionString) { + return [ + { + name: BROWSER_RENDERING_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), + }, + ]; + } + return [ { name: getUserBindingServiceName( BROWSER_RENDERING_PLUGIN_NAME, - "service", - options.browserRendering.remoteProxyConnectionString + "service" ), - worker: options.browserRendering.remoteProxyConnectionString - ? remoteProxyClientWorker( - options.browserRendering.remoteProxyConnectionString, - options.browserRendering.binding - ) - : { - compatibilityDate: "2025-05-01", - compatibilityFlags: ["nodejs_compat"], - modules: [ - { - name: "index.worker.js", - esModule: BROWSER_RENDERING_WORKER(), - }, - ], - bindings: [ - WORKER_BINDING_SERVICE_LOOPBACK, - { - name: "BrowserSession", - durableObjectNamespace: { - className: "BrowserSession", - }, - }, - ], - durableObjectNamespaces: [ - { - className: "BrowserSession", - uniqueKey: "miniflare-BrowserSession", - }, - ], - durableObjectStorage: { inMemory: kVoid }, + worker: { + compatibilityDate: "2025-05-01", + compatibilityFlags: ["nodejs_compat"], + modules: [ + { + name: "index.worker.js", + esModule: BROWSER_RENDERING_WORKER(), + }, + ], + bindings: [ + WORKER_BINDING_SERVICE_LOOPBACK, + { + name: "BrowserSession", + durableObjectNamespace: { + className: "BrowserSession", + }, }, + ], + durableObjectNamespaces: [ + { + className: "BrowserSession", + uniqueKey: "miniflare-BrowserSession", + }, + ], + durableObjectStorage: { inMemory: kVoid }, + }, }, ]; }, diff --git a/packages/miniflare/src/plugins/core/explorer.ts b/packages/miniflare/src/plugins/core/explorer.ts index 022e3ca69f7..7e17adfbd52 100644 --- a/packages/miniflare/src/plugins/core/explorer.ts +++ b/packages/miniflare/src/plugins/core/explorer.ts @@ -7,7 +7,7 @@ import { type Worker_Binding, type Worker_Module, } from "../../runtime"; -import { CoreBindings } from "../../workers"; +import { CoreBindings, SharedBindings } from "../../workers"; import { normaliseDurableObject } from "../do"; import { namespaceEntries, @@ -180,7 +180,10 @@ export function constructExplorerBindingMap( IDToBindingName.d1[databaseId] = binding.name; } - // KV bindings: name = "MINIFLARE_PROXY:kv:worker:BINDING", kvNamespace.name = "kv:ns:ID" + // KV bindings: name = "MINIFLARE_PROXY:kv:worker:BINDING". + // Local namespaces share one entry service ("kv:ns:entry") and carry their + // id in props; remote namespaces still encode the id in the service name + // ("kv:ns:ID[:remoteSuffix]"). if ( binding.name?.startsWith( `${CoreBindings.DURABLE_OBJECT_NAMESPACE_PROXY}:kv:` @@ -188,8 +191,18 @@ export function constructExplorerBindingMap( "kvNamespace" in binding && binding.kvNamespace?.name?.startsWith("kv:ns:") ) { - // Extract ID from service name "kv:ns:ID" - const namespaceId = binding.kvNamespace.name.replace(/^kv:ns:/, ""); + let namespaceId: string | undefined; + const propsJson = binding.kvNamespace.props?.json; + if (propsJson !== undefined) { + try { + namespaceId = JSON.parse(propsJson)[SharedBindings.TEXT_NAMESPACE]; + } catch { + // fall through to service-name parsing + } + } + if (namespaceId === undefined) { + namespaceId = binding.kvNamespace.name.replace(/^kv:ns:/, ""); + } IDToBindingName.kv[namespaceId] = binding.name; } diff --git a/packages/miniflare/src/plugins/core/index.ts b/packages/miniflare/src/plugins/core/index.ts index 49c8a7fa6c7..0c8d608b7e7 100644 --- a/packages/miniflare/src/plugins/core/index.ts +++ b/packages/miniflare/src/plugins/core/index.ts @@ -35,6 +35,7 @@ import { import { IMAGES_PLUGIN_NAME } from "../images"; import { getR2PublicService, R2_PUBLIC_SERVICE_NAME } from "../r2"; import { + buildRemoteProxyProps, getUserBindingServiceName, parseRoutes, ProxyNodeBinding, @@ -436,6 +437,8 @@ function getCustomServiceDesignator( } else if ("remoteProxyConnectionString" in service) { assert("name" in service && typeof service.name === "string"); serviceName = `${CORE_PLUGIN_NAME}:remote-proxy-service:${workerIndex}:${name}`; + // Per-binding remote config travels via props to a generic proxy worker. + props = buildRemoteProxyProps(service.remoteProxyConnectionString, name); } // Worker with entrypoint else if ("name" in service) { @@ -524,10 +527,7 @@ function maybeGetCustomServiceService( return { name: `${CORE_PLUGIN_NAME}:remote-proxy-service:${workerIndex}:${name}`, - worker: remoteProxyClientWorker( - service.remoteProxyConnectionString, - name - ), + worker: remoteProxyClientWorker(), }; } } diff --git a/packages/miniflare/src/plugins/d1/index.ts b/packages/miniflare/src/plugins/d1/index.ts index 3dee2a65c06..007f73d68eb 100644 --- a/packages/miniflare/src/plugins/d1/index.ts +++ b/packages/miniflare/src/plugins/d1/index.ts @@ -4,6 +4,7 @@ import SCRIPT_D1_DATABASE_OBJECT from "worker:d1/database"; import { z } from "zod"; import { SharedBindings } from "../../workers"; import { + buildRemoteProxyProps, getMiniflareObjectBindings, getPersistPath, getUserBindingServiceName, @@ -48,6 +49,8 @@ export const D1SharedOptionsSchema = z.object({ export const D1_PLUGIN_NAME = "d1"; const D1_STORAGE_SERVICE_NAME = `${D1_PLUGIN_NAME}:storage`; const D1_DATABASE_SERVICE_PREFIX = `${D1_PLUGIN_NAME}:db`; +// One shared remote-proxy service for all remote D1 databases (config via props). +const D1_REMOTE_SERVICE_NAME = `${D1_PLUGIN_NAME}:db:remote`; const D1_DATABASE_OBJECT_CLASS_NAME = "D1DatabaseObject"; const D1_DATABASE_OBJECT: Worker_Binding_DurableObjectNamespaceDesignator = { serviceName: D1_DATABASE_SERVICE_PREFIX, @@ -70,18 +73,21 @@ export const D1_PLUGIN: Plugin< "Alpha D1 Databases cannot run remotely" ); - const serviceName = getUserBindingServiceName( - D1_DATABASE_SERVICE_PREFIX, - id, - remoteProxyConnectionString - ); + // Remote databases share one proxy service (config via props); + // local databases keep their per-id entry service. + const serviceDesignator = remoteProxyConnectionString + ? { + name: D1_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps(remoteProxyConnectionString, name), + } + : { + name: getUserBindingServiceName(D1_DATABASE_SERVICE_PREFIX, id), + }; const binding = name.startsWith("__D1_BETA__") ? // Used before Wrangler 3.3 { - service: { - name: serviceName, - }, + service: serviceDesignator, } : // Used after Wrangler 3.3 { @@ -90,9 +96,7 @@ export const D1_PLUGIN: Plugin< innerBindings: [ { name: "fetcher", - service: { - name: serviceName, - }, + service: serviceDesignator, }, ], }, @@ -118,20 +122,28 @@ export const D1_PLUGIN: Plugin< }) { const persist = sharedOptions.d1Persist; const databases = namespaceEntries(options.d1Databases); - const services = databases.map( - ([name, { id, remoteProxyConnectionString }]) => ({ - name: getUserBindingServiceName( - D1_DATABASE_SERVICE_PREFIX, - id, - remoteProxyConnectionString - ), - worker: remoteProxyConnectionString - ? remoteProxyClientWorker(remoteProxyConnectionString, name) - : objectEntryWorker(D1_DATABASE_OBJECT, id), - }) - ); - if (databases.length > 0) { + const services: Service[] = []; + let hasRemote = false; + for (const [, { id, remoteProxyConnectionString }] of databases) { + if (remoteProxyConnectionString) { + hasRemote = true; + } else { + services.push({ + name: getUserBindingServiceName(D1_DATABASE_SERVICE_PREFIX, id), + worker: objectEntryWorker(D1_DATABASE_OBJECT, id), + }); + } + } + if (hasRemote) { + services.push({ + name: D1_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), + }); + } + + const hasLocal = services.some((s) => s.name !== D1_REMOTE_SERVICE_NAME); + if (hasLocal) { const uniqueKey = `miniflare-${D1_DATABASE_OBJECT_CLASS_NAME}`; const persistPath = getPersistPath( D1_PLUGIN_NAME, @@ -180,8 +192,11 @@ export const D1_PLUGIN: Plugin< }; services.push(storageService, objectService); - for (const database of databases) { - await migrateDatabase(log, uniqueKey, persistPath, database[1].id); + for (const [, database] of databases) { + if (database.remoteProxyConnectionString) { + continue; + } + await migrateDatabase(log, uniqueKey, persistPath, database.id); } } diff --git a/packages/miniflare/src/plugins/dispatch-namespace/index.ts b/packages/miniflare/src/plugins/dispatch-namespace/index.ts index 3f387ab7b3f..11e4c776312 100644 --- a/packages/miniflare/src/plugins/dispatch-namespace/index.ts +++ b/packages/miniflare/src/plugins/dispatch-namespace/index.ts @@ -2,7 +2,7 @@ import SCRIPT_DISPATCH_NAMESPACE from "worker:dispatch-namespace/dispatch-namesp import SCRIPT_DISPATCH_NAMESPACE_PROXY from "worker:dispatch-namespace/dispatch-namespace-proxy"; import { z } from "zod"; import { - getUserBindingServiceName, + buildRemoteProxyProps, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; @@ -24,17 +24,8 @@ export const DispatchNamespaceOptionsSchema = z.object({ export const DISPATCH_NAMESPACE_PLUGIN_NAME = "dispatch-namespace"; -/** Service name for the proxy client worker backing a dispatch namespace. */ -function getProxyServiceName( - name: string, - remoteProxyConnectionString?: RemoteProxyConnectionString -): string { - return getUserBindingServiceName( - `${DISPATCH_NAMESPACE_PLUGIN_NAME}-proxy`, - name, - remoteProxyConnectionString - ); -} +// One shared proxy client service for all dispatch namespaces (config via props). +const DISPATCH_NAMESPACE_REMOTE_SERVICE_NAME = `${DISPATCH_NAMESPACE_PLUGIN_NAME}-proxy:remote`; export const DISPATCH_NAMESPACE_PLUGIN: Plugin< typeof DispatchNamespaceOptionsSchema @@ -57,9 +48,10 @@ export const DISPATCH_NAMESPACE_PLUGIN: Plugin< { name: "proxyClient", service: { - name: getProxyServiceName( - name, - config.remoteProxyConnectionString + name: DISPATCH_NAMESPACE_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + config.remoteProxyConnectionString, + name ), }, }, @@ -81,18 +73,19 @@ export const DISPATCH_NAMESPACE_PLUGIN: Plugin< ); }, async getServices({ options }) { - if (!options.dispatchNamespaces) { + if ( + !options.dispatchNamespaces || + Object.keys(options.dispatchNamespaces).length === 0 + ) { return []; } - return Object.entries(options.dispatchNamespaces).map(([name, config]) => ({ - name: getProxyServiceName(name, config.remoteProxyConnectionString), - worker: remoteProxyClientWorker( - config.remoteProxyConnectionString, - name, - SCRIPT_DISPATCH_NAMESPACE_PROXY - ), - })); + return [ + { + name: DISPATCH_NAMESPACE_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(SCRIPT_DISPATCH_NAMESPACE_PROXY), + }, + ]; }, getExtensions({ options }) { if (!options.some((o) => o.dispatchNamespaces)) { diff --git a/packages/miniflare/src/plugins/email/index.ts b/packages/miniflare/src/plugins/email/index.ts index 908d19c44e5..d46d5c8024f 100644 --- a/packages/miniflare/src/plugins/email/index.ts +++ b/packages/miniflare/src/plugins/email/index.ts @@ -4,6 +4,7 @@ import EMAIL_MESSAGE from "worker:email/email"; import SEND_EMAIL_BINDING from "worker:email/send_email"; import { z } from "zod"; import { + buildRemoteProxyProps, getUserBindingServiceName, remoteProxyClientWorker, ProxyNodeBinding, @@ -43,6 +44,7 @@ export const EmailOptionsSchema = z.object({ export const EMAIL_PLUGIN_NAME = "email"; const SERVICE_SEND_EMAIL_WORKER_PREFIX = `SEND-EMAIL-WORKER`; +const EMAIL_REMOTE_SERVICE_NAME = `${EMAIL_PLUGIN_NAME}:remote`; // Disk service name and binding name for writing temporary files to system temp directory const EMAIL_DISK_SERVICE_NAME = `${EMAIL_PLUGIN_NAME}:disk`; const EMAIL_DISK_BINDING_NAME = "MINIFLARE_EMAIL_DISK"; @@ -112,12 +114,18 @@ export const EMAIL_PLUGIN: Plugin = { return sendEmailBindings.map(({ name, remoteProxyConnectionString }) => ({ name, - service: { - entrypoint: remoteProxyConnectionString - ? undefined - : "SendEmailBinding", - name: getUserBindingServiceName(SERVICE_SEND_EMAIL_WORKER_PREFIX, name), - }, + service: remoteProxyConnectionString + ? { + name: EMAIL_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps(remoteProxyConnectionString, name), + } + : { + entrypoint: "SendEmailBinding", + name: getUserBindingServiceName( + SERVICE_SEND_EMAIL_WORKER_PREFIX, + name + ), + }, })); }, getNodeBindings(options) { @@ -179,32 +187,42 @@ export const EMAIL_PLUGIN: Plugin = { }, })); + let hasRemote = false; for (const { name, remoteProxyConnectionString, ...config } of args.options .email?.send_email ?? []) { + if (remoteProxyConnectionString) { + hasRemote = true; + continue; + } services.push({ name: getUserBindingServiceName(SERVICE_SEND_EMAIL_WORKER_PREFIX, name), - worker: remoteProxyConnectionString - ? remoteProxyClientWorker(remoteProxyConnectionString, name) - : { - compatibilityDate: "2025-03-17", - modules: [ - { - name: "send_email.mjs", - esModule: SEND_EMAIL_BINDING(), - }, - ], - bindings: [ - ...buildJsonBindings(config), - ...diskServices.map(({ bindingName, serviceName }) => ({ - name: bindingName, - service: { name: serviceName }, - })), - { - name: "email_disk_services", - json: JSON.stringify(diskServices), - }, - ], + worker: { + compatibilityDate: "2025-03-17", + modules: [ + { + name: "send_email.mjs", + esModule: SEND_EMAIL_BINDING(), + }, + ], + bindings: [ + ...buildJsonBindings(config), + ...diskServices.map(({ bindingName, serviceName }) => ({ + name: bindingName, + service: { name: serviceName }, + })), + { + name: "email_disk_services", + json: JSON.stringify(diskServices), }, + ], + }, + }); + } + + if (hasRemote) { + services.push({ + name: EMAIL_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), }); } diff --git a/packages/miniflare/src/plugins/flagship/index.ts b/packages/miniflare/src/plugins/flagship/index.ts index 136eb45f318..8c30d56aa3c 100644 --- a/packages/miniflare/src/plugins/flagship/index.ts +++ b/packages/miniflare/src/plugins/flagship/index.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { - getUserBindingServiceName, + buildRemoteProxyProps, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; @@ -19,6 +19,7 @@ export const FlagshipOptionsSchema = z.object({ }); export const FLAGSHIP_PLUGIN_NAME = "flagship"; +const FLAGSHIP_REMOTE_SERVICE_NAME = `${FLAGSHIP_PLUGIN_NAME}:remote`; export const FLAGSHIP_PLUGIN: Plugin = { options: FlagshipOptionsSchema, @@ -32,10 +33,10 @@ export const FLAGSHIP_PLUGIN: Plugin = { ([name, config]) => ({ name, service: { - name: getUserBindingServiceName( - FLAGSHIP_PLUGIN_NAME, - name, - config.remoteProxyConnectionString + name: FLAGSHIP_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + config.remoteProxyConnectionString, + name ), }, }) @@ -53,21 +54,15 @@ export const FLAGSHIP_PLUGIN: Plugin = { ); }, async getServices({ options }) { - if (!options.flagship) { + if (!options.flagship || Object.keys(options.flagship).length === 0) { return []; } - return Object.entries(options.flagship).map( - ([name, { remoteProxyConnectionString }]) => { - return { - name: getUserBindingServiceName( - FLAGSHIP_PLUGIN_NAME, - name, - remoteProxyConnectionString - ), - worker: remoteProxyClientWorker(remoteProxyConnectionString, name), - }; - } - ); + return [ + { + name: FLAGSHIP_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), + }, + ]; }, }; diff --git a/packages/miniflare/src/plugins/images/index.ts b/packages/miniflare/src/plugins/images/index.ts index c729aa38e73..7df446df282 100644 --- a/packages/miniflare/src/plugins/images/index.ts +++ b/packages/miniflare/src/plugins/images/index.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { SharedBindings } from "../../workers"; import { KV_NAMESPACE_OBJECT_CLASS_NAME } from "../kv"; import { + buildRemoteProxyProps, getMiniflareObjectBindings, getPersistPath, getUserBindingServiceName, @@ -34,6 +35,7 @@ export const ImagesSharedOptionsSchema = z.object({ }); export const IMAGES_PLUGIN_NAME = "images"; +const IMAGES_REMOTE_SERVICE_NAME = `${IMAGES_PLUGIN_NAME}:remote`; export const IMAGES_PLUGIN: Plugin< typeof ImagesOptionsSchema, @@ -55,13 +57,20 @@ export const IMAGES_PLUGIN: Plugin< innerBindings: [ { name: "fetcher", - service: { - name: getUserBindingServiceName( - IMAGES_PLUGIN_NAME, - options.images.binding, - options.images.remoteProxyConnectionString - ), - }, + service: options.images.remoteProxyConnectionString + ? { + name: IMAGES_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + options.images.remoteProxyConnectionString, + options.images.binding + ), + } + : { + name: getUserBindingServiceName( + IMAGES_PLUGIN_NAME, + options.images.binding + ), + }, }, ], }, @@ -87,24 +96,20 @@ export const IMAGES_PLUGIN: Plugin< return []; } - const serviceName = getUserBindingServiceName( - IMAGES_PLUGIN_NAME, - options.images.binding, - options.images.remoteProxyConnectionString - ); - if (options.images.remoteProxyConnectionString) { return [ { - name: serviceName, - worker: remoteProxyClientWorker( - options.images.remoteProxyConnectionString, - options.images.binding - ), + name: IMAGES_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), }, ]; } + const serviceName = getUserBindingServiceName( + IMAGES_PLUGIN_NAME, + options.images.binding + ); + const persistPath = getPersistPath( IMAGES_PLUGIN_NAME, tmpPath, diff --git a/packages/miniflare/src/plugins/kv/index.ts b/packages/miniflare/src/plugins/kv/index.ts index ffcbd0d74b2..ff79ca4100c 100644 --- a/packages/miniflare/src/plugins/kv/index.ts +++ b/packages/miniflare/src/plugins/kv/index.ts @@ -4,9 +4,9 @@ import { z } from "zod"; import { PathSchema } from "../../shared"; import { SharedBindings } from "../../workers"; import { + buildRemoteProxyProps, getMiniflareObjectBindings, getPersistPath, - getUserBindingServiceName, migrateDatabase, namespaceEntries, namespaceKeys, @@ -58,6 +58,11 @@ export const KVSharedOptionsSchema = z.object({ }); const SERVICE_NAMESPACE_PREFIX = `${KV_PLUGIN_NAME}:ns`; +// A single entry service shared by every *local* namespace. Each namespace's id +// is supplied per-binding via `ctx.props`, so one service serves all of them. +const KV_LOCAL_ENTRY_SERVICE_NAME = `${KV_PLUGIN_NAME}:ns:entry`; +// One shared remote-proxy service for all remote namespaces (config via props). +const KV_REMOTE_SERVICE_NAME = `${KV_PLUGIN_NAME}:ns:remote`; const KV_STORAGE_SERVICE_NAME = `${KV_PLUGIN_NAME}:storage`; export const KV_NAMESPACE_OBJECT_CLASS_NAME = "KVNamespaceObject"; const KV_NAMESPACE_OBJECT: Worker_Binding_DurableObjectNamespaceDesignator = { @@ -80,16 +85,35 @@ export const KV_PLUGIN: Plugin< bindingTypeDescription: "KV namespace", async getBindings(options) { const namespaces = namespaceEntries(options.kvNamespaces); - const bindings = namespaces.map(([name, namespace]) => ({ - name, - kvNamespace: { - name: getUserBindingServiceName( - SERVICE_NAMESPACE_PREFIX, - namespace.id, - namespace.remoteProxyConnectionString - ), - }, - })); + const bindings = namespaces.map(([name, namespace]) => { + // Remote (mixed-mode) namespaces share one proxy service; per-binding + // config (connection string) travels via props. + if (namespace.remoteProxyConnectionString) { + return { + name, + kvNamespace: { + name: KV_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + namespace.remoteProxyConnectionString, + name + ), + }, + }; + } + // Local namespaces all share one entry service; the namespace id is + // passed at runtime via props (read in object-entry.worker.ts). + return { + name, + kvNamespace: { + name: KV_LOCAL_ENTRY_SERVICE_NAME, + props: { + json: JSON.stringify({ + [SharedBindings.TEXT_NAMESPACE]: namespace.id, + }), + }, + }, + }; + }); if (isWorkersSitesEnabled(options)) { bindings.push(...(await getSitesBindings(options))); @@ -121,20 +145,32 @@ export const KV_PLUGIN: Plugin< }) { const persist = sharedOptions.kvPersist; const namespaces = namespaceEntries(options.kvNamespaces); - const services = namespaces.map( - ([name, { id, remoteProxyConnectionString }]) => ({ - name: getUserBindingServiceName( - SERVICE_NAMESPACE_PREFIX, - id, - remoteProxyConnectionString - ), - worker: remoteProxyConnectionString - ? remoteProxyClientWorker(remoteProxyConnectionString, name) - : objectEntryWorker(KV_NAMESPACE_OBJECT, id), - }) + + const services: Service[] = []; + + // One shared entry service for all local namespaces (id supplied via props). + const hasLocalNamespace = namespaces.some( + ([, ns]) => !ns.remoteProxyConnectionString ); + if (hasLocalNamespace) { + services.push({ + name: KV_LOCAL_ENTRY_SERVICE_NAME, + worker: objectEntryWorker(KV_NAMESPACE_OBJECT), + }); + } + + // One shared proxy service for all remote (mixed-mode) namespaces. + const hasRemoteNamespace = namespaces.some( + ([, ns]) => ns.remoteProxyConnectionString + ); + if (hasRemoteNamespace) { + services.push({ + name: KV_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), + }); + } - if (services.length > 0) { + if (hasLocalNamespace) { const uniqueKey = `miniflare-${KV_NAMESPACE_OBJECT_CLASS_NAME}`; const persistPath = getPersistPath( KV_PLUGIN_NAME, @@ -184,8 +220,11 @@ export const KV_PLUGIN: Plugin< // another breaking change to the persistence location, migrate SQLite // databases from the old location to the new location. Blobs are still // stored in the same location. - for (const namespace of namespaces) { - await migrateDatabase(log, uniqueKey, persistPath, namespace[1].id); + for (const [, namespace] of namespaces) { + if (namespace.remoteProxyConnectionString) { + continue; + } + await migrateDatabase(log, uniqueKey, persistPath, namespace.id); } } diff --git a/packages/miniflare/src/plugins/media/index.ts b/packages/miniflare/src/plugins/media/index.ts index 7c0583290ac..5ac1fda46a2 100644 --- a/packages/miniflare/src/plugins/media/index.ts +++ b/packages/miniflare/src/plugins/media/index.ts @@ -1,12 +1,13 @@ import { z } from "zod"; import { - getUserBindingServiceName, + buildRemoteProxyProps, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; import type { Plugin, RemoteProxyConnectionString } from "../shared"; export const MEDIA_PLUGIN_NAME = "media"; +const MEDIA_REMOTE_SERVICE_NAME = `${MEDIA_PLUGIN_NAME}:remote`; const MediaSchema = z.object({ binding: z.string(), @@ -31,10 +32,10 @@ export const MEDIA_PLUGIN: Plugin = { { name: options.media.binding, service: { - name: getUserBindingServiceName( - MEDIA_PLUGIN_NAME, - options.media.binding, - options.media.remoteProxyConnectionString + name: MEDIA_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + options.media.remoteProxyConnectionString, + options.media.binding ), }, }, @@ -55,15 +56,8 @@ export const MEDIA_PLUGIN: Plugin = { return [ { - name: getUserBindingServiceName( - MEDIA_PLUGIN_NAME, - options.media.binding, - options.media.remoteProxyConnectionString - ), - worker: remoteProxyClientWorker( - options.media.remoteProxyConnectionString, - options.media.binding - ), + name: MEDIA_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), }, ]; }, diff --git a/packages/miniflare/src/plugins/mtls/index.ts b/packages/miniflare/src/plugins/mtls/index.ts index dafd8180948..5977b4dde79 100644 --- a/packages/miniflare/src/plugins/mtls/index.ts +++ b/packages/miniflare/src/plugins/mtls/index.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { - getUserBindingServiceName, + buildRemoteProxyProps, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; @@ -18,6 +18,7 @@ export const MtlsOptionsSchema = z.object({ }); export const MTLS_PLUGIN_NAME = "mtls"; +const MTLS_REMOTE_SERVICE_NAME = `${MTLS_PLUGIN_NAME}:remote`; export const MTLS_PLUGIN: Plugin = { options: MtlsOptionsSchema, @@ -28,16 +29,13 @@ export const MTLS_PLUGIN: Plugin = { } return Object.entries(options.mtlsCertificates).map( - ([name, { certificate_id, remoteProxyConnectionString }]) => { + ([name, { remoteProxyConnectionString }]) => { return { name, service: { - name: getUserBindingServiceName( - MTLS_PLUGIN_NAME, - certificate_id, - remoteProxyConnectionString - ), + name: MTLS_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps(remoteProxyConnectionString, name), }, }; } @@ -55,21 +53,18 @@ export const MTLS_PLUGIN: Plugin = { ); }, async getServices({ options }) { - if (!options.mtlsCertificates) { + if ( + !options.mtlsCertificates || + Object.keys(options.mtlsCertificates).length === 0 + ) { return []; } - return Object.entries(options.mtlsCertificates).map( - ([name, { certificate_id, remoteProxyConnectionString }]) => { - return { - name: getUserBindingServiceName( - MTLS_PLUGIN_NAME, - certificate_id, - remoteProxyConnectionString - ), - worker: remoteProxyClientWorker(remoteProxyConnectionString, name), - }; - } - ); + return [ + { + name: MTLS_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), + }, + ]; }, }; diff --git a/packages/miniflare/src/plugins/pipelines/index.ts b/packages/miniflare/src/plugins/pipelines/index.ts index a3c0d649c55..c65fc7f5e3d 100644 --- a/packages/miniflare/src/plugins/pipelines/index.ts +++ b/packages/miniflare/src/plugins/pipelines/index.ts @@ -1,6 +1,7 @@ import SCRIPT_PIPELINE_OBJECT from "worker:pipelines/pipeline"; import { z } from "zod"; import { + buildRemoteProxyProps, namespaceKeys, ProxyNodeBinding, remoteProxyClientWorker, @@ -36,16 +37,24 @@ export const PipelineOptionsSchema = z.object({ export const PIPELINES_PLUGIN_NAME = "pipelines"; const SERVICE_PIPELINE_PREFIX = `${PIPELINES_PLUGIN_NAME}:pipeline`; +const PIPELINES_REMOTE_SERVICE_NAME = `${PIPELINES_PLUGIN_NAME}:pipeline:remote`; export const PIPELINE_PLUGIN: Plugin = { options: PipelineOptionsSchema, bindingTypeDescription: "Pipeline", getBindings(options) { const pipelines = bindingEntries(options.pipelines); - return pipelines.map(([name, { id }]) => ({ - name, - service: { name: `${SERVICE_PIPELINE_PREFIX}:${id}` }, - })); + return pipelines.map( + ([name, { id, remoteProxyConnectionString }]) => ({ + name, + service: remoteProxyConnectionString + ? { + name: PIPELINES_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps(remoteProxyConnectionString, name), + } + : { name: `${SERVICE_PIPELINE_PREFIX}:${id}` }, + }) + ); }, getNodeBindings(options) { const buckets = namespaceKeys(options.pipelines); @@ -56,24 +65,31 @@ export const PIPELINE_PLUGIN: Plugin = { async getServices({ options }) { const pipelines = bindingEntries(options.pipelines); - const services = []; - for (const [bindingName, pipeline] of pipelines) { + const services: Service[] = []; + let hasRemote = false; + for (const [, pipeline] of pipelines) { + if (pipeline.remoteProxyConnectionString) { + hasRemote = true; + continue; + } services.push({ name: `${SERVICE_PIPELINE_PREFIX}:${pipeline.id}`, - worker: pipeline.remoteProxyConnectionString - ? remoteProxyClientWorker( - pipeline.remoteProxyConnectionString, - bindingName - ) - : { - compatibilityDate: "2024-12-30", - modules: [ - { - name: "pipeline.worker.js", - esModule: SCRIPT_PIPELINE_OBJECT(), - }, - ], + worker: { + compatibilityDate: "2024-12-30", + modules: [ + { + name: "pipeline.worker.js", + esModule: SCRIPT_PIPELINE_OBJECT(), }, + ], + }, + }); + } + + if (hasRemote) { + services.push({ + name: PIPELINES_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), }); } diff --git a/packages/miniflare/src/plugins/r2/index.ts b/packages/miniflare/src/plugins/r2/index.ts index f089c2917fd..59543f5d409 100644 --- a/packages/miniflare/src/plugins/r2/index.ts +++ b/packages/miniflare/src/plugins/r2/index.ts @@ -4,6 +4,7 @@ import SCRIPT_R2_PUBLIC from "worker:r2/public"; import { z } from "zod"; import { SharedBindings } from "../../workers"; import { + buildRemoteProxyProps, getMiniflareObjectBindings, getPersistPath, getUserBindingServiceName, @@ -48,6 +49,8 @@ export const R2SharedOptionsSchema = z.object({ export const R2_PLUGIN_NAME = "r2"; const R2_STORAGE_SERVICE_NAME = `${R2_PLUGIN_NAME}:storage`; const R2_BUCKET_SERVICE_PREFIX = `${R2_PLUGIN_NAME}:bucket`; +// One shared remote-proxy service for all remote R2 buckets (config via props). +const R2_REMOTE_SERVICE_NAME = `${R2_PLUGIN_NAME}:bucket:remote`; export const R2_PUBLIC_SERVICE_NAME = `${R2_PLUGIN_NAME}:public`; const R2_BUCKET_OBJECT_CLASS_NAME = "R2BucketObject"; const R2_BUCKET_OBJECT: Worker_Binding_DurableObjectNamespaceDesignator = { @@ -97,13 +100,20 @@ export const R2_PLUGIN: Plugin< const buckets = namespaceEntries(options.r2Buckets); return buckets.map(([name, bucket]) => ({ name, - r2Bucket: { - name: getUserBindingServiceName( - R2_BUCKET_SERVICE_PREFIX, - bucket.id, - bucket.remoteProxyConnectionString - ), - }, + r2Bucket: bucket.remoteProxyConnectionString + ? { + name: R2_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + bucket.remoteProxyConnectionString, + name + ), + } + : { + name: getUserBindingServiceName( + R2_BUCKET_SERVICE_PREFIX, + bucket.id + ), + }, })); }, getNodeBindings(options) { @@ -122,20 +132,28 @@ export const R2_PLUGIN: Plugin< }) { const persist = sharedOptions.r2Persist; const buckets = namespaceEntries(options.r2Buckets); - const services = buckets.map( - ([name, { id, remoteProxyConnectionString }]) => ({ - name: getUserBindingServiceName( - R2_BUCKET_SERVICE_PREFIX, - id, - remoteProxyConnectionString - ), - worker: remoteProxyConnectionString - ? remoteProxyClientWorker(remoteProxyConnectionString, name) - : objectEntryWorker(R2_BUCKET_OBJECT, id), - }) - ); - if (buckets.length > 0) { + const services: Service[] = []; + let hasRemote = false; + for (const [, { id, remoteProxyConnectionString }] of buckets) { + if (remoteProxyConnectionString) { + hasRemote = true; + } else { + services.push({ + name: getUserBindingServiceName(R2_BUCKET_SERVICE_PREFIX, id), + worker: objectEntryWorker(R2_BUCKET_OBJECT, id), + }); + } + } + if (hasRemote) { + services.push({ + name: R2_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), + }); + } + + const hasLocal = services.some((s) => s.name !== R2_REMOTE_SERVICE_NAME); + if (hasLocal) { const uniqueKey = `miniflare-${R2_BUCKET_OBJECT_CLASS_NAME}`; const persistPath = getPersistPath( R2_PLUGIN_NAME, @@ -183,8 +201,11 @@ export const R2_PLUGIN: Plugin< }; services.push(storageService, objectService); - for (const bucket of buckets) { - await migrateDatabase(log, uniqueKey, persistPath, bucket[1].id); + for (const [, bucket] of buckets) { + if (bucket.remoteProxyConnectionString) { + continue; + } + await migrateDatabase(log, uniqueKey, persistPath, bucket.id); } } diff --git a/packages/miniflare/src/plugins/shared/constants.ts b/packages/miniflare/src/plugins/shared/constants.ts index b26fcc06a4e..02c5c2ef601 100644 --- a/packages/miniflare/src/plugins/shared/constants.ts +++ b/packages/miniflare/src/plugins/shared/constants.ts @@ -61,7 +61,11 @@ export function _enableControlEndpoints() { export function objectEntryWorker( durableObjectNamespace: Worker_Binding_DurableObjectNamespaceDesignator, - namespace: string + // When provided, the namespace is baked into the worker as a static binding + // (the original per-resource model). When omitted, the namespace is supplied + // per-request via `ctx.props` (the props-based model that lets a single entry + // service serve any number of namespaces). + namespace?: string ): Worker { return { compatibilityDate: "2023-07-24", @@ -69,7 +73,9 @@ export function objectEntryWorker( { name: "object-entry.worker.js", esModule: SCRIPT_OBJECT_ENTRY() }, ], bindings: [ - { name: SharedBindings.TEXT_NAMESPACE, text: namespace }, + ...(namespace !== undefined + ? [{ name: SharedBindings.TEXT_NAMESPACE, text: namespace }] + : []), { name: SharedBindings.DURABLE_OBJECT_NAMESPACE_OBJECT, durableObjectNamespace, @@ -78,12 +84,13 @@ export function objectEntryWorker( }; } -export function remoteProxyClientWorker( - remoteProxyConnectionString: RemoteProxyConnectionString | undefined, - binding: string, - script?: () => string -) { - const cfTraceId = process.env.CF_TRACE_ID; +// A single remote-proxy client service can serve any number of remote bindings: +// the per-binding data (connection string, binding name, trace id) is supplied +// at runtime via `ctx.props` (see `buildRemoteProxyProps`), rather than baked +// into a per-binding service. The only static, non-props-able binding is the +// loopback service (used to surface diagnostics back to the Miniflare host, +// e.g. a Cloudflare Access block detected on the remote proxy response). +export function remoteProxyClientWorker(script?: () => string) { return { compatibilityDate: "2025-01-01", modules: [ @@ -92,32 +99,22 @@ export function remoteProxyClientWorker( esModule: (script ?? SCRIPT_REMOTE_PROXY_CLIENT)(), }, ], - bindings: [ - ...(remoteProxyConnectionString?.href - ? [ - { - name: "remoteProxyConnectionString", - text: remoteProxyConnectionString.href, - }, - ] - : []), - { - name: "binding", - text: binding, - }, - ...(cfTraceId - ? [ - { - name: "cfTraceId", - text: cfTraceId, - }, - ] - : []), - // Loopback binding so the proxy client can report diagnostics - // (e.g. a Cloudflare Access block on the remote proxy server) - // back to the Miniflare host for a single, actionable warning. - WORKER_BINDING_SERVICE_LOOPBACK, - ], + bindings: [WORKER_BINDING_SERVICE_LOOPBACK], + }; +} + +// Builds the `props` value for a binding that points at a shared remote-proxy +// client service. Read back in `remote-proxy-client.worker.ts` via `ctx.props`. +export function buildRemoteProxyProps( + remoteProxyConnectionString: RemoteProxyConnectionString | undefined, + binding: string +): { json: string } { + return { + json: JSON.stringify({ + remoteProxyConnectionString: remoteProxyConnectionString?.href, + binding, + cfTraceId: process.env.CF_TRACE_ID, + }), }; } diff --git a/packages/miniflare/src/plugins/stream/index.ts b/packages/miniflare/src/plugins/stream/index.ts index 9c1b446f646..fe75ffbc367 100644 --- a/packages/miniflare/src/plugins/stream/index.ts +++ b/packages/miniflare/src/plugins/stream/index.ts @@ -4,6 +4,7 @@ import OBJECT_SCRIPT from "worker:stream/object"; import { z } from "zod"; import { SharedBindings } from "../../workers"; import { + buildRemoteProxyProps, getMiniflareObjectBindings, getPersistPath, getUserBindingServiceName, @@ -31,6 +32,7 @@ export const StreamSharedOptionsSchema = z.object({ }); export const STREAM_PLUGIN_NAME = "stream"; +const STREAM_REMOTE_SERVICE_NAME = `${STREAM_PLUGIN_NAME}:remote`; const STREAM_STORAGE_SERVICE_NAME = `${STREAM_PLUGIN_NAME}:storage`; const STREAM_OBJECT_SERVICE_NAME = `${STREAM_PLUGIN_NAME}:object`; export const STREAM_OBJECT_CLASS_NAME = "StreamObject"; @@ -52,16 +54,18 @@ export const STREAM_PLUGIN: Plugin< return [ { name: options.stream.binding, - service: { - name: getUserBindingServiceName( - STREAM_PLUGIN_NAME, - "service", - options.stream.remoteProxyConnectionString - ), - entrypoint: options.stream.remoteProxyConnectionString - ? undefined - : "StreamBinding", - }, + service: options.stream.remoteProxyConnectionString + ? { + name: STREAM_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + options.stream.remoteProxyConnectionString, + options.stream.binding + ), + } + : { + name: getUserBindingServiceName(STREAM_PLUGIN_NAME, "service"), + entrypoint: "StreamBinding", + }, }, ]; }, @@ -85,19 +89,10 @@ export const STREAM_PLUGIN: Plugin< } if (options.stream.remoteProxyConnectionString) { - const serviceName = getUserBindingServiceName( - STREAM_PLUGIN_NAME, - "service", - options.stream.remoteProxyConnectionString - ); - return [ { - name: serviceName, - worker: remoteProxyClientWorker( - options.stream.remoteProxyConnectionString, - options.stream.binding - ), + name: STREAM_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), }, ]; } diff --git a/packages/miniflare/src/plugins/vectorize/index.ts b/packages/miniflare/src/plugins/vectorize/index.ts index ce9a0be9719..b9e4f585281 100644 --- a/packages/miniflare/src/plugins/vectorize/index.ts +++ b/packages/miniflare/src/plugins/vectorize/index.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { - getUserBindingServiceName, + buildRemoteProxyProps, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; @@ -18,6 +18,7 @@ export const VectorizeOptionsSchema = z.object({ }); export const VECTORIZE_PLUGIN_NAME = "vectorize"; +const VECTORIZE_REMOTE_SERVICE_NAME = `${VECTORIZE_PLUGIN_NAME}:remote`; export const VECTORIZE_PLUGIN: Plugin = { options: VectorizeOptionsSchema, @@ -37,10 +38,10 @@ export const VECTORIZE_PLUGIN: Plugin = { { name: "fetcher", service: { - name: getUserBindingServiceName( - VECTORIZE_PLUGIN_NAME, - name, - remoteProxyConnectionString + name: VECTORIZE_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + remoteProxyConnectionString, + name ), }, }, @@ -74,21 +75,15 @@ export const VECTORIZE_PLUGIN: Plugin = { ); }, async getServices({ options }) { - if (!options.vectorize) { + if (!options.vectorize || Object.keys(options.vectorize).length === 0) { return []; } - return Object.entries(options.vectorize).map( - ([name, { remoteProxyConnectionString }]) => { - return { - name: getUserBindingServiceName( - VECTORIZE_PLUGIN_NAME, - name, - remoteProxyConnectionString - ), - worker: remoteProxyClientWorker(remoteProxyConnectionString, name), - }; - } - ); + return [ + { + name: VECTORIZE_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), + }, + ]; }, }; diff --git a/packages/miniflare/src/plugins/vpc-networks/index.ts b/packages/miniflare/src/plugins/vpc-networks/index.ts index 9314fb1609c..bc363f2bba3 100644 --- a/packages/miniflare/src/plugins/vpc-networks/index.ts +++ b/packages/miniflare/src/plugins/vpc-networks/index.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { - getUserBindingServiceName, + buildRemoteProxyProps, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; @@ -26,6 +26,7 @@ export const VpcNetworksOptionsSchema = z.object({ }); export const VPC_NETWORKS_PLUGIN_NAME = "vpc-networks"; +const VPC_NETWORKS_REMOTE_SERVICE_NAME = `${VPC_NETWORKS_PLUGIN_NAME}:remote`; export const VPC_NETWORKS_PLUGIN: Plugin = { options: VpcNetworksOptionsSchema, @@ -36,16 +37,14 @@ export const VPC_NETWORKS_PLUGIN: Plugin = { } return Object.entries(options.vpcNetworks).map(([name, binding]) => { - const identifier = - "tunnel_id" in binding ? binding.tunnel_id : binding.network_id; return { name, service: { - name: getUserBindingServiceName( - VPC_NETWORKS_PLUGIN_NAME, - identifier, - binding.remoteProxyConnectionString + name: VPC_NETWORKS_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + binding.remoteProxyConnectionString, + name ), }, }; @@ -63,24 +62,15 @@ export const VPC_NETWORKS_PLUGIN: Plugin = { ); }, async getServices({ options }) { - if (!options.vpcNetworks) { + if (!options.vpcNetworks || Object.keys(options.vpcNetworks).length === 0) { return []; } - return Object.entries(options.vpcNetworks).map(([name, binding]) => { - const identifier = - "tunnel_id" in binding ? binding.tunnel_id : binding.network_id; - return { - name: getUserBindingServiceName( - VPC_NETWORKS_PLUGIN_NAME, - identifier, - binding.remoteProxyConnectionString - ), - worker: remoteProxyClientWorker( - binding.remoteProxyConnectionString, - name - ), - }; - }); + return [ + { + name: VPC_NETWORKS_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), + }, + ]; }, }; diff --git a/packages/miniflare/src/plugins/vpc-services/index.ts b/packages/miniflare/src/plugins/vpc-services/index.ts index 0bdd14be540..597155d4f6e 100644 --- a/packages/miniflare/src/plugins/vpc-services/index.ts +++ b/packages/miniflare/src/plugins/vpc-services/index.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { - getUserBindingServiceName, + buildRemoteProxyProps, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; @@ -18,6 +18,7 @@ export const VpcServicesOptionsSchema = z.object({ }); export const VPC_SERVICES_PLUGIN_NAME = "vpc-services"; +const VPC_SERVICES_REMOTE_SERVICE_NAME = `${VPC_SERVICES_PLUGIN_NAME}:remote`; export const VPC_SERVICES_PLUGIN: Plugin = { options: VpcServicesOptionsSchema, @@ -28,16 +29,13 @@ export const VPC_SERVICES_PLUGIN: Plugin = { } return Object.entries(options.vpcServices).map( - ([name, { service_id, remoteProxyConnectionString }]) => { + ([name, { remoteProxyConnectionString }]) => { return { name, service: { - name: getUserBindingServiceName( - VPC_SERVICES_PLUGIN_NAME, - service_id, - remoteProxyConnectionString - ), + name: VPC_SERVICES_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps(remoteProxyConnectionString, name), }, }; } @@ -55,21 +53,15 @@ export const VPC_SERVICES_PLUGIN: Plugin = { ); }, async getServices({ options }) { - if (!options.vpcServices) { + if (!options.vpcServices || Object.keys(options.vpcServices).length === 0) { return []; } - return Object.entries(options.vpcServices).map( - ([name, { service_id, remoteProxyConnectionString }]) => { - return { - name: getUserBindingServiceName( - VPC_SERVICES_PLUGIN_NAME, - service_id, - remoteProxyConnectionString - ), - worker: remoteProxyClientWorker(remoteProxyConnectionString, name), - }; - } - ); + return [ + { + name: VPC_SERVICES_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), + }, + ]; }, }; diff --git a/packages/miniflare/src/plugins/websearch/index.ts b/packages/miniflare/src/plugins/websearch/index.ts index a8271680caa..70e29380cf9 100644 --- a/packages/miniflare/src/plugins/websearch/index.ts +++ b/packages/miniflare/src/plugins/websearch/index.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { - getUserBindingServiceName, + buildRemoteProxyProps, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; @@ -19,6 +19,7 @@ export const WebsearchOptionsSchema = z.object({ export const WEBSEARCH_PLUGIN_NAME = "websearch"; const WEBSEARCH_SCOPE = "websearch"; +const WEBSEARCH_REMOTE_SERVICE_NAME = `${WEBSEARCH_SCOPE}:remote`; export const WEBSEARCH_PLUGIN: Plugin = { options: WebsearchOptionsSchema, @@ -26,7 +27,7 @@ export const WEBSEARCH_PLUGIN: Plugin = { async getBindings(options) { const bindings: { name: string; - service: { name: string }; + service: { name: string; props?: { json: string } }; }[] = []; for (const [bindingName, entry] of Object.entries( @@ -35,10 +36,10 @@ export const WEBSEARCH_PLUGIN: Plugin = { bindings.push({ name: bindingName, service: { - name: getUserBindingServiceName( - WEBSEARCH_SCOPE, - bindingName, - entry.remoteProxyConnectionString + name: WEBSEARCH_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + entry.remoteProxyConnectionString, + bindingName ), }, }); @@ -61,19 +62,10 @@ export const WEBSEARCH_PLUGIN: Plugin = { worker: ReturnType; }[] = []; - for (const [bindingName, entry] of Object.entries( - options.websearch ?? {} - )) { + if (Object.keys(options.websearch ?? {}).length > 0) { services.push({ - name: getUserBindingServiceName( - WEBSEARCH_SCOPE, - bindingName, - entry.remoteProxyConnectionString - ), - worker: remoteProxyClientWorker( - entry.remoteProxyConnectionString, - bindingName - ), + name: WEBSEARCH_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), }); } diff --git a/packages/miniflare/src/workers/dispatch-namespace/dispatch-namespace-proxy.worker.ts b/packages/miniflare/src/workers/dispatch-namespace/dispatch-namespace-proxy.worker.ts index 80fdc81db80..fa5faeb2501 100644 --- a/packages/miniflare/src/workers/dispatch-namespace/dispatch-namespace-proxy.worker.ts +++ b/packages/miniflare/src/workers/dispatch-namespace/dispatch-namespace-proxy.worker.ts @@ -4,21 +4,27 @@ import { makeRemoteProxyStub, throwRemoteRequired, } from "../shared/remote-bindings-utils"; -import type { RemoteBindingEnv } from "../shared/remote-bindings-utils"; +import type { + RemoteBindingEnv, + RemoteBindingProps, +} from "../shared/remote-bindings-utils"; /** Proxy client for dispatch namespace bindings. */ -export default class DispatchNamespaceProxy extends WorkerEntrypoint { +export default class DispatchNamespaceProxy extends WorkerEntrypoint< + RemoteBindingEnv, + RemoteBindingProps +> { get( name: string, args?: { [key: string]: unknown }, options?: DynamicDispatchOptions ): Fetcher { - if (!this.env.remoteProxyConnectionString) { - throwRemoteRequired(this.env.binding); + if (!this.ctx.props.remoteProxyConnectionString) { + throwRemoteRequired(this.ctx.props.binding); } return makeRemoteProxyStub( - this.env.remoteProxyConnectionString, - this.env.binding, + this.ctx.props.remoteProxyConnectionString, + this.ctx.props.binding, { "MF-Dispatch-Namespace-Options": JSON.stringify({ name, @@ -26,7 +32,7 @@ export default class DispatchNamespaceProxy extends WorkerEntrypoint>{ - async fetch(request, env) { - const name = env[SharedBindings.TEXT_NAMESPACE]; +export default >{ + async fetch(request, env, ctx) { + // Prefer the namespace passed at runtime via `ctx.props` (props-based + // model: one entry service serves many namespaces). Fall back to the + // static binding for callers that still bake the namespace in. + const name = + ctx.props[SharedBindings.TEXT_NAMESPACE] ?? + env[SharedBindings.TEXT_NAMESPACE]; + if (name === undefined) { + throw new Error( + "object-entry worker: no namespace provided via props or binding" + ); + } const objectNamespace = env[SharedBindings.DURABLE_OBJECT_NAMESPACE_OBJECT]; const id = objectNamespace.idFromName(name); const stub = objectNamespace.get(id); diff --git a/packages/miniflare/src/workers/shared/remote-bindings-utils.ts b/packages/miniflare/src/workers/shared/remote-bindings-utils.ts index 7034bb3be37..fcf19a22d87 100644 --- a/packages/miniflare/src/workers/shared/remote-bindings-utils.ts +++ b/packages/miniflare/src/workers/shared/remote-bindings-utils.ts @@ -2,18 +2,27 @@ import { newWebSocketRpcSession } from "capnweb"; import type { SharedBindings } from "./constants"; /** - * Common environment type for remote binding workers. + * Common environment type for remote binding workers. The loopback service is + * the only binding still passed via env (services can't travel through props); + * the per-binding fields now arrive via `ctx.props` (see `RemoteBindingProps`). */ export type RemoteBindingEnv = { - remoteProxyConnectionString?: string; - binding: string; - cfTraceId?: string; // Optional loopback service used to surface diagnostics back to the // Miniflare host (e.g. a Cloudflare Access block detected on the response // from the remote-bindings proxy server). [SharedBindings.MAYBE_SERVICE_LOOPBACK]?: Fetcher; }; +/** + * Per-binding configuration supplied at runtime via `ctx.props`. This is what + * lets a single remote-proxy client service serve many bindings. + */ +export type RemoteBindingProps = { + remoteProxyConnectionString?: string; + binding: string; + cfTraceId?: string; +}; + /** Headers sent alongside proxy requests to provide additional context. */ export type ProxyMetadata = { "MF-Dispatch-Namespace-Options"?: string; diff --git a/packages/miniflare/src/workers/shared/remote-proxy-client.worker.ts b/packages/miniflare/src/workers/shared/remote-proxy-client.worker.ts index 2a7dbb67ff2..a4090913d8e 100644 --- a/packages/miniflare/src/workers/shared/remote-proxy-client.worker.ts +++ b/packages/miniflare/src/workers/shared/remote-proxy-client.worker.ts @@ -5,29 +5,38 @@ import { makeRemoteProxyStub, throwRemoteRequired, } from "./remote-bindings-utils"; -import type { RemoteBindingEnv } from "./remote-bindings-utils"; +import type { + RemoteBindingEnv, + RemoteBindingProps, +} from "./remote-bindings-utils"; /** Generic remote proxy client for bindings. */ -export default class Client extends WorkerEntrypoint { +export default class Client extends WorkerEntrypoint< + RemoteBindingEnv, + RemoteBindingProps +> { fetch(request: Request): Promise { return makeFetch( - this.env.remoteProxyConnectionString, - this.env.binding, + this.ctx.props.remoteProxyConnectionString, + this.ctx.props.binding, undefined, - this.env.cfTraceId, + this.ctx.props.cfTraceId, this.env[SharedBindings.MAYBE_SERVICE_LOOPBACK] )(request); } - constructor(ctx: ExecutionContext, env: RemoteBindingEnv) { + constructor( + ctx: ExecutionContext, + env: RemoteBindingEnv + ) { super(ctx, env); - const stub = env.remoteProxyConnectionString + const stub = ctx.props.remoteProxyConnectionString ? makeRemoteProxyStub( - env.remoteProxyConnectionString, - env.binding, + ctx.props.remoteProxyConnectionString, + ctx.props.binding, undefined, - env.cfTraceId, + ctx.props.cfTraceId, env[SharedBindings.MAYBE_SERVICE_LOOPBACK] ) : undefined; @@ -38,7 +47,7 @@ export default class Client extends WorkerEntrypoint { return Reflect.get(target, prop); } if (!stub) { - throwRemoteRequired(env.binding); + throwRemoteRequired(ctx.props.binding); } return Reflect.get(stub, prop); }, From a4d0fef228a5871812bbdcebb7501fdaf3deeccd Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 30 Jun 2026 14:45:05 +0100 Subject: [PATCH 33/37] [miniflare] Add shared storage owner routed over the remote-bindings boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an experimental option that makes a single detached process own local storage for a given persist root, so multiple Miniflare instances (e.g. several wrangler dev / vite dev sessions) no longer each open the same SQLite/blob files — the root cause of cross-process SQLITE_BUSY errors. The first instance to find no published owner elects itself (via a per-persist -root spawn lock) and launches a detached owner process. Every instance then routes its storage operations to the owner over the remote-bindings boundary — fetch for KV/R2/D1/Images and capnweb JSRPC for Streams/Secrets Store — and skips standing up its own local storage. Bindings are repointed by id (type:id) so the owner avoids binding-name collisions and resolves dynamic ids via the MF-Storage-Owner-Namespace header. The owner publishes its address, heartbeats it, and self-terminates once no instances remain. Cache, Durable Objects and Workflows stay per-instance via isolateLocalStorage. --- packages/miniflare/src/index.ts | 802 +++++++++++++++++- packages/miniflare/src/plugins/cache/index.ts | 10 +- .../miniflare/src/plugins/core/explorer.ts | 35 +- packages/miniflare/src/plugins/core/index.ts | 41 +- packages/miniflare/src/plugins/d1/index.ts | 44 +- packages/miniflare/src/plugins/do/index.ts | 6 +- .../miniflare/src/plugins/images/index.ts | 51 +- packages/miniflare/src/plugins/kv/index.ts | 22 +- packages/miniflare/src/plugins/r2/index.ts | 43 +- .../src/plugins/secret-store/index.ts | 10 + .../miniflare/src/plugins/shared/constants.ts | 11 + .../miniflare/src/plugins/shared/index.ts | 15 + .../miniflare/src/plugins/stream/index.ts | 12 + .../miniflare/src/plugins/workflows/index.ts | 12 +- packages/miniflare/src/shared/index.ts | 1 + .../miniflare/src/shared/storage-owner.ts | 290 +++++++ .../core/storage-owner-server.worker.ts | 111 +++ .../miniflare/src/workers/shared/constants.ts | 4 + .../src/workers/shared/object-entry.worker.ts | 12 +- 19 files changed, 1463 insertions(+), 69 deletions(-) create mode 100644 packages/miniflare/src/shared/storage-owner.ts create mode 100644 packages/miniflare/src/workers/core/storage-owner-server.worker.ts diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index 5fb228ab679..e9bc588de57 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -1,4 +1,5 @@ import assert from "node:assert"; +import { spawn } from "node:child_process"; import crypto from "node:crypto"; import fs from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; @@ -15,6 +16,7 @@ import { $ as colors$, bold, dim, green, yellow } from "kleur/colors"; import stoppable from "stoppable"; import { getGlobalDispatcher, Pool } from "undici"; import SCRIPT_DEV_REGISTRY_PROXY from "worker:core/dev-registry-proxy"; +import SCRIPT_STORAGE_OWNER_SERVER from "worker:core/storage-owner-server"; import SCRIPT_MINIFLARE_SHARED from "worker:shared/index"; import SCRIPT_MINIFLARE_ZOD from "worker:shared/zod"; import { WebSocketServer } from "ws"; @@ -43,26 +45,37 @@ import { getPersistPath, HELLO_WORLD_PLUGIN_NAME, HOST_CAPNP_CONNECT, + IMAGES_NS_DATA_SERVICE_NAME, IMAGES_PLUGIN_NAME, + KV_LOCAL_ENTRY_SERVICE_NAME, KV_PLUGIN_NAME, launchBrowser, loadExternalPlugins, + namespaceEntries, namespaceKeys, normaliseDurableObject, PLUGIN_ENTRIES, + buildRemoteProxyProps, + D1_LOCAL_ENTRY_SERVICE_NAME, ProxyClient, ProxyNodeBinding, QUEUES_PLUGIN_NAME, QueuesError, + R2_LOCAL_ENTRY_SERVICE_NAME, R2_PLUGIN_NAME, + getUserBindingServiceName, + remoteProxyClientWorker, SECRET_STORE_PLUGIN_NAME, + SECRET_STORE_SECRET_ENTRYPOINT, + STREAM_BINDING_ENTRYPOINT, + STREAM_BINDING_SERVICE_NAME, + STREAM_PLUGIN_NAME, SERVICE_DEV_REGISTRY_PROXY, SERVICE_ENTRY, SOCKET_DEBUG_PORT, SOCKET_DEV_REGISTRY, SOCKET_ENTRY, SOCKET_ENTRY_LOCAL, - STREAM_PLUGIN_NAME, WORKFLOWS_PLUGIN_NAME, } from "./plugins"; import { RPC_PROXY_SERVICE_NAME } from "./plugins/assets/constants"; @@ -92,11 +105,21 @@ import { } from "./runtime"; import { _isCyclic, + clearStorageOwner, + countLiveStorageClients, + heartbeatStorageClient, + heartbeatStorageOwner, isFileNotFoundError, MiniflareCoreError, NoOpLog, + OWNER_HEARTBEAT_MS, parseWithRootPath, + readStorageOwner, + registerStorageClient, stripAnsi, + tryAcquireOwnerSpawnLock, + unregisterStorageClient, + writeStorageOwner, } from "./shared"; import { createDurableObjectStorageHandle } from "./shared/dev-control"; import { DevRegistry, getWorkerRegistry } from "./shared/dev-registry"; @@ -112,6 +135,7 @@ import { CorePaths, LogLevel, Mutex, + SharedBindings, SharedHeaders, SiteBindings, } from "./workers"; @@ -127,6 +151,7 @@ import type { PluginWorkerOptions, QueueConsumers, QueueProducers, + RemoteProxyConnectionString, ReplaceWorkersTypes, SharedOptions, WorkerOptions, @@ -175,6 +200,104 @@ import type { Duplex, Transform, Writable } from "node:stream"; import type { Dispatcher, Response as UndiciResponse } from "undici"; const DEFAULT_HOST = "127.0.0.1"; +// Client-side service that proxies routed storage bindings to the owner over +// HTTP. This reuses the remote-bindings ("mixed-mode") client worker: each +// routed binding carries the owner's address + resource key via props. +const SERVICE_STORAGE_OWNER_PROXY = "storage-owner-proxy"; +// Owner-side service + socket exposing the owner's local storage entry services +// over HTTP (the remote-bindings proxy-server protocol). +const SERVICE_STORAGE_OWNER_SERVER = "storage-owner-server"; +const SOCKET_STORAGE_OWNER = "storage-owner"; + +// Detached storage-owner process bootstrap. The owner runs the same built +// miniflare module (its path handed over via env), so we avoid a second build +// entry point. Kept as a constant string with no interpolation so it satisfies +// the no-unsafe-command-execution lint rule. +const STORAGE_OWNER_BOOTSTRAP = + "require(process.env.MINIFLARE_STORAGE_OWNER_MAIN).runStorageOwnerProcess()"; +const ENV_STORAGE_OWNER_MAIN = "MINIFLARE_STORAGE_OWNER_MAIN"; +const ENV_STORAGE_OWNER_CONFIG = "MINIFLARE_STORAGE_OWNER_CONFIG"; +// How long a client waits for a freshly spawned owner to publish itself. +const STORAGE_OWNER_SPAWN_TIMEOUT_MS = 30_000; +const STORAGE_OWNER_POLL_MS = 50; +// Owner self-teardown tuning: a startup grace period before the owner is +// eligible to exit, and a debounce so a transient client gap (e.g. a reload) +// doesn't tear storage down. Overridable via env (read by the spawned owner +// process, which inherits the spawner's environment) primarily for tests. +const STORAGE_OWNER_STARTUP_GRACE_MS = + Number(process.env.MINIFLARE_STORAGE_OWNER_GRACE_MS) || 10_000; +const STORAGE_OWNER_IDLE_CHECK_MS = + Number(process.env.MINIFLARE_STORAGE_OWNER_IDLE_CHECK_MS) || 1_000; +const STORAGE_OWNER_IDLE_DEBOUNCE = 3; + +const PERSIST_ROOT_STARTUP_LOCK = ".miniflare-startup.lock"; +const PERSIST_ROOT_STARTUP_LOCK_STALE_MS = 30_000; +const PERSIST_ROOT_STARTUP_LOCK_RETRY_MS = 50; + +async function wait(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function withPersistRootStartupLock( + persistRoot: string | undefined, + signal: AbortSignal, + callback: () => Promise +): Promise { + if (persistRoot === undefined || signal.aborted) { + return callback(); + } + + await mkdir(persistRoot, { recursive: true }); + const lockPath = path.join(persistRoot, PERSIST_ROOT_STARTUP_LOCK); + let lock: fs.promises.FileHandle | undefined; + let heartbeat: NodeJS.Timeout | undefined; + + while (lock === undefined && !signal.aborted) { + try { + lock = await fs.promises.open(lockPath, "wx"); + await lock.writeFile(`${process.pid}\n${Date.now()}\n`); + heartbeat = setInterval(() => { + fs.promises.utimes(lockPath, new Date(), new Date()).catch(() => {}); + }, 1_000); + break; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "EEXIST") { + throw e; + } + + try { + const stats = await fs.promises.stat(lockPath); + if ( + stats.mtime.getTime() < + Date.now() - PERSIST_ROOT_STARTUP_LOCK_STALE_MS + ) { + await fs.promises.rm(lockPath, { force: true }); + continue; + } + } catch (statError) { + if ((statError as NodeJS.ErrnoException).code !== "ENOENT") { + throw statError; + } + } + + await wait(PERSIST_ROOT_STARTUP_LOCK_RETRY_MS); + } + } + if (lock === undefined) { + return callback(); + } + + try { + return await callback(); + } finally { + if (heartbeat !== undefined) { + clearInterval(heartbeat); + } + await lock?.close(); + await fs.promises.rm(lockPath, { force: true }); + } +} + function getURLSafeHost(host: string) { return net.isIPv6(host) ? `[${host}]` : host; } @@ -661,6 +784,212 @@ function getExternalServiceEntrypoints(allWorkerOpts: PluginWorkerOptions[]) { return externalServices; } +/** + * Extracts the resource id carried in an object-entry binding's `props.json` + * (written by `buildObjectEntryProps`), or `undefined` if the props don't carry + * one (e.g. remote/mixed-mode bindings). + */ +function extractObjectEntryId( + propsJson: string | undefined +): string | undefined { + if (propsJson === undefined) { + return undefined; + } + try { + const parsed = JSON.parse(propsJson) as Record; + const id = parsed[SharedBindings.TEXT_NAMESPACE]; + return typeof id === "string" ? id : undefined; + } catch { + return undefined; + } +} + +// Resource keys advertised to the owner via `MF-Binding` (see +// `storage-owner-server.worker.ts`). Type-prefixed so KV/R2/D1 ids can never +// collide in the owner's binding namespace. +function storageOwnerResourceKey(type: "kv" | "r2" | "d1", id: string): string { + return `${type}:${id}`; +} + +/** + * Builds a binding designator routing a storage op through the client-side + * remote-proxy worker to the owner. The owner address + resource key travel via + * props (read by `remote-proxy-client.worker.ts`). + */ +function storageOwnerProxyDesignator( + conn: RemoteProxyConnectionString, + resourceKey: string +) { + return { + name: SERVICE_STORAGE_OWNER_PROXY, + props: buildRemoteProxyProps(conn, resourceKey), + }; +} + +/** + * If `binding` is a *local* storage binding pointing at a shared object-entry + * service with a resource id in props, rewrite it to route through the + * client-side storage-owner proxy (so the owner process performs the storage + * I/O). Remote (mixed-mode) bindings — which carry no object-entry id — are + * returned unchanged. + */ +function rewriteStorageOwnerBinding( + binding: Worker_Binding, + conn: RemoteProxyConnectionString, + pluginKey: string +): Worker_Binding { + // Streams: a single per-instance store accessed over RPC. Repoint the whole + // binding at the owner proxy (the remote-proxy client carries the RPC); the + // owner hosts the stream entrypoint + store. + if ( + pluginKey === STREAM_PLUGIN_NAME && + "service" in binding && + binding.service?.name !== undefined + ) { + return { + name: binding.name, + service: storageOwnerProxyDesignator(conn, "stream"), + }; + } + // Secrets Store: per-secret RPC service. Repoint at the owner proxy, keyed by + // "secrets::" (extracted from the local service name). + if ( + pluginKey === SECRET_STORE_PLUGIN_NAME && + "service" in binding && + binding.service?.name !== undefined + ) { + const resource = binding.service.name.slice( + `${SECRET_STORE_PLUGIN_NAME}:`.length + ); + return { + name: binding.name, + service: storageOwnerProxyDesignator(conn, `secrets:${resource}`), + }; + } + // KV namespace bindings. + if ("kvNamespace" in binding && binding.kvNamespace?.name !== undefined) { + const id = extractObjectEntryId(binding.kvNamespace.props?.json); + if (id !== undefined) { + return { + name: binding.name, + kvNamespace: storageOwnerProxyDesignator( + conn, + storageOwnerResourceKey("kv", id) + ), + }; + } + } + // R2 bucket bindings. + if ("r2Bucket" in binding && binding.r2Bucket?.name !== undefined) { + const id = extractObjectEntryId(binding.r2Bucket.props?.json); + if (id !== undefined) { + return { + name: binding.name, + r2Bucket: storageOwnerProxyDesignator( + conn, + storageOwnerResourceKey("r2", id) + ), + }; + } + } + // D1 (pre-Wrangler-3.3 `__D1_BETA__`) service binding. + if ("service" in binding && binding.service?.name !== undefined) { + const id = extractObjectEntryId(binding.service.props?.json); + if (id !== undefined) { + return { + name: binding.name, + service: storageOwnerProxyDesignator( + conn, + storageOwnerResourceKey("d1", id) + ), + }; + } + } + // D1 (post-3.3) wrapped binding: rewrite the inner fetcher service designator. + if ("wrapped" in binding && binding.wrapped?.innerBindings !== undefined) { + let rewrote = false; + const innerBindings = binding.wrapped.innerBindings.map((inner) => { + if ("service" in inner && inner.service?.name !== undefined) { + const id = extractObjectEntryId(inner.service.props?.json); + if (id !== undefined) { + rewrote = true; + return { + ...inner, + service: storageOwnerProxyDesignator( + conn, + storageOwnerResourceKey("d1", id) + ), + }; + } + } + return inner; + }); + if (rewrote) { + return { + ...binding, + wrapped: { ...binding.wrapped, innerBindings }, + }; + } + } + return binding; +} + +/** + * Collects the union of *local* (non-remote) KV/R2/D1 resource ids declared + * across the given workers. Used both to tell a spawned owner which storage to + * stand up and to bind those resources on the owner's HTTP storage server. The + * entry services route by `idFromName`, so the owner additionally serves ids + * declared only by other clients. + */ +function collectLocalStorageIds(workerOpts: PluginWorkerOptions[]): { + kv: Set; + r2: Set; + d1: Set; + stream: boolean; + images: boolean; + secrets: Map; +} { + const kv = new Set(); + const r2 = new Set(); + const d1 = new Set(); + let stream = false; + let images = false; + const secrets = new Map(); + for (const opts of workerOpts) { + for (const [, ns] of namespaceEntries(opts.kv.kvNamespaces)) { + if (!ns.remoteProxyConnectionString) { + kv.add(ns.id); + } + } + for (const [, bucket] of namespaceEntries(opts.r2.r2Buckets)) { + if (!bucket.remoteProxyConnectionString) { + r2.add(bucket.id); + } + } + for (const [, db] of namespaceEntries(opts.d1.d1Databases)) { + if (!db.remoteProxyConnectionString) { + d1.add(db.id); + } + } + if (opts.stream.stream && !opts.stream.stream.remoteProxyConnectionString) { + stream = true; + } + if (opts.images.images && !opts.images.images.remoteProxyConnectionString) { + images = true; + } + const secretsStoreSecrets = + opts[SECRET_STORE_PLUGIN_NAME]?.secretsStoreSecrets; + if (secretsStoreSecrets) { + for (const { store_id, secret_name } of Object.values( + secretsStoreSecrets + )) { + secrets.set(`${store_id}:${secret_name}`, { store_id, secret_name }); + } + } + } + return { kv, r2, d1, stream, images, secrets }; +} + function invalidWrappedAsBound(name: string, bindingType: string): never { const stringName = JSON.stringify(name); throw new MiniflareCoreError( @@ -1026,6 +1355,13 @@ export class Miniflare { readonly #webSocketExtraHeaders: WeakMap; readonly #devRegistry: DevRegistry; + // Shared-storage-owner state (experimental `unsafeSharedStorageOwner`). + // `#storageOwnerHeartbeat` keeps the owner definition / client presence file + // fresh; `#storageClientPath` is this instance's client-presence file (client + // role only) so it can be removed on dispose. + #storageOwnerHeartbeat?: NodeJS.Timeout; + #storageClientPath?: string; + #maybeInspectorProxyController?: InspectorProxyController; #previousRuntimeInspectorPort?: number; @@ -1182,6 +1518,8 @@ export class Miniflare { // The .catch() will never run since the event loop won't tick again, // but the synchronous portion still executes. this.#devRegistry.dispose(); + // Best-effort sync removal of our shared-storage presence files. + this.#disposeStorageOwnerPresence(); }); this.#disposeController = new AbortController(); @@ -1998,6 +2336,50 @@ export class Miniflare { ? getExternalServiceEntrypoints(allWorkerOpts) : null; + // As a client, ensure an owner exists (spawning a detached one if needed) + // before we resolve routing below. + await this.#ensureStorageOwner(); + + // When acting as a shared-storage *client*, resolve the owner so local + // storage bindings can be routed to it (and local storage services + // skipped). `undefined` => behave normally (owner role, feature off, or + // no owner currently published). + const storageOwnerRouting = this.#getStorageOwnerRouting(); + // Only route a storage type to the owner when this instance hasn't pinned + // it to an explicit persist path. An explicit `*Persist` overrides + // `defaultPersistRoot` (the owner's basis), so such storage must stay + // local rather than being shared through the owner. + const storageOwnerRoutePlugins = new Set(); + if (storageOwnerRouting !== undefined) { + if (sharedOpts.kv.kvPersist === undefined) { + storageOwnerRoutePlugins.add(KV_PLUGIN_NAME); + } + if (sharedOpts.r2.r2Persist === undefined) { + storageOwnerRoutePlugins.add(R2_PLUGIN_NAME); + } + if (sharedOpts.d1.d1Persist === undefined) { + storageOwnerRoutePlugins.add(D1_PLUGIN_NAME); + } + if (sharedOpts.stream.streamPersist === undefined) { + storageOwnerRoutePlugins.add(STREAM_PLUGIN_NAME); + } + if ( + sharedOpts[SECRET_STORE_PLUGIN_NAME].secretsStorePersist === undefined + ) { + storageOwnerRoutePlugins.add(SECRET_STORE_PLUGIN_NAME); + } + if (sharedOpts.images.imagesPersist === undefined) { + storageOwnerRoutePlugins.add(IMAGES_PLUGIN_NAME); + } + } + // Connection string clients use to reach the owner's HTTP storage server. + const storageOwnerConn = + storageOwnerRouting !== undefined + ? (new URL( + `http://${storageOwnerRouting.ownerAddress}` + ) as RemoteProxyConnectionString) + : undefined; + const durableObjectClassNames = getDurableObjectClassNames(allWorkerOpts); const wrappedBindingNames = getWrappedBindingNames( allWorkerOpts, @@ -2097,7 +2479,18 @@ export class Miniflare { i ); if (pluginBindings !== undefined) { - for (const binding of pluginBindings) { + for (const originalBinding of pluginBindings) { + // When routing this plugin's storage to a shared owner, repoint + // local storage bindings at the storage-owner proxy. + const binding = + storageOwnerRoutePlugins.has(key) && + storageOwnerConn !== undefined + ? rewriteStorageOwnerBinding( + originalBinding, + storageOwnerConn, + key + ) + : originalBinding; // If this is the Workers Sites manifest, we need to add it as a // module for modules workers. For all other bindings, and in // service workers, just add to worker bindings. @@ -2194,6 +2587,14 @@ export class Miniflare { queueConsumers, devRegistryEnabled, hyperdriveProxyController: this.#hyperdriveProxyController, + storageOwnerRoutePlugins, + storageOwnerConn, + // Plugins not routed to the owner but still disk-backed (Cache, + // Durable Objects, Workflows) keep their storage per-instance when + // the feature is enabled, so separate processes don't contend on one + // database under the shared `defaultPersistRoot`. Applies to every + // role (client, fallback-to-local client, and owner). + isolateLocalStorage: this.#storageOwnerPersistRoot() !== undefined, }; for (const [key, plugin] of this.#mergedPluginEntries) { const workerOptions = this.#getWorkerOptsForPlugin(key, workerOpts); @@ -2363,6 +2764,99 @@ export class Miniflare { }); } + // Client-side storage-owner proxy: forwards repointed storage bindings to + // the owner over HTTP, reusing the remote-bindings ("mixed-mode") client + // worker. The owner address + resource key travel via each routed + // binding's props (see `rewriteStorageOwnerBinding`). + if (storageOwnerRouting !== undefined) { + services.set(SERVICE_STORAGE_OWNER_PROXY, { + name: SERVICE_STORAGE_OWNER_PROXY, + worker: remoteProxyClientWorker(), + }); + } + + // Owner-side storage server: exposes this instance's local storage entry + // services over a dedicated HTTP socket using the remote-bindings + // proxy-server protocol. Clients reach it via the address published in the + // owner definition. Each resource id is bound under a type-prefixed key + // pointing at the shared object-entry service with that id in props, so + // the entry worker attaches the correct `cf.miniflare.name` locally. + if (sharedOpts.core.unsafeStorageOwnerRole === "owner") { + // Bind one generic entry service per storage type the owner stood up. + // The resource id travels per-request (via header), so these serve any + // id — including ones only declared by clients that join later. + const ids = collectLocalStorageIds(allWorkerOpts); + const ownerBindings: Worker_Binding[] = []; + if (ids.kv.size > 0) { + ownerBindings.push({ + name: "kv", + service: { name: KV_LOCAL_ENTRY_SERVICE_NAME }, + }); + } + if (ids.r2.size > 0) { + ownerBindings.push({ + name: "r2", + service: { name: R2_LOCAL_ENTRY_SERVICE_NAME }, + }); + } + if (ids.d1.size > 0) { + ownerBindings.push({ + name: "d1", + service: { name: D1_LOCAL_ENTRY_SERVICE_NAME }, + }); + } + // Images: single fixed store, served via the fetch path like KV. + if (ids.images) { + ownerBindings.push({ + name: "images", + service: { name: IMAGES_NS_DATA_SERVICE_NAME }, + }); + } + // Streams: single RPC entrypoint (one store per owner), exposed under + // the "stream" key and dispatched via the JSRPC branch of the server. + if (ids.stream) { + ownerBindings.push({ + name: "stream", + service: { + name: STREAM_BINDING_SERVICE_NAME, + entrypoint: STREAM_BINDING_ENTRYPOINT, + }, + }); + } + // Secrets Store: one RPC entrypoint per secret, exposed under + // "secrets::". + for (const { store_id, secret_name } of ids.secrets.values()) { + const resource = `${store_id}:${secret_name}`; + ownerBindings.push({ + name: `secrets:${resource}`, + service: { + name: getUserBindingServiceName(SECRET_STORE_PLUGIN_NAME, resource), + entrypoint: SECRET_STORE_SECRET_ENTRYPOINT, + }, + }); + } + services.set(SERVICE_STORAGE_OWNER_SERVER, { + name: SERVICE_STORAGE_OWNER_SERVER, + worker: { + compatibilityDate: "2025-01-01", + compatibilityFlags: ["nodejs_compat", "experimental"], + modules: [ + { + name: "storage-owner-server.worker.js", + esModule: SCRIPT_STORAGE_OWNER_SERVER(), + }, + ], + bindings: ownerBindings, + }, + }); + sockets.push({ + name: SOCKET_STORAGE_OWNER, + address: "127.0.0.1:0", + service: { name: SERVICE_STORAGE_OWNER_SERVER }, + http: {}, + }); + } + // Collect workflow options from all workers for the explorer binding map const workflowOptions = new Map< string, @@ -2404,6 +2898,7 @@ export class Miniflare { durableObjectClassNames, workflowOptions: workflowOptions.size > 0 ? workflowOptions : undefined, allWorkerOpts, + storageOwnerRoutePlugins, }); for (const service of globalServices) { // Global services should all have unique names @@ -2452,6 +2947,7 @@ export class Miniflare { // This function must be run with `#runtimeMutex` held const initial = !this.#runtimeEntryURL; assert(this.#runtime !== undefined); + const runtime = this.#runtime; const configuredHost = this.#sharedOpts.core.host ?? DEFAULT_HOST; // For internal loopback communication with workerd, always use 127.0.0.1 // when localhost is configured. This prevents IPv6/IPv4 mismatch issues @@ -2532,11 +3028,16 @@ export class Miniflare { handleStructuredLogs: this.#sharedOpts.core.handleStructuredLogs, runtimeEnv: this.#sharedOpts.core.unsafeRuntimeEnv, }; - const maybeSocketPorts = await this.#runtime.updateConfig( - configBuffer, - runtimeOpts, - this.#workerOpts.flatMap((w) => w.core.name ?? []), - this.#disposeController.signal + const maybeSocketPorts = await withPersistRootStartupLock( + this.#sharedOpts.core.defaultPersistRoot, + this.#disposeController.signal, + () => + runtime.updateConfig( + configBuffer, + runtimeOpts, + this.#workerOpts.flatMap((w) => w.core.name ?? []), + this.#disposeController.signal + ) ); if (this.#disposeController.signal.aborted) return; if (maybeSocketPorts === undefined) { @@ -2633,6 +3134,8 @@ export class Miniflare { await this.#registerWorkers(); + this.#updateStorageOwnerPresence(); + // Catch any registry updates that occurred while workerd was booting. if (this.#devRegistry.isEnabled()) { await this.#pushRegistryUpdate(); @@ -2715,6 +3218,218 @@ export class Miniflare { return new URL(this.#runtimeEntryURL.toString()); } + /** + * The persist root this instance participates in as a shared storage + * owner/client, or `undefined` if the feature is off or there is nothing to + * share (pure in-memory storage). + */ + #storageOwnerPersistRoot(): string | undefined { + const core = this.#sharedOpts.core; + if (!core.unsafeSharedStorageOwner) { + return undefined; + } + return core.defaultPersistRoot; + } + + /** + * Resolves the storage owner this instance (as a *client*) should route to, + * or `undefined` to behave normally (owner role, feature off, no persist + * root, or no owner currently published). + */ + #getStorageOwnerRouting(): { ownerAddress: string } | undefined { + const core = this.#sharedOpts.core; + const persistRoot = this.#storageOwnerPersistRoot(); + if (persistRoot === undefined || core.unsafeStorageOwnerRole === "owner") { + return undefined; + } + const owner = readStorageOwner(persistRoot); + if (owner === undefined) { + this.#log.warn( + "Shared storage owner enabled but no owner is currently published — " + + "using local storage for this instance" + ); + return undefined; + } + return { ownerAddress: owner.httpAddress }; + } + + /** + * As a client, make sure a storage owner exists for our persist root before + * we assemble (and therefore route to it). If none is published, elect a + * single spawner via the owner spawn-lock, spawn a detached owner process, + * and wait for it to publish itself. Other clients just wait. + * + * Best-effort: on any failure we log and fall back to local storage (the + * client simply won't route), so the feature degrades rather than crashes. + */ + async #ensureStorageOwner(): Promise { + const core = this.#sharedOpts.core; + const persistRoot = this.#storageOwnerPersistRoot(); + if (persistRoot === undefined || core.unsafeStorageOwnerRole === "owner") { + return; + } + if (readStorageOwner(persistRoot) !== undefined) { + return; + } + + let lock: ReturnType; + try { + lock = tryAcquireOwnerSpawnLock(persistRoot); + // Re-check under the lock: another client may have just published one. + if (readStorageOwner(persistRoot) !== undefined) { + return; + } + if (lock !== undefined) { + this.#spawnStorageOwner(persistRoot); + } + // Wait for the owner (ours or another client's) to publish itself. + const deadline = Date.now() + STORAGE_OWNER_SPAWN_TIMEOUT_MS; + while ( + readStorageOwner(persistRoot) === undefined && + Date.now() < deadline && + !this.#disposeController.signal.aborted + ) { + await new Promise((resolve) => + setTimeout(resolve, STORAGE_OWNER_POLL_MS) + ); + } + if (readStorageOwner(persistRoot) === undefined) { + this.#log.warn( + "Timed out waiting for the shared storage owner to start — " + + "using local storage for this instance" + ); + } + } catch (e) { + this.#log.warn(`Failed to ensure a shared storage owner: ${String(e)}`); + } finally { + lock?.release(); + } + } + + /** + * Spawns a detached owner process for the persist root, hosting the storage + * resources this instance uses. The owner runs the same built miniflare + * module and self-terminates once no clients remain (see + * {@link runStorageOwnerProcess}). + */ + #spawnStorageOwner(persistRoot: string): void { + // Union of local (non-remote) storage resource ids across all workers, so + // the owner stands up the corresponding storage services. The services are + // generic (keyed by `idFromName`), so they additionally serve ids declared + // only by other clients. + const ids = collectLocalStorageIds(this.#workerOpts); + + const ownerOptions = { + defaultPersistRoot: persistRoot, + unsafeDevRegistryPath: this.#sharedOpts.core.unsafeDevRegistryPath, + modules: true, + script: + "export default { async fetch() { return new Response('miniflare storage owner', { status: 404 }); } }", + kvNamespaces: [...ids.kv], + r2Buckets: [...ids.r2], + d1Databases: [...ids.d1], + // One stream store per owner; the binding name is irrelevant (the + // owner exposes it under the canonical "stream" key). + ...(ids.stream ? { stream: { binding: "stream" } } : {}), + // One images store per owner (binding name irrelevant). + ...(ids.images ? { images: { binding: "images" } } : {}), + // Secrets Store: recreate each secret resource so the owner stands up + // the matching per-secret service (binding names are irrelevant). + ...(ids.secrets.size > 0 + ? { + secretsStoreSecrets: Object.fromEntries( + [...ids.secrets.entries()].map(([resource, secret]) => [ + `owner:${resource}`, + secret, + ]) + ), + } + : {}), + }; + + const configPath = path.join( + persistRoot, + `.miniflare-owner-config-${process.pid}.json` + ); + fs.writeFileSync(configPath, JSON.stringify(ownerOptions)); + + const child = spawn(process.execPath, ["-e", STORAGE_OWNER_BOOTSTRAP], { + detached: true, + stdio: "ignore", + env: { + ...process.env, + [ENV_STORAGE_OWNER_MAIN]: __filename, + [ENV_STORAGE_OWNER_CONFIG]: configPath, + }, + }); + child.unref(); + } + + /** + * Publishes this instance's role in the shared-storage topology once the + * runtime (and therefore the debug port) is available: + * - owner: writes the owner definition so clients can discover and route to + * its debug port, and heartbeats it. + * - client: registers a presence file (and heartbeats it) so the owner can + * tell when no clients remain and tear itself down. + */ + #updateStorageOwnerPresence(): void { + const persistRoot = this.#storageOwnerPersistRoot(); + if (persistRoot === undefined) { + return; + } + if (this.#storageOwnerHeartbeat !== undefined) { + clearInterval(this.#storageOwnerHeartbeat); + this.#storageOwnerHeartbeat = undefined; + } + + const isOwner = this.#sharedOpts.core.unsafeStorageOwnerRole === "owner"; + if (isOwner) { + const ownerPort = this.#socketPorts?.get(SOCKET_STORAGE_OWNER); + if (ownerPort === undefined) { + this.#log.warn( + "Shared storage owner enabled but its HTTP storage socket is " + + "unavailable — storage will not be shared" + ); + return; + } + writeStorageOwner(persistRoot, { + pid: process.pid, + httpAddress: `127.0.0.1:${ownerPort}`, + updatedAt: Date.now(), + }); + this.#storageOwnerHeartbeat = setInterval(() => { + heartbeatStorageOwner(persistRoot); + }, OWNER_HEARTBEAT_MS); + } else { + this.#storageClientPath = registerStorageClient(persistRoot); + const clientPath = this.#storageClientPath; + this.#storageOwnerHeartbeat = setInterval(() => { + heartbeatStorageClient(clientPath); + }, OWNER_HEARTBEAT_MS); + } + // Don't keep the event loop alive solely for the heartbeat. + this.#storageOwnerHeartbeat?.unref?.(); + } + + /** Tears down this instance's shared-storage presence on dispose. */ + #disposeStorageOwnerPresence(): void { + if (this.#storageOwnerHeartbeat !== undefined) { + clearInterval(this.#storageOwnerHeartbeat); + this.#storageOwnerHeartbeat = undefined; + } + const persistRoot = this.#storageOwnerPersistRoot(); + if (persistRoot === undefined) { + return; + } + if (this.#sharedOpts.core.unsafeStorageOwnerRole === "owner") { + clearStorageOwner(persistRoot, process.pid); + } else if (this.#storageClientPath !== undefined) { + unregisterStorageClient(this.#storageClientPath); + this.#storageClientPath = undefined; + } + } + async #registerWorkers(): Promise { if (!this.#devRegistry.isEnabled()) { return; @@ -3458,6 +4173,9 @@ export class Miniflare { // Unregister workers from dev registry and stop the file watcher await this.#devRegistry.dispose(); + // Remove our shared-storage owner/client presence files + this.#disposeStorageOwnerPresence(); + // shutdown hyperdrive proxies if any exist await this.#hyperdriveProxyController.dispose(); @@ -3468,6 +4186,76 @@ export class Miniflare { } } +/** + * Entry point for the detached storage-owner process spawned by a client (see + * `Miniflare.#spawnStorageOwner`). Reads its config from a temp file named in + * the environment, starts a headless owner-role Miniflare, and self-terminates + * once no clients have been present for a debounce window (after a startup + * grace period), so storage processes don't linger. + */ +export async function runStorageOwnerProcess(): Promise { + const configPath = process.env[ENV_STORAGE_OWNER_CONFIG]; + assert(configPath !== undefined, `${ENV_STORAGE_OWNER_CONFIG} must be set`); + const options = JSON.parse( + fs.readFileSync(configPath, "utf8") + ) as MiniflareOptions; + // The config file has served its purpose; remove it. + fs.rmSync(configPath, { force: true }); + + const persistRoot = (options as { defaultPersistRoot?: string }) + .defaultPersistRoot; + assert( + persistRoot !== undefined, + "storage owner config must set `defaultPersistRoot`" + ); + + const mf = new Miniflare({ + ...options, + unsafeSharedStorageOwner: true, + unsafeStorageOwnerRole: "owner", + }); + + let disposing = false; + // Holder so `shutdown` (defined before the interval is created) can clear it. + const timers: { idle?: NodeJS.Timeout } = {}; + const shutdown = async () => { + if (disposing) { + return; + } + disposing = true; + if (timers.idle !== undefined) { + clearInterval(timers.idle); + } + try { + await mf.dispose(); + } finally { + process.exit(0); + } + }; + process.on("SIGTERM", () => void shutdown()); + process.on("SIGINT", () => void shutdown()); + + await mf.ready; + + // Self-teardown: once past the startup grace, exit after a debounced run of + // checks observing zero live clients. + const startedAt = Date.now(); + let idleChecks = 0; + timers.idle = setInterval(() => { + if (Date.now() - startedAt < STORAGE_OWNER_STARTUP_GRACE_MS) { + return; + } + if (countLiveStorageClients(persistRoot) === 0) { + idleChecks++; + if (idleChecks >= STORAGE_OWNER_IDLE_DEBOUNCE) { + void shutdown(); + } + } else { + idleChecks = 0; + } + }, STORAGE_OWNER_IDLE_CHECK_MS); +} + export type { WorkerdStructuredLog } from "./plugins/core"; export interface SecretsStoreSecretAdmin { diff --git a/packages/miniflare/src/plugins/cache/index.ts b/packages/miniflare/src/plugins/cache/index.ts index 7f74670444c..da1f130ee19 100644 --- a/packages/miniflare/src/plugins/cache/index.ts +++ b/packages/miniflare/src/plugins/cache/index.ts @@ -58,6 +58,7 @@ export const CACHE_PLUGIN: Plugin< tmpPath, defaultPersistRoot, unsafeStickyBlobs, + isolateLocalStorage, }) { const cache = options.cache ?? true; const cacheWarnUsage = options.cacheWarnUsage ?? false; @@ -101,10 +102,17 @@ export const CACHE_PLUGIN: Plugin< const uniqueKey = `miniflare-${CACHE_OBJECT_CLASS_NAME}`; const persist = sharedOptions.cachePersist; + // With the shared storage owner enabled, cache stays local to each + // instance: ignore the shared `defaultPersistRoot` (unless the user set + // an explicit `cachePersist`) so each process uses its own per-instance + // `tmpPath` cache and never contends cross-process. + const cacheDefaultPersistRoot = isolateLocalStorage + ? undefined + : defaultPersistRoot; const persistPath = getPersistPath( CACHE_PLUGIN_NAME, tmpPath, - defaultPersistRoot, + cacheDefaultPersistRoot, persist ); await fs.mkdir(persistPath, { recursive: true }); diff --git a/packages/miniflare/src/plugins/core/explorer.ts b/packages/miniflare/src/plugins/core/explorer.ts index 7e17adfbd52..8ac3c155971 100644 --- a/packages/miniflare/src/plugins/core/explorer.ts +++ b/packages/miniflare/src/plugins/core/explorer.ts @@ -174,7 +174,21 @@ export function constructExplorerBindingMap( const [innerBinding] = binding.wrapped?.innerBindings ?? []; assert(innerBinding && "service" in innerBinding); - const databaseId = innerBinding.service?.name?.replace(/^d1:db:/, ""); + // Local databases share one entry service ("d1:db:entry") and carry + // their id in props; remote databases still encode the id in the + // service name ("d1:db:ID"). + let databaseId: string | undefined; + const propsJson = innerBinding.service?.props?.json; + if (propsJson !== undefined) { + try { + databaseId = JSON.parse(propsJson)[SharedBindings.TEXT_NAMESPACE]; + } catch { + // fall through to service-name parsing + } + } + if (databaseId === undefined) { + databaseId = innerBinding.service?.name?.replace(/^d1:db:/, ""); + } assert(databaseId); IDToBindingName.d1[databaseId] = binding.name; @@ -206,7 +220,10 @@ export function constructExplorerBindingMap( IDToBindingName.kv[namespaceId] = binding.name; } - // R2 bindings: name = "MINIFLARE_PROXY:r2:worker:BINDING", r2Bucket.name = "r2:bucket:ID" + // R2 bindings: name = "MINIFLARE_PROXY:r2:worker:BINDING". + // Local buckets share one entry service ("r2:bucket:entry") and carry + // their id in props; remote buckets still encode the id in the service + // name ("r2:bucket:ID"). if ( binding.name?.startsWith( `${CoreBindings.DURABLE_OBJECT_NAMESPACE_PROXY}:r2:` @@ -214,8 +231,18 @@ export function constructExplorerBindingMap( "r2Bucket" in binding && binding.r2Bucket?.name?.startsWith("r2:bucket:") ) { - // Extract bucket name from service name "r2:bucket:BUCKET_NAME" - const bucketName = binding.r2Bucket.name.replace(/^r2:bucket:/, ""); + let bucketName: string | undefined; + const propsJson = binding.r2Bucket.props?.json; + if (propsJson !== undefined) { + try { + bucketName = JSON.parse(propsJson)[SharedBindings.TEXT_NAMESPACE]; + } catch { + // fall through to service-name parsing + } + } + if (bucketName === undefined) { + bucketName = binding.r2Bucket.name.replace(/^r2:bucket:/, ""); + } IDToBindingName.r2[bucketName] = binding.name; } } diff --git a/packages/miniflare/src/plugins/core/index.ts b/packages/miniflare/src/plugins/core/index.ts index 0c8d608b7e7..f447c6fe2cf 100644 --- a/packages/miniflare/src/plugins/core/index.ts +++ b/packages/miniflare/src/plugins/core/index.ts @@ -33,7 +33,11 @@ import { normaliseDurableObject, } from "../do"; import { IMAGES_PLUGIN_NAME } from "../images"; -import { getR2PublicService, R2_PUBLIC_SERVICE_NAME } from "../r2"; +import { + getR2PublicService, + R2_PLUGIN_NAME, + R2_PUBLIC_SERVICE_NAME, +} from "../r2"; import { buildRemoteProxyProps, getUserBindingServiceName, @@ -347,6 +351,17 @@ export const CoreSharedOptionsSchema = z // Path to the project temporary directory for plugins that need it // (e.g. email logs). Falls back to a subdirectory of tmpPath if not set. defaultProjectTmpPath: z.string().optional(), + // EXPERIMENTAL: route all storage (KV/R2/D1/Cache) for a given persist + // root through a single detached "owner" process, so exactly one process + // opens the underlying SQLite/blob files. Eliminates cross-process SQLite + // contention when multiple Miniflare instances share a persist root. + // No-op when `defaultPersistRoot` is undefined (pure in-memory storage). + unsafeSharedStorageOwner: z.boolean().optional(), + // Internal: the role this instance plays in the shared-storage-owner + // topology. "owner" publishes itself as the storage owner for the persist + // root; "client" (the default when the feature is enabled) routes storage + // to whichever owner is published. Set on the detached owner process. + unsafeStorageOwnerRole: z.enum(["owner", "client"]).optional(), // Strip the MF-DISABLE_PRETTY_ERROR header from user request stripDisablePrettyError: z.boolean().default(true), @@ -1083,6 +1098,8 @@ export interface GlobalServicesOptions { workflowOptions?: Map; /** All worker options for building per-worker resource bindings */ allWorkerOpts?: PluginWorkerOptions[]; + /** Storage plugins routed to a shared owner; their global services are skipped. */ + storageOwnerRoutePlugins?: Set; } export function getGlobalServices({ sharedOptions, @@ -1095,6 +1112,7 @@ export function getGlobalServices({ durableObjectClassNames, workflowOptions, allWorkerOpts, + storageOwnerRoutePlugins, }: GlobalServicesOptions): Service[] { // Collect list of workers we could route to, then parse and sort all routes const workerNames = [...allWorkerRoutes.keys()]; @@ -1154,11 +1172,15 @@ export function getGlobalServices({ }, }); } - const streamServiceEnabled = allWorkerOpts?.some( - (worker) => - worker.stream?.stream !== undefined && - !worker.stream.stream.remoteProxyConnectionString - ); + // When Stream is routed to a shared storage owner, the local stream service + // isn't stood up, so the entry worker must not bind it either. + const streamServiceEnabled = + !storageOwnerRoutePlugins?.has(STREAM_PLUGIN_NAME) && + allWorkerOpts?.some( + (worker) => + worker.stream?.stream !== undefined && + !worker.stream.stream.remoteProxyConnectionString + ); if (streamServiceEnabled) { serviceEntryBindings.push({ name: CoreBindings.SERVICE_STREAM, @@ -1168,7 +1190,12 @@ export function getGlobalServices({ }, }); } - const r2PublicService = getR2PublicService(allWorkerOpts ?? []); + // When R2 is routed to a shared storage owner, the local R2 storage services + // (incl. the entry service the public worker binds) aren't stood up, so skip + // the public-bucket service too. + const r2PublicService = storageOwnerRoutePlugins?.has(R2_PLUGIN_NAME) + ? undefined + : getR2PublicService(allWorkerOpts ?? []); if (r2PublicService !== undefined) { serviceEntryBindings.push({ name: CoreBindings.SERVICE_R2_PUBLIC, diff --git a/packages/miniflare/src/plugins/d1/index.ts b/packages/miniflare/src/plugins/d1/index.ts index 007f73d68eb..ea11cbb92b2 100644 --- a/packages/miniflare/src/plugins/d1/index.ts +++ b/packages/miniflare/src/plugins/d1/index.ts @@ -4,10 +4,10 @@ import SCRIPT_D1_DATABASE_OBJECT from "worker:d1/database"; import { z } from "zod"; import { SharedBindings } from "../../workers"; import { + buildObjectEntryProps, buildRemoteProxyProps, getMiniflareObjectBindings, getPersistPath, - getUserBindingServiceName, migrateDatabase, namespaceEntries, namespaceKeys, @@ -49,6 +49,9 @@ export const D1SharedOptionsSchema = z.object({ export const D1_PLUGIN_NAME = "d1"; const D1_STORAGE_SERVICE_NAME = `${D1_PLUGIN_NAME}:storage`; const D1_DATABASE_SERVICE_PREFIX = `${D1_PLUGIN_NAME}:db`; +// A single entry service shared by every *local* database. Each database's id is +// supplied per-binding via `ctx.props`, so one service serves all of them. +export const D1_LOCAL_ENTRY_SERVICE_NAME = `${D1_PLUGIN_NAME}:db:entry`; // One shared remote-proxy service for all remote D1 databases (config via props). const D1_REMOTE_SERVICE_NAME = `${D1_PLUGIN_NAME}:db:remote`; const D1_DATABASE_OBJECT_CLASS_NAME = "D1DatabaseObject"; @@ -73,15 +76,16 @@ export const D1_PLUGIN: Plugin< "Alpha D1 Databases cannot run remotely" ); - // Remote databases share one proxy service (config via props); - // local databases keep their per-id entry service. + // Remote databases share one proxy service (config via props); local + // databases share one entry service with the id supplied via props. const serviceDesignator = remoteProxyConnectionString ? { name: D1_REMOTE_SERVICE_NAME, props: buildRemoteProxyProps(remoteProxyConnectionString, name), } : { - name: getUserBindingServiceName(D1_DATABASE_SERVICE_PREFIX, id), + name: D1_LOCAL_ENTRY_SERVICE_NAME, + props: buildObjectEntryProps(id), }; const binding = name.startsWith("__D1_BETA__") @@ -119,22 +123,33 @@ export const D1_PLUGIN: Plugin< defaultPersistRoot, log, unsafeStickyBlobs, + storageOwnerRoutePlugins, }) { const persist = sharedOptions.d1Persist; const databases = namespaceEntries(options.d1Databases); const services: Service[] = []; - let hasRemote = false; - for (const [, { id, remoteProxyConnectionString }] of databases) { - if (remoteProxyConnectionString) { - hasRemote = true; - } else { - services.push({ - name: getUserBindingServiceName(D1_DATABASE_SERVICE_PREFIX, id), - worker: objectEntryWorker(D1_DATABASE_OBJECT, id), - }); - } + + // When routing local D1 to a shared storage owner, this instance must not + // stand up its own D1 storage — its bindings are repointed at the owner + // proxy by `Miniflare`. + const routeToOwner = storageOwnerRoutePlugins.has(D1_PLUGIN_NAME); + + // One shared entry service for all local databases (id supplied via props). + const hasLocal = + !routeToOwner && + databases.some(([, db]) => !db.remoteProxyConnectionString); + if (hasLocal) { + services.push({ + name: D1_LOCAL_ENTRY_SERVICE_NAME, + worker: objectEntryWorker(D1_DATABASE_OBJECT), + }); } + + // One shared proxy service for all remote (mixed-mode) databases. + const hasRemote = databases.some( + ([, db]) => db.remoteProxyConnectionString + ); if (hasRemote) { services.push({ name: D1_REMOTE_SERVICE_NAME, @@ -142,7 +157,6 @@ export const D1_PLUGIN: Plugin< }); } - const hasLocal = services.some((s) => s.name !== D1_REMOTE_SERVICE_NAME); if (hasLocal) { const uniqueKey = `miniflare-${D1_DATABASE_OBJECT_CLASS_NAME}`; const persistPath = getPersistPath( diff --git a/packages/miniflare/src/plugins/do/index.ts b/packages/miniflare/src/plugins/do/index.ts index e97ae2d580c..b5c96225f00 100644 --- a/packages/miniflare/src/plugins/do/index.ts +++ b/packages/miniflare/src/plugins/do/index.ts @@ -137,6 +137,7 @@ export const DURABLE_OBJECTS_PLUGIN: Plugin< defaultPersistRoot, durableObjectClassNames, unsafeEphemeralDurableObjects, + isolateLocalStorage, }) { // Check if we even have any Durable Object bindings, if we don't, we can // skip creating the storage directory @@ -154,10 +155,13 @@ export const DURABLE_OBJECTS_PLUGIN: Plugin< // don't need to create the storage service at all. if (unsafeEphemeralDurableObjects) return; + // Durable Objects aren't routed to the shared storage owner (a DO is + // single-owner compute, not just storage). When the owner feature is on, + // keep DO storage per-instance so separate processes don't contend. const storagePath = getPersistPath( DURABLE_OBJECTS_PLUGIN_NAME, tmpPath, - defaultPersistRoot, + isolateLocalStorage ? undefined : defaultPersistRoot, sharedOptions.durableObjectsPersist ); // `workerd` requires the `disk.path` to exist. Setting `recursive: true` diff --git a/packages/miniflare/src/plugins/images/index.ts b/packages/miniflare/src/plugins/images/index.ts index 7df446df282..4e700933e62 100644 --- a/packages/miniflare/src/plugins/images/index.ts +++ b/packages/miniflare/src/plugins/images/index.ts @@ -36,6 +36,11 @@ export const ImagesSharedOptionsSchema = z.object({ export const IMAGES_PLUGIN_NAME = "images"; const IMAGES_REMOTE_SERVICE_NAME = `${IMAGES_PLUGIN_NAME}:remote`; +// Fixed namespace backing the Images store (one per instance/owner). +const IMAGES_DATA_NAMESPACE = "images-data"; +// The object-entry service exposing the Images store. Referenced by the shared +// storage owner so it can serve a routed client's Images KV operations. +export const IMAGES_NS_DATA_SERVICE_NAME = `${IMAGES_PLUGIN_NAME}:ns:data`; export const IMAGES_PLUGIN: Plugin< typeof ImagesOptionsSchema, @@ -91,11 +96,53 @@ export const IMAGES_PLUGIN: Plugin< tmpPath, defaultPersistRoot, unsafeStickyBlobs, + storageOwnerRoutePlugins, + storageOwnerConn, }) { if (!options.images) { return []; } + // Routed to the shared storage owner: keep the transform worker local but + // repoint its backing KV store (`IMAGES_STORE`) at the owner, and skip the + // local storage/object services (the owner stands them up). + if ( + storageOwnerRoutePlugins.has(IMAGES_PLUGIN_NAME) && + storageOwnerConn !== undefined + ) { + return [ + { + name: IMAGES_REMOTE_SERVICE_NAME, + worker: remoteProxyClientWorker(), + }, + { + name: getUserBindingServiceName( + IMAGES_PLUGIN_NAME, + options.images.binding + ), + worker: { + compatibilityDate: "2025-04-01", + modules: [ + { name: "images.worker.js", esModule: SCRIPT_IMAGES_SERVICE() }, + ], + bindings: [ + { + name: "IMAGES_STORE", + kvNamespace: { + name: IMAGES_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps( + storageOwnerConn, + `images:${IMAGES_DATA_NAMESPACE}` + ), + }, + }, + WORKER_BINDING_SERVICE_LOOPBACK, + ], + }, + }, + ]; + } + if (options.images.remoteProxyConnectionString) { return [ { @@ -157,13 +204,13 @@ export const IMAGES_PLUGIN: Plugin< } satisfies Service; const kvNamespaceService = { - name: `${IMAGES_PLUGIN_NAME}:ns:data`, + name: IMAGES_NS_DATA_SERVICE_NAME, worker: objectEntryWorker( { serviceName: objectService.name, className: KV_NAMESPACE_OBJECT_CLASS_NAME, }, - "images-data" + IMAGES_DATA_NAMESPACE ), } satisfies Service; diff --git a/packages/miniflare/src/plugins/kv/index.ts b/packages/miniflare/src/plugins/kv/index.ts index ff79ca4100c..4e7bb5630f6 100644 --- a/packages/miniflare/src/plugins/kv/index.ts +++ b/packages/miniflare/src/plugins/kv/index.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { PathSchema } from "../../shared"; import { SharedBindings } from "../../workers"; import { + buildObjectEntryProps, buildRemoteProxyProps, getMiniflareObjectBindings, getPersistPath, @@ -60,7 +61,7 @@ export const KVSharedOptionsSchema = z.object({ const SERVICE_NAMESPACE_PREFIX = `${KV_PLUGIN_NAME}:ns`; // A single entry service shared by every *local* namespace. Each namespace's id // is supplied per-binding via `ctx.props`, so one service serves all of them. -const KV_LOCAL_ENTRY_SERVICE_NAME = `${KV_PLUGIN_NAME}:ns:entry`; +export const KV_LOCAL_ENTRY_SERVICE_NAME = `${KV_PLUGIN_NAME}:ns:entry`; // One shared remote-proxy service for all remote namespaces (config via props). const KV_REMOTE_SERVICE_NAME = `${KV_PLUGIN_NAME}:ns:remote`; const KV_STORAGE_SERVICE_NAME = `${KV_PLUGIN_NAME}:storage`; @@ -106,11 +107,7 @@ export const KV_PLUGIN: Plugin< name, kvNamespace: { name: KV_LOCAL_ENTRY_SERVICE_NAME, - props: { - json: JSON.stringify({ - [SharedBindings.TEXT_NAMESPACE]: namespace.id, - }), - }, + props: buildObjectEntryProps(namespace.id), }, }; }); @@ -142,16 +139,23 @@ export const KV_PLUGIN: Plugin< defaultPersistRoot, log, unsafeStickyBlobs, + storageOwnerRoutePlugins, }) { const persist = sharedOptions.kvPersist; const namespaces = namespaceEntries(options.kvNamespaces); const services: Service[] = []; + // When routing local KV to a shared storage owner, this instance must not + // stand up its own KV storage (disk/DO/migrations) — its bindings are + // repointed at the owner proxy by `Miniflare`. Sites are still served + // locally as they aren't routed. + const routeToOwner = storageOwnerRoutePlugins.has(KV_PLUGIN_NAME); + // One shared entry service for all local namespaces (id supplied via props). - const hasLocalNamespace = namespaces.some( - ([, ns]) => !ns.remoteProxyConnectionString - ); + const hasLocalNamespace = + !routeToOwner && + namespaces.some(([, ns]) => !ns.remoteProxyConnectionString); if (hasLocalNamespace) { services.push({ name: KV_LOCAL_ENTRY_SERVICE_NAME, diff --git a/packages/miniflare/src/plugins/r2/index.ts b/packages/miniflare/src/plugins/r2/index.ts index 59543f5d409..6f37e32e98f 100644 --- a/packages/miniflare/src/plugins/r2/index.ts +++ b/packages/miniflare/src/plugins/r2/index.ts @@ -4,10 +4,10 @@ import SCRIPT_R2_PUBLIC from "worker:r2/public"; import { z } from "zod"; import { SharedBindings } from "../../workers"; import { + buildObjectEntryProps, buildRemoteProxyProps, getMiniflareObjectBindings, getPersistPath, - getUserBindingServiceName, migrateDatabase, namespaceEntries, namespaceKeys, @@ -49,6 +49,9 @@ export const R2SharedOptionsSchema = z.object({ export const R2_PLUGIN_NAME = "r2"; const R2_STORAGE_SERVICE_NAME = `${R2_PLUGIN_NAME}:storage`; const R2_BUCKET_SERVICE_PREFIX = `${R2_PLUGIN_NAME}:bucket`; +// A single entry service shared by every *local* bucket. Each bucket's id is +// supplied per-binding via `ctx.props`, so one service serves all of them. +export const R2_LOCAL_ENTRY_SERVICE_NAME = `${R2_PLUGIN_NAME}:bucket:entry`; // One shared remote-proxy service for all remote R2 buckets (config via props). const R2_REMOTE_SERVICE_NAME = `${R2_PLUGIN_NAME}:bucket:remote`; export const R2_PUBLIC_SERVICE_NAME = `${R2_PLUGIN_NAME}:public`; @@ -76,7 +79,8 @@ export function getR2PublicService( const bindings = Array.from(publicBucketIds).map((id) => ({ name: id, r2Bucket: { - name: getUserBindingServiceName(R2_BUCKET_SERVICE_PREFIX, id), + name: R2_LOCAL_ENTRY_SERVICE_NAME, + props: buildObjectEntryProps(id), }, })); return { @@ -109,10 +113,8 @@ export const R2_PLUGIN: Plugin< ), } : { - name: getUserBindingServiceName( - R2_BUCKET_SERVICE_PREFIX, - bucket.id - ), + name: R2_LOCAL_ENTRY_SERVICE_NAME, + props: buildObjectEntryProps(bucket.id), }, })); }, @@ -129,22 +131,30 @@ export const R2_PLUGIN: Plugin< defaultPersistRoot, log, unsafeStickyBlobs, + storageOwnerRoutePlugins, }) { const persist = sharedOptions.r2Persist; const buckets = namespaceEntries(options.r2Buckets); const services: Service[] = []; - let hasRemote = false; - for (const [, { id, remoteProxyConnectionString }] of buckets) { - if (remoteProxyConnectionString) { - hasRemote = true; - } else { - services.push({ - name: getUserBindingServiceName(R2_BUCKET_SERVICE_PREFIX, id), - worker: objectEntryWorker(R2_BUCKET_OBJECT, id), - }); - } + + // When routing local R2 to a shared storage owner, this instance must not + // stand up its own R2 storage — its bindings are repointed at the owner + // proxy by `Miniflare`. + const routeToOwner = storageOwnerRoutePlugins.has(R2_PLUGIN_NAME); + + // One shared entry service for all local buckets (id supplied via props). + const hasLocal = + !routeToOwner && buckets.some(([, b]) => !b.remoteProxyConnectionString); + if (hasLocal) { + services.push({ + name: R2_LOCAL_ENTRY_SERVICE_NAME, + worker: objectEntryWorker(R2_BUCKET_OBJECT), + }); } + + // One shared proxy service for all remote (mixed-mode) buckets. + const hasRemote = buckets.some(([, b]) => b.remoteProxyConnectionString); if (hasRemote) { services.push({ name: R2_REMOTE_SERVICE_NAME, @@ -152,7 +162,6 @@ export const R2_PLUGIN: Plugin< }); } - const hasLocal = services.some((s) => s.name !== R2_REMOTE_SERVICE_NAME); if (hasLocal) { const uniqueKey = `miniflare-${R2_BUCKET_OBJECT_CLASS_NAME}`; const persistPath = getPersistPath( diff --git a/packages/miniflare/src/plugins/secret-store/index.ts b/packages/miniflare/src/plugins/secret-store/index.ts index 609c6e8a811..6bd173fdb96 100644 --- a/packages/miniflare/src/plugins/secret-store/index.ts +++ b/packages/miniflare/src/plugins/secret-store/index.ts @@ -32,6 +32,9 @@ export const SecretsStoreSecretsSharedOptionsSchema = z.object({ }); export const SECRET_STORE_PLUGIN_NAME = "secrets-store"; +// RPC entrypoint exposing a single secret. Referenced by the shared storage +// owner so it can route a client's Secrets Store binding here. +export const SECRET_STORE_SECRET_ENTRYPOINT = "SecretsStoreSecret"; export const SECRET_STORE_PLUGIN: Plugin< typeof SecretsStoreSecretsOptionsSchema, @@ -78,6 +81,7 @@ export const SECRET_STORE_PLUGIN: Plugin< tmpPath, defaultPersistRoot, unsafeStickyBlobs, + storageOwnerRoutePlugins, }) { const configs = options.secretsStoreSecrets ? Object.values(options.secretsStoreSecrets) @@ -87,6 +91,12 @@ export const SECRET_STORE_PLUGIN: Plugin< return []; } + // Routed to the shared storage owner: the owner stands up the secret + // services; this instance's bindings are repointed at the owner proxy. + if (storageOwnerRoutePlugins.has(SECRET_STORE_PLUGIN_NAME)) { + return []; + } + const persistPath = getPersistPath( SECRET_STORE_PLUGIN_NAME, tmpPath, diff --git a/packages/miniflare/src/plugins/shared/constants.ts b/packages/miniflare/src/plugins/shared/constants.ts index 02c5c2ef601..d586e9dbc01 100644 --- a/packages/miniflare/src/plugins/shared/constants.ts +++ b/packages/miniflare/src/plugins/shared/constants.ts @@ -84,6 +84,17 @@ export function objectEntryWorker( }; } +// Builds the `props` for a binding that points at a shared object-entry service +// (KV namespace / R2 bucket / D1 database). The resource id travels via props so +// that a single entry service can route to any number of resources; it is read +// back in `object-entry.worker.ts` via `ctx.props` and used as the Durable +// Object name (`idFromName`). +export function buildObjectEntryProps(id: string): { json: string } { + return { + json: JSON.stringify({ [SharedBindings.TEXT_NAMESPACE]: id }), + }; +} + // A single remote-proxy client service can serve any number of remote bindings: // the per-binding data (connection string, binding name, trace id) is supplied // at runtime via `ctx.props` (see `buildRemoteProxyProps`), rather than baked diff --git a/packages/miniflare/src/plugins/shared/index.ts b/packages/miniflare/src/plugins/shared/index.ts index baf5928461c..be599a8e7d9 100644 --- a/packages/miniflare/src/plugins/shared/index.ts +++ b/packages/miniflare/src/plugins/shared/index.ts @@ -97,6 +97,21 @@ export interface PluginServicesOptions< // messages to a consumer in another `wrangler dev` process. devRegistryEnabled: boolean; hyperdriveProxyController: HyperdriveProxyController; + // Plugin names (e.g. "kv") whose *local* storage is being routed to a shared + // storage owner process. Plugins listed here should skip standing up their + // local storage services (disk/DO/migrations); their bindings are rewritten + // to the storage-owner proxy by `Miniflare`. + storageOwnerRoutePlugins: Set; + // Connection string for the shared storage owner's proxy, when a plugin needs + // to repoint an internal storage binding at the owner itself (e.g. the Images + // transform worker's backing KV store). `undefined` when not routing. + storageOwnerConn: RemoteProxyConnectionString | undefined; + // When the shared storage owner feature is enabled, plugins that aren't + // routed to the owner but still persist to `defaultPersistRoot` (Cache, + // Durable Objects, Workflows) keep their storage per-instance (under + // `tmpPath`) instead of the shared root, so separate processes don't contend + // on one database. Honoured unless the user set an explicit `*Persist`. + isolateLocalStorage: boolean; } export interface ServicesExtensions { diff --git a/packages/miniflare/src/plugins/stream/index.ts b/packages/miniflare/src/plugins/stream/index.ts index fe75ffbc367..dde01c2a22c 100644 --- a/packages/miniflare/src/plugins/stream/index.ts +++ b/packages/miniflare/src/plugins/stream/index.ts @@ -36,6 +36,10 @@ const STREAM_REMOTE_SERVICE_NAME = `${STREAM_PLUGIN_NAME}:remote`; const STREAM_STORAGE_SERVICE_NAME = `${STREAM_PLUGIN_NAME}:storage`; const STREAM_OBJECT_SERVICE_NAME = `${STREAM_PLUGIN_NAME}:object`; export const STREAM_OBJECT_CLASS_NAME = "StreamObject"; +// The RPC entrypoint service exposing the stream store. Referenced by the +// shared storage owner so it can route a client's Stream binding here. +export const STREAM_BINDING_SERVICE_NAME = `${STREAM_PLUGIN_NAME}:service`; +export const STREAM_BINDING_ENTRYPOINT = "StreamBinding"; export const STREAM_COMPAT_DATE = "2026-03-23"; @@ -83,11 +87,19 @@ export const STREAM_PLUGIN: Plugin< tmpPath, defaultPersistRoot, unsafeStickyBlobs, + storageOwnerRoutePlugins, }) { if (!options.stream) { return []; } + // Routed to the shared storage owner: the owner stands up the stream + // store and entrypoint; this instance's binding is repointed at the owner + // proxy by `Miniflare`, so skip standing up local storage here. + if (storageOwnerRoutePlugins.has(STREAM_PLUGIN_NAME)) { + return []; + } + if (options.stream.remoteProxyConnectionString) { return [ { diff --git a/packages/miniflare/src/plugins/workflows/index.ts b/packages/miniflare/src/plugins/workflows/index.ts index a89b5219121..388876e1234 100644 --- a/packages/miniflare/src/plugins/workflows/index.ts +++ b/packages/miniflare/src/plugins/workflows/index.ts @@ -97,11 +97,19 @@ export const WORKFLOWS_PLUGIN: Plugin< ]; }, - async getServices({ options, sharedOptions, tmpPath, defaultPersistRoot }) { + async getServices({ + options, + sharedOptions, + tmpPath, + defaultPersistRoot, + isolateLocalStorage, + }) { + // Workflows aren't routed to the shared storage owner; keep their storage + // per-instance when the feature is on so separate processes don't contend. const persistPath = getPersistPath( WORKFLOWS_PLUGIN_NAME, tmpPath, - defaultPersistRoot, + isolateLocalStorage ? undefined : defaultPersistRoot, sharedOptions.workflowsPersist ); await fs.mkdir(persistPath, { recursive: true }); diff --git a/packages/miniflare/src/shared/index.ts b/packages/miniflare/src/shared/index.ts index 253f2e50a53..b2faedc5368 100644 --- a/packages/miniflare/src/shared/index.ts +++ b/packages/miniflare/src/shared/index.ts @@ -3,5 +3,6 @@ export * from "./error"; export * from "./event"; export * from "./log"; export * from "./matcher"; +export * from "./storage-owner"; export * from "./streams"; export * from "./types"; diff --git a/packages/miniflare/src/shared/storage-owner.ts b/packages/miniflare/src/shared/storage-owner.ts new file mode 100644 index 00000000000..e15f44a25b6 --- /dev/null +++ b/packages/miniflare/src/shared/storage-owner.ts @@ -0,0 +1,290 @@ +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; + +// Filesystem layout under a persist root for the "central storage owner" feature: +// +// /.miniflare-owner.json - the live owner's definition (heartbeated) +// /.miniflare-owner.lock - transient lock serialising owner election +// /.miniflare-owner-clients/ - one heartbeat file per live client +// +// Exactly one process per persist root publishes the owner definition; every +// other Miniflare instance reads it and routes its storage there. + +const OWNER_DEFINITION_FILE = ".miniflare-owner.json"; +const OWNER_SPAWN_LOCK_FILE = ".miniflare-owner.lock"; +const OWNER_CLIENTS_DIR = ".miniflare-owner-clients"; + +// The owner definition / lock is considered stale once its mtime is older than +// this. Heartbeats run well within this window. +export const OWNER_STALE_MS = 30_000; +export const OWNER_HEARTBEAT_MS = 5_000; +const OWNER_LOCK_RETRY_MS = 50; + +export interface StorageOwnerDefinition { + /** PID of the owner process, used for liveness / orphan reclaim. */ + pid: number; + /** + * HTTP address of the owner's storage server (e.g. "127.0.0.1:12345"). + * Clients route their KV/R2/D1 bindings here via the remote-bindings proxy + * client, addressing individual resources by a type-prefixed key (e.g. + * "kv:") carried in the `MF-Binding` header. + */ + httpAddress: string; + /** Wall-clock time the definition was last (re)written. */ + updatedAt: number; +} + +/** + * Returns whether a process with the given pid is currently alive. + * + * `process.kill(pid, 0)` sends no signal but performs the permission/existence + * check: it throws `ESRCH` if the process does not exist, and `EPERM` if it + * exists but we lack permission to signal it (still alive). + */ +export function isProcessAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch (e) { + return (e as NodeJS.ErrnoException).code === "EPERM"; + } +} + +function ownerDefinitionPath(persistRoot: string): string { + return path.join(persistRoot, OWNER_DEFINITION_FILE); +} + +function ownerSpawnLockPath(persistRoot: string): string { + return path.join(persistRoot, OWNER_SPAWN_LOCK_FILE); +} + +function ownerClientsDir(persistRoot: string): string { + return path.join(persistRoot, OWNER_CLIENTS_DIR); +} + +/** + * Reads the current owner definition for a persist root, or `undefined` if there + * is no live owner. A definition is considered absent when the file is missing, + * unparseable, its heartbeat is stale, or its process is dead. + */ +export function readStorageOwner( + persistRoot: string +): StorageOwnerDefinition | undefined { + const definitionPath = ownerDefinitionPath(persistRoot); + let stats; + try { + stats = statSync(definitionPath, { throwIfNoEntry: false }); + } catch { + return undefined; + } + if (stats === undefined) { + return undefined; + } + if (stats.mtime.getTime() < Date.now() - OWNER_STALE_MS) { + return undefined; + } + let definition: StorageOwnerDefinition; + try { + definition = JSON.parse( + readFileSync(definitionPath, { encoding: "utf8" }) + ) as StorageOwnerDefinition; + } catch { + return undefined; + } + if (!isProcessAlive(definition.pid)) { + return undefined; + } + return definition; +} + +/** + * Atomically writes the owner definition (write-to-temp + rename) so concurrent + * readers never observe a partial file. + */ +export function writeStorageOwner( + persistRoot: string, + definition: StorageOwnerDefinition +): void { + mkdirSync(persistRoot, { recursive: true }); + const definitionPath = ownerDefinitionPath(persistRoot); + const tmpPath = `${definitionPath}.${process.pid}.tmp`; + writeFileSync(tmpPath, JSON.stringify(definition, null, 2)); + renameSync(tmpPath, definitionPath); +} + +/** + * Removes the owner definition if it belongs to the given pid (or is already + * dead/stale). Used by the owner on shutdown and by clients reclaiming an + * orphaned lease. + */ +export function clearStorageOwner(persistRoot: string, pid?: number): void { + const definitionPath = ownerDefinitionPath(persistRoot); + if (pid !== undefined) { + const current = readStorageOwnerRaw(persistRoot); + if (current !== undefined && current.pid !== pid) { + // Belongs to a different live owner — don't stomp it. + if (isProcessAlive(current.pid)) { + return; + } + } + } + rmSync(definitionPath, { force: true }); +} + +/** Reads the definition ignoring staleness/liveness (raw bytes). */ +function readStorageOwnerRaw( + persistRoot: string +): StorageOwnerDefinition | undefined { + try { + return JSON.parse( + readFileSync(ownerDefinitionPath(persistRoot), { encoding: "utf8" }) + ) as StorageOwnerDefinition; + } catch { + return undefined; + } +} + +/** Touches the owner definition mtime to signal liveness. */ +export function heartbeatStorageOwner(persistRoot: string): void { + try { + const now = new Date(); + utimesSync(ownerDefinitionPath(persistRoot), now, now); + } catch { + // File may have been reclaimed; the owner's publish loop will rewrite it. + } +} + +/** Handle for a held owner-election lock. */ +export interface OwnerSpawnLock { + release(): void; +} + +/** + * Attempts to acquire the per-persist-root election lock so that exactly one + * client spawns the owner process. Returns a release handle on success, or + * `undefined` if another live process currently holds it. + * + * A lock whose mtime is stale or whose pid is dead is reclaimed. + */ +export function tryAcquireOwnerSpawnLock( + persistRoot: string +): OwnerSpawnLock | undefined { + mkdirSync(persistRoot, { recursive: true }); + const lockPath = ownerSpawnLockPath(persistRoot); + try { + writeFileSync(lockPath, String(process.pid), { flag: "wx" }); + return { release: () => rmSync(lockPath, { force: true }) }; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "EEXIST") { + throw e; + } + } + // Lock exists — reclaim if stale or owned by a dead process. + if (isOwnerSpawnLockStale(lockPath)) { + rmSync(lockPath, { force: true }); + try { + writeFileSync(lockPath, String(process.pid), { flag: "wx" }); + return { release: () => rmSync(lockPath, { force: true }) }; + } catch { + return undefined; + } + } + return undefined; +} + +function isOwnerSpawnLockStale(lockPath: string): boolean { + let stats; + try { + stats = statSync(lockPath, { throwIfNoEntry: false }); + } catch { + return true; + } + if (stats === undefined) { + return true; + } + if (stats.mtime.getTime() < Date.now() - OWNER_STALE_MS) { + return true; + } + let pid: number; + try { + pid = Number(readFileSync(lockPath, { encoding: "utf8" }).trim()); + } catch { + return true; + } + return !isProcessAlive(pid); +} + +export { OWNER_LOCK_RETRY_MS }; + +// --- Client presence registry (used by lifecycle guards) --- + +/** + * Registers this process as a live client of the owner by writing a heartbeat + * file named after its pid. Returns the file path so the caller can heartbeat + * and remove it on dispose. + */ +export function registerStorageClient(persistRoot: string): string { + const dir = ownerClientsDir(persistRoot); + mkdirSync(dir, { recursive: true }); + const clientPath = path.join(dir, String(process.pid)); + writeFileSync(clientPath, String(Date.now())); + return clientPath; +} + +export function heartbeatStorageClient(clientPath: string): void { + try { + const now = new Date(); + utimesSync(clientPath, now, now); + } catch { + // File removed by reclaim; caller will re-register on next assemble. + } +} + +export function unregisterStorageClient(clientPath: string): void { + rmSync(clientPath, { force: true }); +} + +/** + * Counts live clients of the owner, reclaiming stale entries (dead pid or stale + * mtime). Used by the owner to decide when it can tear itself down. + */ +export function countLiveStorageClients(persistRoot: string): number { + const dir = ownerClientsDir(persistRoot); + if (!existsSync(dir)) { + return 0; + } + let count = 0; + for (const name of readdirSync(dir)) { + const clientPath = path.join(dir, name); + const pid = Number(name); + let stats; + try { + stats = statSync(clientPath, { throwIfNoEntry: false }); + } catch { + continue; + } + if (stats === undefined) { + continue; + } + const stale = stats.mtime.getTime() < Date.now() - OWNER_STALE_MS; + if (stale || !isProcessAlive(pid)) { + rmSync(clientPath, { force: true }); + continue; + } + count++; + } + return count; +} diff --git a/packages/miniflare/src/workers/core/storage-owner-server.worker.ts b/packages/miniflare/src/workers/core/storage-owner-server.worker.ts new file mode 100644 index 00000000000..22709ef9677 --- /dev/null +++ b/packages/miniflare/src/workers/core/storage-owner-server.worker.ts @@ -0,0 +1,111 @@ +import { newWorkersRpcResponse } from "capnweb"; +import { SharedHeaders } from "../shared/constants"; + +// Owner-side server for the shared "central storage owner". Runs in the +// detached owner process and exposes the owner's local storage services over +// HTTP/WebSocket using the same wire protocol as the remote-bindings proxy +// server (`packages/wrangler/templates/remoteBindings/ProxyServerWorker.ts`). +// +// Clients route their storage bindings through the shared remote-proxy client +// worker (`remote-proxy-client.worker.ts`) pointed at this server's address. +// Two transports share the one boundary: +// - Fetch (KV / R2 / D1 / Images): a plain HTTP request carrying +// - `MF-Binding`: a ":" key. The type selects the matching +// object-entry service (bound by `Miniflare`); the id is forwarded via +// `MF-Storage-Owner-Namespace` so a single shared entry service resolves +// any resource — including ids only declared by other clients — without +// a per-id binding. +// - `MF-URL`: the original storage-protocol URL. +// - `MF-Header-*`: the original request headers. +// - JSRPC (Streams / Secrets Store): a capnweb WebSocket session, selected by +// the `Upgrade` header + `MF-Binding` query param, dispatched onto the +// bound entrypoint service. + +type Env = Record; + +class BindingError extends Error {} + +// Fetch types (KV/R2/D1/Images) are served by one generic entry service per +// type; the resource id is forwarded separately so a single binding serves any +// id. Returns the entry fetcher and the resource id. +function getFetchBinding( + request: Request, + env: Env +): { fetcher: Fetcher; id: string } { + const bindingKey = request.headers.get("MF-Binding"); + if (!bindingKey) { + throw new BindingError("missing MF-Binding"); + } + const sep = bindingKey.indexOf(":"); + const type = sep === -1 ? bindingKey : bindingKey.slice(0, sep); + const id = sep === -1 ? "" : bindingKey.slice(sep + 1); + const fetcher = env[type]; + if (!fetcher) { + throw new BindingError(`storage type "${type}" not served by owner`); + } + return { fetcher, id }; +} + +function getRpcBinding(request: Request, env: Env): Fetcher { + // For RPC the client (`makeRemoteProxyStub`) puts the binding key in the URL. + const bindingKey = new URL(request.url).searchParams.get("MF-Binding"); + if (!bindingKey) { + throw new BindingError("missing MF-Binding"); + } + const target = env[bindingKey]; + if (!target) { + throw new BindingError( + `storage binding "${bindingKey}" not served by owner` + ); + } + return target; +} + +function isJSRPCBinding(request: Request): boolean { + return ( + request.headers.has("Upgrade") && + new URL(request.url).searchParams.has("MF-Binding") + ); +} + +export default { + async fetch(request, env) { + try { + if (isJSRPCBinding(request)) { + return await newWorkersRpcResponse( + request, + getRpcBinding(request, env) + ); + } + + const { fetcher, id } = getFetchBinding(request, env); + + const originalHeaders = new Headers(); + for (const [name, value] of request.headers) { + if (name.startsWith("mf-header-")) { + originalHeaders.set(name.slice("mf-header-".length), value); + } else if (name === "upgrade") { + // The `Upgrade` header needs to be special-cased to prevent: + // TypeError: Worker tried to return a WebSocket in a response to + // a request which did not contain the header "Upgrade: websocket" + originalHeaders.set(name, value); + } + } + // Tell the shared object-entry service which resource this op targets. + originalHeaders.set(SharedHeaders.STORAGE_OWNER_NAMESPACE, id); + + return await fetcher.fetch( + request.headers.get("MF-URL") ?? "http://example.com", + new Request(request, { + redirect: "manual", + headers: originalHeaders, + }) + ); + } catch (e) { + if (e instanceof BindingError) { + return new Response(e.message, { status: 400 }); + } + return new Response((e as Error).message, { status: 500 }); + } + }, +} satisfies ExportedHandler; diff --git a/packages/miniflare/src/workers/shared/constants.ts b/packages/miniflare/src/workers/shared/constants.ts index f30006a2319..f8dda0108f3 100644 --- a/packages/miniflare/src/workers/shared/constants.ts +++ b/packages/miniflare/src/workers/shared/constants.ts @@ -1,5 +1,9 @@ export const SharedHeaders = { LOG_LEVEL: "MF-Log-Level", + // Resource id (KV namespace / R2 bucket / D1 database) supplied per-request + // by the storage-owner server so a single shared object-entry service can + // resolve any resource without a per-id binding. Read by object-entry. + STORAGE_OWNER_NAMESPACE: "MF-Storage-Owner-Namespace", } as const; export const SharedBindings = { diff --git a/packages/miniflare/src/workers/shared/object-entry.worker.ts b/packages/miniflare/src/workers/shared/object-entry.worker.ts index 5ce89db88f8..5fb9c27cbd1 100644 --- a/packages/miniflare/src/workers/shared/object-entry.worker.ts +++ b/packages/miniflare/src/workers/shared/object-entry.worker.ts @@ -1,4 +1,4 @@ -import { SharedBindings } from "./constants"; +import { SharedBindings, SharedHeaders } from "./constants"; import type { MiniflareDurableObjectCf } from "./object.worker"; interface Props { @@ -13,11 +13,15 @@ interface Env { export default >{ async fetch(request, env, ctx) { - // Prefer the namespace passed at runtime via `ctx.props` (props-based - // model: one entry service serves many namespaces). Fall back to the - // static binding for callers that still bake the namespace in. + // Resolve the namespace, in priority order: + // 1. `ctx.props` — props-based model: one entry service serves many + // namespaces (local bindings). + // 2. The storage-owner header — the shared storage owner serves any + // resource id supplied per-request (it can't bake props per id). + // 3. The static binding — legacy per-resource model. const name = ctx.props[SharedBindings.TEXT_NAMESPACE] ?? + request.headers.get(SharedHeaders.STORAGE_OWNER_NAMESPACE) ?? env[SharedBindings.TEXT_NAMESPACE]; if (name === undefined) { throw new Error( From 97057249ed1211249e3f8e038d1f8aef1416e4c5 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 30 Jun 2026 14:45:18 +0100 Subject: [PATCH 34/37] [miniflare] Add storage-owner tests and high-concurrency oracle Unit tests for owner election, presence, routing and teardown, plus a high -concurrency oracle that runs multiple instances against one persist root and asserts KV/R2/D1/Streams/Secrets/Images sharing with exactly-correct results under contention. The oracle runs under MINIFLARE_TEST_SHARED_OWNER (added to turbo.json globalPassThroughEnv) so it gates both feature-on and baseline modes. --- .changeset/shared-storage-owner.md | 9 + .../miniflare/test/persist-sharing.spec.ts | 2699 +++++++++++++++++ packages/miniflare/test/storage-owner.spec.ts | 680 +++++ turbo.json | 1 + 4 files changed, 3389 insertions(+) create mode 100644 .changeset/shared-storage-owner.md create mode 100644 packages/miniflare/test/persist-sharing.spec.ts create mode 100644 packages/miniflare/test/storage-owner.spec.ts diff --git a/.changeset/shared-storage-owner.md b/.changeset/shared-storage-owner.md new file mode 100644 index 00000000000..ba62399f008 --- /dev/null +++ b/.changeset/shared-storage-owner.md @@ -0,0 +1,9 @@ +--- +"miniflare": minor +--- + +Add experimental `unsafeSharedStorageOwner` option to share local storage across processes + +When several Miniflare instances run against the same persist root (for example multiple `wrangler dev` / `vite dev` sessions), each one opens the same SQLite and blob files, which can produce cross-process `SQLITE_BUSY` errors under concurrent access. With `unsafeSharedStorageOwner` enabled, a single detached "owner" process opens the storage files and every other instance routes its KV, R2, D1, Images, Streams and Secrets Store operations to that owner over the remote-bindings boundary, so exactly one process performs storage I/O. The owner is elected and spawned automatically, publishes its address to the persist root, and self-terminates once no instances remain. + +The option is off by default. Cache, Durable Objects and Workflows are intentionally kept per-instance rather than routed, so processes never contend on those databases. diff --git a/packages/miniflare/test/persist-sharing.spec.ts b/packages/miniflare/test/persist-sharing.spec.ts new file mode 100644 index 00000000000..2b265e31cc6 --- /dev/null +++ b/packages/miniflare/test/persist-sharing.spec.ts @@ -0,0 +1,2699 @@ +// Validates whether the existing `defaultPersistRoot` option is sufficient to +// make storage bindings behave as singletons across multiple Miniflare +// instances (e.g. separate `wrangler dev` / `vite dev` sessions). +// +// Each `Miniflare` instance spawns its own `workerd` subprocess, so two +// instances in the same Node process still exercise genuine cross-process +// access to the same on-disk SQLite databases and blob files. +// +// These tests deliberately use NO special concurrency handling (no busy-timeout +// retries, no sticky blobs, no owner election). They characterise the +// out-of-the-box behaviour of a shared `defaultPersistRoot`. + +import { Miniflare } from "miniflare"; +import { afterEach, describe, test } from "vitest"; +import { useTmp } from "./test-shared"; +import type { MiniflareOptions } from "miniflare"; + +const COMPAT_DATE = "2024-11-01"; + +// When set, route storage through a single shared owner process (all instances +// sharing a `defaultPersistRoot` elect one owner). Tests branch on this where +// shared-owner semantics intentionally differ from plain `defaultPersistRoot` +// (notably: cache is kept per-instance, not shared). +const sharedOwner = process.env.MINIFLARE_TEST_SHARED_OWNER === "1"; + +const NOOP_SCRIPT = `export default { async fetch() { return new Response("ok"); } };`; + +interface MakeOptions { + root: string; + name?: string; + kvId?: string; + r2Bucket?: string; + d1Id?: string; + script?: string; + durableObjects?: Record; + /** Explicit `kvPersist` path, to test precedence over `defaultPersistRoot`. */ + kvPersist?: string; + /** Configure a Stream binding (`STREAM`). */ + stream?: boolean; + /** Configure an Images binding (`IMAGES`). */ + images?: boolean; + /** Configure a Secrets Store secret binding (`SECRET`). */ + secret?: { store_id: string; secret_name: string }; +} + +const instances: Miniflare[] = []; + +function make({ + root, + name, + kvId, + r2Bucket, + d1Id, + script, + durableObjects, + kvPersist, + stream, + images, + secret, +}: MakeOptions): Miniflare { + const opts: MiniflareOptions = { + name, + defaultPersistRoot: root, + modules: true, + script: script ?? NOOP_SCRIPT, + compatibilityDate: COMPAT_DATE, + kvNamespaces: kvId !== undefined ? { KV: kvId } : {}, + r2Buckets: r2Bucket !== undefined ? { R2: r2Bucket } : {}, + d1Databases: d1Id !== undefined ? { DB: d1Id } : {}, + durableObjects, + cache: true, + kvPersist, + ...(stream ? { stream: { binding: "STREAM" } } : {}), + ...(images ? { images: { binding: "IMAGES" } } : {}), + ...(secret ? { secretsStoreSecrets: { SECRET: secret } } : {}), + unsafeSharedStorageOwner: sharedOwner || undefined, + unsafeDevRegistryPath: sharedOwner ? `${root}/.registry` : undefined, + }; + const mf = new Miniflare(opts); + instances.push(mf); + return mf; +} + +afterEach(async () => { + // Dispose in reverse creation order; tolerate already-disposed instances. + const toDispose = instances.splice(0, instances.length).reverse(); + for (const mf of toDispose) { + try { + await mf.dispose(); + } catch {} + } +}); + +describe.sequential("defaultPersistRoot sharing", () => { + // --------------------------------------------------------------- Host API + // One smoke test that the documented host-side helper API (getKVNamespace, + // etc.) also observes shared storage across instances. The worker-driven + // tests below exercise the real binding code paths in depth. + describe("host API access", () => { + test("host-side getKVNamespace in B sees a write from A", async ({ + expect, + }) => { + const root = await useTmp(); + const a = make({ root, name: "a", kvId: "ns" }); + const b = make({ root, name: "b", kvId: "ns" }); + await a.ready; + await b.ready; + const kvA = await a.getKVNamespace("KV"); + const kvB = await b.getKVNamespace("KV"); + + await kvA.put("key", "value-from-a"); + expect(await kvB.get("key")).toBe("value-from-a"); + }); + }); + + // --------------------------------------------------------------- Isolation + // Storage must be shared ONLY when the same id/path is used. These guard the + // keying / persist-path scheme against accidental collisions or over-sharing. + describe("isolation (not shared)", () => { + test("KV: different namespace ids are not shared", async ({ expect }) => { + const root = await useTmp(); + const a = make({ root, name: "a", kvId: "ns-a" }); + const b = make({ root, name: "b", kvId: "ns-b" }); + await a.ready; + await b.ready; + const kvA = await a.getKVNamespace("KV"); + const kvB = await b.getKVNamespace("KV"); + + await kvA.put("k", "v"); + expect(await kvB.get("k")).toBe(null); + }); + + test("KV: different defaultPersistRoot is not shared", async ({ + expect, + }) => { + const rootA = await useTmp(); + const rootB = await useTmp(); + const a = make({ root: rootA, name: "a", kvId: "ns" }); + const b = make({ root: rootB, name: "b", kvId: "ns" }); + await a.ready; + await b.ready; + const kvA = await a.getKVNamespace("KV"); + const kvB = await b.getKVNamespace("KV"); + + await kvA.put("k", "v"); + expect(await kvB.get("k")).toBe(null); + }); + + test("KV: explicit kvPersist overrides defaultPersistRoot (precedence)", async ({ + expect, + }) => { + const root = await useTmp(); + const kvOnly = await useTmp(); + // A uses an explicit kvPersist path; B uses the shared default root. + const a = make({ root, name: "a", kvId: "ns", kvPersist: kvOnly }); + const b = make({ root, name: "b", kvId: "ns" }); + await a.ready; + await b.ready; + const kvA = await a.getKVNamespace("KV"); + const kvB = await b.getKVNamespace("KV"); + + await kvA.put("k", "v"); + // Not shared: A wrote to its own kvPersist location. + expect(await kvB.get("k")).toBe(null); + }); + + test("R2: different bucket names are not shared", async ({ expect }) => { + const root = await useTmp(); + const a = make({ root, name: "a", r2Bucket: "bucket-a" }); + const b = make({ root, name: "b", r2Bucket: "bucket-b" }); + await a.ready; + await b.ready; + const r2A = await a.getR2Bucket("R2"); + const r2B = await b.getR2Bucket("R2"); + + await r2A.put("obj", "data"); + expect(await r2B.head("obj")).toBe(null); + }); + + test("D1: different database ids are not shared", async ({ expect }) => { + const root = await useTmp(); + const a = make({ root, name: "a", d1Id: "db-a" }); + const b = make({ root, name: "b", d1Id: "db-b" }); + await a.ready; + await b.ready; + const dbA = await a.getD1Database("DB"); + const dbB = await b.getD1Database("DB"); + + await dbA.exec("CREATE TABLE t (x INTEGER);"); + await dbA.prepare("INSERT INTO t (x) VALUES (1)").run(); + // B's database should not have the table at all. + await expect(dbB.prepare("SELECT * FROM t").all()).rejects.toThrow(); + }); + }); + + // ---------------------------------------------------------- Runtime code paths + describe("runtime code paths", () => { + const STORAGE_WORKER_SCRIPT = ` + export default { + async fetch(request, env) { + const url = new URL(request.url); + const key = url.searchParams.get("key") ?? "key"; + if (url.pathname === "/kv") { + if (request.method === "PUT") { + await env.KV.put(key, await request.text(), { + metadata: { source: url.searchParams.get("source") ?? "worker" }, + }); + return new Response("ok"); + } + const result = await env.KV.getWithMetadata(key); + return Response.json(result); + } + + if (url.pathname === "/r2") { + if (request.method === "PUT") { + await env.R2.put(key, await request.text(), { + customMetadata: { source: url.searchParams.get("source") ?? "worker" }, + }); + return new Response("ok"); + } + const object = await env.R2.get(key); + if (object === null) { + return Response.json(null); + } + return Response.json({ + value: await object.text(), + customMetadata: object.customMetadata, + }); + } + + if (url.pathname === "/d1/init") { + await env.DB.exec("CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, name TEXT)"); + return new Response("ok"); + } + if (url.pathname === "/d1/insert") { + await env.DB.prepare("INSERT INTO items (name) VALUES (?)") + .bind(url.searchParams.get("name")) + .run(); + return new Response("ok"); + } + if (url.pathname === "/d1") { + return Response.json(await env.DB.prepare("SELECT name FROM items ORDER BY name").all()); + } + + if (url.pathname === "/cache") { + const cacheUrl = url.searchParams.get("url") ?? "http://example.com/cache"; + if (request.method === "PUT") { + await caches.default.put( + cacheUrl, + new Response(await request.text(), { + headers: { "Cache-Control": "max-age=3600" }, + }) + ); + return new Response("ok"); + } + const response = await caches.default.match(cacheUrl); + return new Response(response === undefined ? "" : await response.text()); + } + + return new Response("not found", { status: 404 }); + } + }; + `; + + test("Worker handlers share KV, R2, D1 and Cache state", async ({ + expect, + }) => { + const root = await useTmp(); + const a = make({ + root, + name: "a", + kvId: "ns", + r2Bucket: "bucket", + d1Id: "db", + script: STORAGE_WORKER_SCRIPT, + }); + const b = make({ + root, + name: "b", + kvId: "ns", + r2Bucket: "bucket", + d1Id: "db", + script: STORAGE_WORKER_SCRIPT, + }); + await a.ready; + await b.ready; + + await ( + await a.dispatchFetch("http://x/kv?key=from-worker&source=a", { + method: "PUT", + body: "kv-value", + }) + ).text(); + const kvResult = (await ( + await b.dispatchFetch("http://x/kv?key=from-worker") + ).json()) as { value: string | null; metadata: unknown }; + expect(kvResult).toMatchObject({ + value: "kv-value", + metadata: { source: "a" }, + }); + + await ( + await a.dispatchFetch("http://x/r2?key=from-worker&source=a", { + method: "PUT", + body: "r2-value", + }) + ).text(); + const r2Result = (await ( + await b.dispatchFetch("http://x/r2?key=from-worker") + ).json()) as { value: string; customMetadata: unknown }; + expect(r2Result).toEqual({ + value: "r2-value", + customMetadata: { source: "a" }, + }); + + await (await a.dispatchFetch("http://x/d1/init")).text(); + await (await a.dispatchFetch("http://x/d1/insert?name=a")).text(); + await (await b.dispatchFetch("http://x/d1/insert?name=b")).text(); + const d1Result = (await ( + await a.dispatchFetch("http://x/d1") + ).json()) as { results: { name: string }[] }; + expect(d1Result.results).toEqual([{ name: "a" }, { name: "b" }]); + + await ( + await a.dispatchFetch( + "http://x/cache?url=http://example.com/from-worker", + { + method: "PUT", + body: "cache-value", + } + ) + ).text(); + const cacheFromB = await ( + await b.dispatchFetch( + "http://x/cache?url=http://example.com/from-worker" + ) + ).text(); + // Cache is intentionally NOT shared through the storage owner: each + // instance keeps its own local cache (evictions are recoverable), so in + // shared-owner mode B does not observe A's cache write. + expect(cacheFromB).toBe(sharedOwner ? "" : "cache-value"); + }); + + // Regression test: once two instances share a persist root, concurrent writes + // from user Worker code used to surface as 500 responses from the KV simulator + // (cross-process SQLITE_BUSY / read-only fallback). They must now all succeed. + test("Worker handlers can concurrently write shared KV without failed responses", async ({ + expect, + }) => { + const root = await useTmp(); + const a = make({ + root, + name: "a", + kvId: "ns", + script: STORAGE_WORKER_SCRIPT, + }); + const b = make({ + root, + name: "b", + kvId: "ns", + script: STORAGE_WORKER_SCRIPT, + }); + await a.ready; + await b.ready; + + const N = 50; + const writes: Promise<{ key: string; status: number; body: string }>[] = + []; + for (let i = 0; i < N; i++) { + const keyA = `worker-a-${i}`; + const keyB = `worker-b-${i}`; + writes.push( + a + .dispatchFetch(`http://x/kv?key=${keyA}&source=a`, { + method: "PUT", + body: "v", + }) + .then(async (response) => ({ + key: keyA, + status: response.status, + body: await response.text(), + })) + ); + writes.push( + b + .dispatchFetch(`http://x/kv?key=${keyB}&source=b`, { + method: "PUT", + body: "v", + }) + .then(async (response) => ({ + key: keyB, + status: response.status, + body: await response.text(), + })) + ); + } + + const results = await Promise.all(writes); + expect(results.filter((result) => result.status !== 200)).toEqual([]); + + for (const { key } of results) { + const result = (await ( + await b.dispatchFetch(`http://x/kv?key=${key}`) + ).json()) as { value: string | null }; + expect(result.value).toBe("v"); + } + }); + + const DO_WRITES_STORAGE_SCRIPT = ` + export class StorageWriter { + constructor(state, env) { + this.env = env; + } + + async fetch(request) { + const url = new URL(request.url); + const key = url.searchParams.get("key") ?? "from-do"; + + if (url.pathname === "/do/kv") { + if (request.method === "PUT") { + await this.env.KV.put(key, await request.text()); + return new Response("ok"); + } + return new Response((await this.env.KV.get(key)) ?? ""); + } + + if (url.pathname === "/do/r2") { + if (request.method === "PUT") { + await this.env.R2.put(key, await request.text()); + return new Response("ok"); + } + const object = await this.env.R2.get(key); + return new Response(object === null ? "" : await object.text()); + } + + if (url.pathname === "/do/d1/init") { + await this.env.DB.exec("CREATE TABLE IF NOT EXISTS do_items (id INTEGER PRIMARY KEY, name TEXT)"); + return new Response("ok"); + } + if (url.pathname === "/do/d1/insert") { + await this.env.DB.prepare("INSERT INTO do_items (name) VALUES (?)") + .bind(url.searchParams.get("name")) + .run(); + return new Response("ok"); + } + if (url.pathname === "/do/d1") { + return Response.json(await this.env.DB.prepare("SELECT name FROM do_items ORDER BY name").all()); + } + + if (request.method === "PUT") { + await this.env.KV.put(key, await request.text()); + return new Response("ok"); + } + return new Response((await this.env.KV.get(key)) ?? ""); + } + } + + export default { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname.startsWith("/do")) { + const id = env.WRITER.idFromName("writer"); + return env.WRITER.get(id).fetch(request); + } + if (url.pathname === "/r2") { + const object = await env.R2.get(url.searchParams.get("key") ?? "from-do"); + return new Response(object === null ? "" : await object.text()); + } + if (url.pathname === "/d1") { + return Response.json(await env.DB.prepare("SELECT name FROM do_items ORDER BY name").all()); + } + const key = url.searchParams.get("key") ?? "from-do"; + return new Response((await env.KV.get(key)) ?? ""); + } + }; + `; + + test("Durable Object code can write shared KV, R2 and D1 read by another instance", async ({ + expect, + }) => { + const root = await useTmp(); + const a = make({ + root, + name: "a", + kvId: "ns", + r2Bucket: "bucket", + d1Id: "db", + script: DO_WRITES_STORAGE_SCRIPT, + durableObjects: { WRITER: "StorageWriter" }, + }); + const b = make({ + root, + name: "b", + kvId: "ns", + r2Bucket: "bucket", + d1Id: "db", + script: DO_WRITES_STORAGE_SCRIPT, + durableObjects: { WRITER: "StorageWriter" }, + }); + await a.ready; + await b.ready; + + await ( + await a.dispatchFetch("http://x/do/kv?key=from-do", { + method: "PUT", + body: "written-by-do", + }) + ).text(); + + expect( + await (await b.dispatchFetch("http://x/?key=from-do")).text() + ).toBe("written-by-do"); + expect( + await (await b.dispatchFetch("http://x/do/kv?key=from-do")).text() + ).toBe("written-by-do"); + + await ( + await a.dispatchFetch("http://x/do/r2?key=from-do", { + method: "PUT", + body: "r2-written-by-do", + }) + ).text(); + expect( + await (await b.dispatchFetch("http://x/r2?key=from-do")).text() + ).toBe("r2-written-by-do"); + + await (await a.dispatchFetch("http://x/do/d1/init")).text(); + await (await a.dispatchFetch("http://x/do/d1/insert?name=do-a")).text(); + const d1Result = (await ( + await b.dispatchFetch("http://x/d1") + ).json()) as { results: { name: string }[] }; + expect(d1Result.results).toEqual([{ name: "do-a" }]); + }); + + // Same regression as above, but through a user Durable Object writing to a + // shared KV binding. This exercises user code -> DO -> binding -> simulator + // instead of direct Worker -> binding -> simulator calls. + test("Durable Object handlers can concurrently write shared KV without failed responses", async ({ + expect, + }) => { + const root = await useTmp(); + const a = make({ + root, + name: "a", + kvId: "ns", + script: DO_WRITES_STORAGE_SCRIPT, + durableObjects: { WRITER: "StorageWriter" }, + }); + const b = make({ + root, + name: "b", + kvId: "ns", + script: DO_WRITES_STORAGE_SCRIPT, + durableObjects: { WRITER: "StorageWriter" }, + }); + await a.ready; + await b.ready; + + const N = 50; + const writes: Promise<{ key: string; status: number; body: string }>[] = + []; + for (let i = 0; i < N; i++) { + const keyA = `do-a-${i}`; + const keyB = `do-b-${i}`; + writes.push( + a + .dispatchFetch(`http://x/do/kv?key=${keyA}`, { + method: "PUT", + body: "v", + }) + .then(async (response) => ({ + key: keyA, + status: response.status, + body: await response.text(), + })) + ); + writes.push( + b + .dispatchFetch(`http://x/do/kv?key=${keyB}`, { + method: "PUT", + body: "v", + }) + .then(async (response) => ({ + key: keyB, + status: response.status, + body: await response.text(), + })) + ); + } + + const results = await Promise.all(writes); + expect(results.filter((result) => result.status !== 200)).toEqual([]); + + for (const { key } of results) { + expect( + await (await b.dispatchFetch(`http://x/?key=${key}`)).text() + ).toBe("v"); + } + }); + }); + + // ------------------------------------------- Worker-driven storage edge cases + // These exercise reads and writes performed entirely *inside* Worker code (via + // the `env.KV` / `env.R2` / `env.DB` bindings and the `caches` API), rather + // than through Miniflare's host-side helper APIs. This goes through the real + // runtime binding -> simulator -> shared SQLite path. All operations are + // sequenced (A completes before B reads) so they characterise cross-process + // visibility, not concurrency (which is covered separately above). + describe("worker-driven storage edge cases", () => { + const EDGE_WORKER_SCRIPT = ` + export default { + async fetch(request, env) { + const url = new URL(request.url); + const p = url.pathname; + const q = url.searchParams; + const key = q.get("key") ?? "key"; + + // -------------------------------------------------------- KV + if (p === "/kv/put") { + const opts = {}; + const meta = q.get("meta"); + if (meta) opts.metadata = JSON.parse(meta); + const ttl = q.get("ttl"); + if (ttl) opts.expirationTtl = Number(ttl); + await env.KV.put(key, await request.text(), opts); + return new Response("ok"); + } + if (p === "/kv/get") { + const res = await env.KV.getWithMetadata(key); + return Response.json({ value: res.value, metadata: res.metadata }); + } + if (p === "/kv/delete") { + await env.KV.delete(key); + return new Response("ok"); + } + if (p === "/kv/list") { + const res = await env.KV.list({ prefix: q.get("prefix") ?? undefined }); + return Response.json({ keys: res.keys.map((k) => k.name).sort() }); + } + if (p === "/kv/ryow") { + // Read-your-writes within a single request. + await env.KV.put(key, await request.text()); + return new Response((await env.KV.get(key)) ?? ""); + } + + // -------------------------------------------------------- R2 + if (p === "/r2/put") { + await env.R2.put(key, await request.text(), { + httpMetadata: { contentType: q.get("ct") ?? "text/plain" }, + customMetadata: { src: q.get("src") ?? "x" }, + }); + return new Response("ok"); + } + if (p === "/r2/get") { + const obj = await env.R2.get(key); + if (obj === null) return Response.json(null); + return Response.json({ + value: await obj.text(), + size: obj.size, + contentType: obj.httpMetadata?.contentType ?? null, + customMetadata: obj.customMetadata ?? null, + }); + } + if (p === "/r2/delete") { + await env.R2.delete(key); + return new Response("ok"); + } + if (p === "/r2/list") { + const res = await env.R2.list({ prefix: q.get("prefix") ?? undefined }); + return Response.json({ keys: res.objects.map((o) => o.key).sort() }); + } + + // -------------------------------------------------------- D1 + if (p === "/d1/exec") { + await env.DB.exec(await request.text()); + return new Response("ok"); + } + if (p === "/d1/insert") { + await env.DB.prepare("INSERT INTO edge (name, src) VALUES (?, ?)") + .bind(q.get("name"), q.get("src") ?? "x") + .run(); + return new Response("ok"); + } + if (p === "/d1/batch") { + const names = (q.get("names") ?? "").split(",").filter(Boolean); + await env.DB.batch( + names.map((n) => + env.DB.prepare("INSERT INTO edge (name, src) VALUES (?, ?)").bind(n, q.get("src") ?? "x") + ) + ); + return new Response("ok"); + } + if (p === "/d1/all") { + const { results } = await env.DB.prepare( + "SELECT name, src FROM edge ORDER BY name" + ).all(); + return Response.json(results); + } + if (p === "/d1/count") { + const row = await env.DB.prepare("SELECT COUNT(*) AS c FROM edge").first(); + return Response.json({ c: row.c }); + } + + // ----------------------------------------------------- Cache + if (p === "/cache/put") { + const cache = q.get("name") ? await caches.open(q.get("name")) : caches.default; + await cache.put( + q.get("url"), + new Response(await request.text(), { + headers: { "Cache-Control": "max-age=3600" }, + }) + ); + return new Response("ok"); + } + if (p === "/cache/get") { + const cache = q.get("name") ? await caches.open(q.get("name")) : caches.default; + const res = await cache.match(q.get("url")); + return new Response(res === undefined ? "" : await res.text()); + } + if (p === "/cache/delete") { + const cache = q.get("name") ? await caches.open(q.get("name")) : caches.default; + return new Response(String(await cache.delete(q.get("url")))); + } + + // ----- Mixed: multiple binding types in a single request ----- + if (p === "/mixed/write") { + const tag = q.get("tag") ?? "t"; + await env.KV.put("mixed:" + tag, "kv-" + tag); + await env.R2.put("mixed:" + tag, "r2-" + tag); + await env.DB.prepare("INSERT INTO edge (name, src) VALUES (?, ?)") + .bind("mixed:" + tag, "mixed") + .run(); + return new Response("ok"); + } + if (p === "/mixed/read") { + const tag = q.get("tag") ?? "t"; + const kv = await env.KV.get("mixed:" + tag); + const r2obj = await env.R2.get("mixed:" + tag); + const r2 = r2obj === null ? null : await r2obj.text(); + const row = await env.DB.prepare("SELECT name FROM edge WHERE name = ?") + .bind("mixed:" + tag) + .first(); + return Response.json({ kv, r2, d1: row ? row.name : null }); + } + + return new Response("not found", { status: 404 }); + } + }; + `; + + function makeEdge(root: string, name: string) { + return make({ + root, + name, + kvId: "ns", + r2Bucket: "bucket", + d1Id: "db", + script: EDGE_WORKER_SCRIPT, + }); + } + + async function text(mf: Miniflare, path: string, body?: string) { + const res = await mf.dispatchFetch(`http://x${path}`, { + method: body === undefined ? "GET" : "PUT", + body, + }); + return res.text(); + } + + async function json( + mf: Miniflare, + path: string, + body?: string + ): Promise { + const res = await mf.dispatchFetch(`http://x${path}`, { + method: body === undefined ? "GET" : "PUT", + body, + }); + return (await res.json()) as T; + } + + // ----------------------------------------------------------------- KV + test("KV: worker write in B is read by worker in A", async ({ expect }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text(b, "/kv/put?key=k", "from-b-worker"); + const got = await json<{ value: string | null }>(a, "/kv/get?key=k"); + expect(got.value).toBe("from-b-worker"); + }); + + test("KV: worker overwrite in A is observed by worker in B", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text(a, "/kv/put?key=k", "v1"); + expect( + (await json<{ value: string | null }>(b, "/kv/get?key=k")).value + ).toBe("v1"); + await text(a, "/kv/put?key=k", "v2"); + expect( + (await json<{ value: string | null }>(b, "/kv/get?key=k")).value + ).toBe("v2"); + }); + + test("KV: worker delete in B is observed by worker in A", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text(a, "/kv/put?key=k", "v"); + expect( + (await json<{ value: string | null }>(b, "/kv/get?key=k")).value + ).toBe("v"); + await text(b, "/kv/delete?key=k"); + expect( + (await json<{ value: string | null }>(a, "/kv/get?key=k")).value + ).toBe(null); + }); + + test("KV: worker-written metadata is read by worker in other instance", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text( + a, + `/kv/put?key=k&meta=${encodeURIComponent('{"hello":"world"}')}`, + "v" + ); + const got = await json<{ value: string | null; metadata: unknown }>( + b, + "/kv/get?key=k" + ); + expect(got.value).toBe("v"); + expect(got.metadata).toEqual({ hello: "world" }); + }); + + test("KV: worker list reflects keys written by workers in both instances", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text(a, "/kv/put?key=edge:a", "1"); + await text(b, "/kv/put?key=edge:b", "2"); + const listed = await json<{ keys: string[] }>(b, "/kv/list?prefix=edge:"); + expect(listed.keys).toEqual(["edge:a", "edge:b"]); + }); + + test("KV: read-your-writes within a single worker request, then visible cross-instance", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + // The same request both writes and reads back the value. + expect(await text(a, "/kv/ryow?key=k", "self-read")).toBe("self-read"); + // And it is durable for another process. + expect( + (await json<{ value: string | null }>(b, "/kv/get?key=k")).value + ).toBe("self-read"); + }); + + test("KV: worker write with expirationTtl is immediately visible cross-instance", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text(a, "/kv/put?key=k&ttl=3600", "ttl-value"); + expect( + (await json<{ value: string | null }>(b, "/kv/get?key=k")).value + ).toBe("ttl-value"); + }); + + // ----------------------------------------------------------------- R2 + test("R2: worker write in A (httpMetadata + customMetadata) read by worker in B", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text(a, "/r2/put?key=obj&ct=application/json&src=a", '{"x":1}'); + const got = await json<{ + value: string; + contentType: string | null; + customMetadata: Record | null; + }>(b, "/r2/get?key=obj"); + expect(got.value).toBe('{"x":1}'); + expect(got.contentType).toBe("application/json"); + expect(got.customMetadata).toEqual({ src: "a" }); + }); + + test("R2: worker write of a large body (blob store) read by worker in B", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + const big = "z".repeat(2 * 1024 * 1024); // 2 MiB + await text(a, "/r2/put?key=big", big); + const got = await json<{ value: string; size: number }>( + b, + "/r2/get?key=big" + ); + expect(got.size).toBe(big.length); + expect(got.value.length).toBe(big.length); + }); + + test("R2: worker delete in B is observed by worker in A", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text(a, "/r2/put?key=obj", "data"); + expect( + (await json<{ value: string } | null>(b, "/r2/get?key=obj"))?.value + ).toBe("data"); + await text(b, "/r2/delete?key=obj"); + expect(await json(a, "/r2/get?key=obj")).toBe(null); + }); + + test("R2: worker list reflects objects written by workers in both instances", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text(a, "/r2/put?key=p/a", "1"); + await text(b, "/r2/put?key=p/b", "2"); + const listed = await json<{ keys: string[] }>(a, "/r2/list?prefix=p/"); + expect(listed.keys).toEqual(["p/a", "p/b"]); + }); + + // ----------------------------------------------------------------- D1 + test("D1: schema + rows created by worker in A are queried by worker in B", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text( + a, + "/d1/exec", + "CREATE TABLE IF NOT EXISTS edge (id INTEGER PRIMARY KEY, name TEXT, src TEXT)" + ); + await text(a, "/d1/insert?name=row-a&src=a"); + const rows = await json<{ name: string; src: string }[]>(b, "/d1/all"); + expect(rows).toEqual([{ name: "row-a", src: "a" }]); + }); + + test("D1: worker batch insert in A is visible to worker in B", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text( + a, + "/d1/exec", + "CREATE TABLE IF NOT EXISTS edge (id INTEGER PRIMARY KEY, name TEXT, src TEXT)" + ); + await text(a, "/d1/batch?names=b1,b2,b3&src=a"); + expect((await json<{ c: number }>(b, "/d1/count")).c).toBe(3); + }); + + test("D1: rows inserted by workers in both instances are all visible", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text( + a, + "/d1/exec", + "CREATE TABLE IF NOT EXISTS edge (id INTEGER PRIMARY KEY, name TEXT, src TEXT)" + ); + await text(a, "/d1/insert?name=from-a&src=a"); + await text(b, "/d1/insert?name=from-b&src=b"); + const rows = await json<{ name: string; src: string }[]>(a, "/d1/all"); + expect(rows.map((r) => r.name)).toEqual(["from-a", "from-b"]); + }); + + // -------------------------------------------------------------- Cache + test("Cache: named cache populated by worker in A is matched by worker in B", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + const cacheUrl = "http://example.com/edge-named"; + await text( + a, + `/cache/put?name=edge&url=${encodeURIComponent(cacheUrl)}`, + "named!" + ); + // Cache is per-instance under the storage owner: B has its own local + // cache and does not observe A's write. + expect( + await text( + b, + `/cache/get?name=edge&url=${encodeURIComponent(cacheUrl)}` + ) + ).toBe(sharedOwner ? "" : "named!"); + }); + + test("Cache: worker delete in B is observed by worker in A", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + const cacheUrl = "http://example.com/edge-delete"; + await text(a, `/cache/put?url=${encodeURIComponent(cacheUrl)}`, "cached"); + if (sharedOwner) { + // Per-instance cache: B never sees A's write, so there is nothing for + // B to delete on A's behalf. Just assert the isolation. + expect( + await text(b, `/cache/get?url=${encodeURIComponent(cacheUrl)}`) + ).toBe(""); + return; + } + expect( + await text(b, `/cache/get?url=${encodeURIComponent(cacheUrl)}`) + ).toBe("cached"); + expect( + await text(b, `/cache/delete?url=${encodeURIComponent(cacheUrl)}`) + ).toBe("true"); + expect( + await text(a, `/cache/get?url=${encodeURIComponent(cacheUrl)}`) + ).toBe(""); + }); + + // ------------------------------------------------- Mixed / cross-type + test("Mixed: worker in A writes KV + R2 + D1 in one request, worker in B reads all", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text( + a, + "/d1/exec", + "CREATE TABLE IF NOT EXISTS edge (id INTEGER PRIMARY KEY, name TEXT, src TEXT)" + ); + await text(a, "/mixed/write?tag=one"); + const got = await json<{ + kv: string | null; + r2: string | null; + d1: string | null; + }>(b, "/mixed/read?tag=one"); + expect(got).toEqual({ + kv: "kv-one", + r2: "r2-one", + d1: "mixed:one", + }); + }); + + test("Mixed: the same key name in KV and R2 stays type-isolated across instances", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + // Write only to KV under a shared key name in A. + await text(a, "/kv/put?key=shared-name", "kv-only"); + // B sees it in KV... + expect( + (await json<{ value: string | null }>(b, "/kv/get?key=shared-name")) + .value + ).toBe("kv-only"); + // ...but the same name in R2 is independent and absent. + expect(await json(b, "/r2/get?key=shared-name")).toBe(null); + }); + }); + + // ----------------------------------- Worker-driven concurrency & stress tests + // Weirder edge cases and stress tests, all driven through Worker code (the real + // binding -> simulator -> shared SQLite path). These exercise genuine + // cross-process contention, and several assert correctness properties that + // only hold with proper SQLite WAL + busy-handling. They REQUIRE the workerd + // fixes for shared local-disk SQLite, so run them with a patched binary via + // MINIFLARE_WORKERD_PATH until those land in the pinned workerd. + describe("worker-driven concurrency & stress", () => { + const STRESS_WORKER_SCRIPT = ` + export default { + async fetch(request, env) { + const url = new URL(request.url); + const p = url.pathname; + const q = url.searchParams; + + // ---------------------------------------------------- KV + if (p === "/kv/put") { + await env.KV.put(q.get("key"), await request.text()); + return new Response("ok"); + } + if (p === "/kv/get") { + const v = await env.KV.get(q.get("key")); + return new Response(v === null ? "" : v); + } + if (p === "/kv/delete") { + await env.KV.delete(q.get("key")); + return new Response("ok"); + } + if (p === "/kv/append") { + // Deliberately non-atomic read-modify-write: demonstrates that + // lost updates are possible across processes without transactions. + const cur = (await env.KV.get(q.get("key"))) ?? ""; + await env.KV.put(q.get("key"), cur + q.get("c")); + return new Response("ok"); + } + if (p === "/kv/bulkput") { + const n = Number(q.get("n")); + const prefix = q.get("prefix"); + for (let i = 0; i < n; i++) { + await env.KV.put(prefix + String(i).padStart(5, "0"), "v"); + } + return new Response("ok"); + } + if (p === "/kv/listcount") { + const prefix = q.get("prefix") ?? undefined; + let cursor = undefined; + const names = new Set(); + for (;;) { + const res = await env.KV.list({ prefix, cursor, limit: 1000 }); + for (const k of res.keys) names.add(k.name); + if (res.list_complete) break; + cursor = res.cursor; + } + return Response.json({ count: names.size }); + } + if (p === "/kv/putbin") { + const len = Number(q.get("len")); + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) bytes[i] = i % 256; + await env.KV.put(q.get("key"), bytes); + return new Response("ok"); + } + if (p === "/kv/getbin") { + const buf = await env.KV.get(q.get("key"), "arrayBuffer"); + if (buf === null) return Response.json({ len: -1, ok: false }); + const bytes = new Uint8Array(buf); + let ok = true; + for (let i = 0; i < bytes.length; i++) { + if (bytes[i] !== i % 256) { + ok = false; + break; + } + } + return Response.json({ len: bytes.length, ok }); + } + + // ---------------------------------------------------- D1 + if (p === "/d1/exec") { + await env.DB.exec(await request.text()); + return new Response("ok"); + } + if (p === "/d1/incr") { + // Atomic, single-statement increment: must never lose updates, + // even under cross-process concurrency. + await env.DB.prepare( + "INSERT INTO counter (id, v) VALUES (?, 1) ON CONFLICT(id) DO UPDATE SET v = v + 1" + ) + .bind(q.get("key")) + .run(); + return new Response("ok"); + } + if (p === "/d1/value") { + const row = await env.DB.prepare("SELECT v FROM counter WHERE id = ?") + .bind(q.get("key")) + .first(); + return Response.json({ v: row ? row.v : null }); + } + if (p === "/d1/insert") { + await env.DB.prepare("INSERT INTO log (src) VALUES (?)") + .bind(q.get("src")) + .run(); + return new Response("ok"); + } + if (p === "/d1/bulkinsert") { + const n = Number(q.get("n")); + const stmt = env.DB.prepare("INSERT INTO log (src) VALUES (?)"); + const batch = []; + for (let i = 0; i < n; i++) batch.push(stmt.bind(q.get("src"))); + await env.DB.batch(batch); + return new Response("ok"); + } + if (p === "/d1/stats") { + const c = await env.DB.prepare("SELECT COUNT(*) AS c FROM log").first(); + const d = await env.DB.prepare( + "SELECT COUNT(DISTINCT id) AS d FROM log" + ).first(); + const m = await env.DB.prepare("SELECT MAX(id) AS m FROM log").first(); + return Response.json({ count: c.c, distinctIds: d.d, maxId: m.m }); + } + + // ---------------------------------------------------- R2 + if (p === "/r2/put") { + await env.R2.put(q.get("key"), await request.text()); + return new Response("ok"); + } + if (p === "/r2/get") { + const o = await env.R2.get(q.get("key")); + return new Response(o === null ? "" : await o.text()); + } + if (p === "/r2/putbig") { + await env.R2.put(q.get("key"), "x".repeat(Number(q.get("len")))); + return new Response("ok"); + } + if (p === "/r2/size") { + const o = await env.R2.get(q.get("key")); + return Response.json({ size: o === null ? -1 : o.size }); + } + + // ---------------------------------------------------- Cache + if (p === "/cache/put") { + await caches.default.put( + q.get("url"), + new Response(await request.text(), { + headers: { "Cache-Control": "max-age=3600" }, + }) + ); + return new Response("ok"); + } + if (p === "/cache/get") { + const res = await caches.default.match(q.get("url")); + return new Response(res === undefined ? "" : await res.text()); + } + + // ------------- Touch every binding type in one request ------------- + if (p === "/touchall") { + const tag = q.get("tag"); + await env.DB.exec( + "CREATE TABLE IF NOT EXISTS log (id INTEGER PRIMARY KEY AUTOINCREMENT, src TEXT)" + ); + await env.KV.put("touch:" + tag, "v"); + await env.R2.put("touch:" + tag, "v"); + await env.DB.prepare("INSERT INTO log (src) VALUES (?)").bind(tag).run(); + await caches.default.put( + "http://x/touch/" + tag, + new Response("v", { headers: { "Cache-Control": "max-age=3600" } }) + ); + return new Response("ok"); + } + + // ------------- D1: tagged big batch + tagged count ------------- + if (p === "/d1/bigbatch") { + const n = Number(q.get("n")); + const stmt = env.DB.prepare("INSERT INTO log (src) VALUES (?)"); + const batch = []; + for (let i = 0; i < n; i++) batch.push(stmt.bind(q.get("tag"))); + await env.DB.batch(batch); + return new Response("ok"); + } + if (p === "/d1/counttag") { + const row = await env.DB.prepare( + "SELECT COUNT(*) AS c FROM log WHERE src = ?" + ) + .bind(q.get("tag")) + .first(); + return Response.json({ c: row.c }); + } + + // ------------- D1: bank-transfer conservation invariant ------------- + if (p === "/bank/init") { + await env.DB.exec( + "CREATE TABLE IF NOT EXISTS accounts (id TEXT PRIMARY KEY, balance INTEGER)" + ); + await env.DB.prepare( + "INSERT OR IGNORE INTO accounts (id, balance) VALUES ('x', 1000), ('y', 1000)" + ).run(); + return new Response("ok"); + } + if (p === "/bank/transfer") { + const amt = Number(q.get("amt")); + // A single atomic batch (transaction): debit one, credit the other. + await env.DB.batch([ + env.DB.prepare("UPDATE accounts SET balance = balance - ? WHERE id = ?").bind( + amt, + q.get("from") + ), + env.DB.prepare("UPDATE accounts SET balance = balance + ? WHERE id = ?").bind( + amt, + q.get("to") + ), + ]); + return new Response("ok"); + } + if (p === "/bank/total") { + const row = await env.DB.prepare("SELECT SUM(balance) AS s FROM accounts").first(); + return Response.json({ total: row.s }); + } + + return new Response("not found", { status: 404 }); + } + }; + `; + + function makeStress(root: string, name: string) { + return make({ + root, + name, + kvId: "ns", + r2Bucket: "bucket", + d1Id: "db", + script: STRESS_WORKER_SCRIPT, + }); + } + + async function fire( + mf: Miniflare, + path: string, + body?: string + ): Promise { + const res = await mf.dispatchFetch(`http://x${path}`, { + method: body === undefined ? "GET" : "PUT", + body, + }); + await res.text(); + return res.status; + } + + async function bodyOf(mf: Miniflare, path: string): Promise { + return (await mf.dispatchFetch(`http://x${path}`)).text(); + } + + async function jsonOf(mf: Miniflare, path: string): Promise { + return (await (await mf.dispatchFetch(`http://x${path}`)).json()) as T; + } + + test("stress: four instances concurrently write distinct KV keys via workers", async ({ + expect, + }) => { + const root = await useTmp(); + const names = ["a", "b", "c", "d"]; + const mfs = names.map((n) => makeStress(root, n)); + await Promise.all(mfs.map((mf) => mf.ready)); + + const PER = 25; + const statuses = await Promise.all( + mfs.flatMap((mf, idx) => + Array.from({ length: PER }, (_unused, i) => + fire(mf, `/kv/put?key=k-${idx}-${i}`, "v") + ) + ) + ); + expect(statuses.filter((s) => s !== 200)).toEqual([]); + + // A fresh reader sees every key written by all four instances. + const reader = makeStress(root, "reader"); + await reader.ready; + const misses: string[] = []; + for (let idx = 0; idx < names.length; idx++) { + for (let i = 0; i < PER; i++) { + const key = `k-${idx}-${i}`; + if ((await bodyOf(reader, `/kv/get?key=${key}`)) !== "v") { + misses.push(key); + } + } + } + expect(misses).toEqual([]); + }); + + test("stress: concurrent writes to the SAME KV key never error and converge", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeStress(root, "a"); + const b = makeStress(root, "b"); + await a.ready; + await b.ready; + + const N = 50; + const ops: Promise[] = []; + const candidates = new Set(); + for (let i = 0; i < N; i++) { + const va = `a-${i}`; + const vb = `b-${i}`; + candidates.add(va); + candidates.add(vb); + ops.push(fire(a, "/kv/put?key=hot", va)); + ops.push(fire(b, "/kv/put?key=hot", vb)); + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + // After writes settle, both processes read the SAME, valid final value. + const fromA = await bodyOf(a, "/kv/get?key=hot"); + const fromB = await bodyOf(b, "/kv/get?key=hot"); + expect(fromA).toBe(fromB); + expect(candidates.has(fromA)).toBe(true); + }); + + test("correctness: concurrent atomic D1 increments across instances lose no updates", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeStress(root, "a"); + const b = makeStress(root, "b"); + await a.ready; + await b.ready; + await fire( + a, + "/d1/exec", + "CREATE TABLE IF NOT EXISTS counter (id TEXT PRIMARY KEY, v INTEGER)" + ); + + const PER = 50; + const ops: Promise[] = []; + for (let i = 0; i < PER; i++) { + ops.push(fire(a, "/d1/incr?key=hot")); + ops.push(fire(b, "/d1/incr?key=hot")); + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + // Each increment is a single atomic statement, so no updates are lost + // even though two processes raced -- the total must be exact. + expect((await jsonOf<{ v: number }>(a, "/d1/value?key=hot")).v).toBe( + PER * 2 + ); + expect((await jsonOf<{ v: number }>(b, "/d1/value?key=hot")).v).toBe( + PER * 2 + ); + }); + + test("hazard: concurrent non-atomic KV read-modify-write may lose updates (documented)", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeStress(root, "a"); + const b = makeStress(root, "b"); + await a.ready; + await b.ready; + + const PER = 30; + const ops: Promise[] = []; + for (let i = 0; i < PER; i++) { + ops.push(fire(a, "/kv/append?key=acc&c=a")); + ops.push(fire(b, "/kv/append?key=acc&c=b")); + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + // KV has no transactions, so read-modify-write races CAN lose updates: the + // final string is some valid interleaving no longer than the total number + // of appends. We only assert no corruption / no errors and a sane length. + const finalA = await bodyOf(a, "/kv/get?key=acc"); + const finalB = await bodyOf(b, "/kv/get?key=acc"); + expect(finalA).toBe(finalB); + expect(finalA.length).toBeGreaterThan(0); + expect(finalA.length).toBeLessThanOrEqual(PER * 2); + expect(/^[ab]*$/.test(finalA)).toBe(true); + }); + + test("correctness: concurrent D1 AUTOINCREMENT inserts keep unique ids and exact count", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeStress(root, "a"); + const b = makeStress(root, "b"); + const c = makeStress(root, "c"); + await Promise.all([a.ready, b.ready, c.ready]); + await fire( + a, + "/d1/exec", + "CREATE TABLE IF NOT EXISTS log (id INTEGER PRIMARY KEY AUTOINCREMENT, src TEXT)" + ); + + const PER = 30; + const writers: [Miniflare, string][] = [ + [a, "a"], + [b, "b"], + [c, "c"], + ]; + const ops: Promise[] = []; + for (const [mf, src] of writers) { + for (let i = 0; i < PER; i++) { + ops.push(fire(mf, `/d1/insert?src=${src}`)); + } + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + const total = PER * writers.length; + const stats = await jsonOf<{ + count: number; + distinctIds: number; + maxId: number; + }>(a, "/d1/stats"); + expect(stats.count).toBe(total); + expect(stats.distinctIds).toBe(total); + expect(stats.maxId).toBe(total); + }); + + test("edge: KV list pagination across instances returns every key (>1000)", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeStress(root, "a"); + const b = makeStress(root, "b"); + await a.ready; + await b.ready; + + // Each instance bulk-writes 600 keys under a shared prefix. + expect(await fire(a, "/kv/bulkput?prefix=page:a-&n=600")).toBe(200); + expect(await fire(b, "/kv/bulkput?prefix=page:b-&n=600")).toBe(200); + + // Listing (with cursor paging) from either instance sees all 1200. + expect( + (await jsonOf<{ count: number }>(a, "/kv/listcount?prefix=page:")).count + ).toBe(1200); + expect( + (await jsonOf<{ count: number }>(b, "/kv/listcount?prefix=page:")).count + ).toBe(1200); + }); + + test("edge: binary KV values with NUL/high bytes round-trip via workers cross-instance", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeStress(root, "a"); + const b = makeStress(root, "b"); + await a.ready; + await b.ready; + + expect(await fire(a, "/kv/putbin?key=bin&len=4096")).toBe(200); + const got = await jsonOf<{ len: number; ok: boolean }>( + b, + "/kv/getbin?key=bin" + ); + expect(got.len).toBe(4096); + expect(got.ok).toBe(true); + }); + + test("edge: empty KV and R2 values round-trip (distinct from missing)", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeStress(root, "a"); + const b = makeStress(root, "b"); + await a.ready; + await b.ready; + + await fire(a, "/kv/put?key=empty", ""); + await fire(a, "/r2/put?key=empty", ""); + // Empty string is a real value, not . + expect(await bodyOf(b, "/kv/get?key=empty")).toBe(""); + expect(await bodyOf(b, "/r2/get?key=empty")).toBe(""); + // A genuinely-missing key is still . + expect(await bodyOf(b, "/kv/get?key=missing")).toBe(""); + }); + + test("edge: an instance restart mid-stream keeps and continues shared writes", async ({ + expect, + }) => { + const root = await useTmp(); + let a = makeStress(root, "a"); + const b = makeStress(root, "b"); + await a.ready; + await b.ready; + + await fire(a, "/kv/put?key=k1", "from-a1"); + await fire(b, "/kv/put?key=k2", "from-b"); + + // Tear down A while B keeps the shared database open, then bring up a + // fresh A pointed at the same persistence dir (cold reopen of an + // actively-held WAL database by a new process). + await a.dispose(); + a = makeStress(root, "a"); + await a.ready; + + expect(await bodyOf(a, "/kv/get?key=k1")).toBe("from-a1"); + expect(await bodyOf(a, "/kv/get?key=k2")).toBe("from-b"); + await fire(a, "/kv/put?key=k3", "from-a2"); + expect(await bodyOf(b, "/kv/get?key=k3")).toBe("from-a2"); + }); + + test("stress: interleaved concurrent multi-type writes from both instances", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeStress(root, "a"); + const b = makeStress(root, "b"); + await a.ready; + await b.ready; + await fire( + a, + "/d1/exec", + "CREATE TABLE IF NOT EXISTS log (id INTEGER PRIMARY KEY AUTOINCREMENT, src TEXT)" + ); + + const PER = 20; + const ops: Promise[] = []; + for (let i = 0; i < PER; i++) { + ops.push(fire(a, `/kv/put?key=mt:a:${i}`, "v")); + ops.push(fire(b, `/kv/put?key=mt:b:${i}`, "v")); + ops.push(fire(a, `/r2/put?key=mt:a:${i}`, "v")); + ops.push(fire(b, `/r2/put?key=mt:b:${i}`, "v")); + ops.push(fire(a, "/d1/insert?src=a")); + ops.push(fire(b, "/d1/insert?src=b")); + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + expect( + (await jsonOf<{ count: number }>(a, "/kv/listcount?prefix=mt:")).count + ).toBe(PER * 2); + expect((await jsonOf<{ count: number }>(b, "/d1/stats")).count).toBe( + PER * 2 + ); + expect(await bodyOf(b, `/r2/get?key=mt:a:0`)).toBe("v"); + expect(await bodyOf(a, `/r2/get?key=mt:b:0`)).toBe("v"); + }); + + test("edge: rapid delete/recreate race on one KV key stays consistent", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeStress(root, "a"); + const b = makeStress(root, "b"); + await a.ready; + await b.ready; + + const N = 40; + const ops: Promise[] = []; + for (let i = 0; i < N; i++) { + ops.push(fire(a, "/kv/put?key=race", "v")); + ops.push(fire(b, "/kv/delete?key=race")); + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + // Whatever the interleaving, both processes must agree on the final state + // and it must be either the value or absent (never corrupt). + const fromA = await bodyOf(a, "/kv/get?key=race"); + const fromB = await bodyOf(b, "/kv/get?key=race"); + expect(fromA).toBe(fromB); + expect(fromA === "v" || fromA === "").toBe(true); + }); + + test("edge: concurrent CREATE TABLE IF NOT EXISTS from both instances at cold start", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeStress(root, "a"); + const b = makeStress(root, "b"); + await a.ready; + await b.ready; + + const ddl = + "CREATE TABLE IF NOT EXISTS log (id INTEGER PRIMARY KEY AUTOINCREMENT, src TEXT)"; + // Both processes race to create the schema on a freshly-created database. + const created = await Promise.all([ + fire(a, "/d1/exec", ddl), + fire(b, "/d1/exec", ddl), + ]); + expect(created.filter((s) => s !== 200)).toEqual([]); + + await fire(a, "/d1/insert?src=a"); + await fire(b, "/d1/insert?src=b"); + expect((await jsonOf<{ count: number }>(a, "/d1/stats")).count).toBe(2); + }); + + test("stress: concurrent R2 writes to distinct keys from both instances", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeStress(root, "a"); + const b = makeStress(root, "b"); + await a.ready; + await b.ready; + + const PER = 40; + const ops: Promise[] = []; + for (let i = 0; i < PER; i++) { + ops.push(fire(a, `/r2/put?key=r2:a:${i}`, "v")); + ops.push(fire(b, `/r2/put?key=r2:b:${i}`, "v")); + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + const misses: string[] = []; + for (let i = 0; i < PER; i++) { + if ((await bodyOf(b, `/r2/get?key=r2:a:${i}`)) !== "v") + misses.push(`a:${i}`); + if ((await bodyOf(a, `/r2/get?key=r2:b:${i}`)) !== "v") + misses.push(`b:${i}`); + } + expect(misses).toEqual([]); + }); + + test("stress: concurrent R2 writes to the SAME key never error and converge", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeStress(root, "a"); + const b = makeStress(root, "b"); + await a.ready; + await b.ready; + + const N = 40; + const candidates = new Set(); + const ops: Promise[] = []; + for (let i = 0; i < N; i++) { + const va = `a-${i}`; + const vb = `b-${i}`; + candidates.add(va); + candidates.add(vb); + ops.push(fire(a, "/r2/put?key=hot", va)); + ops.push(fire(b, "/r2/put?key=hot", vb)); + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + const fromA = await bodyOf(a, "/r2/get?key=hot"); + const fromB = await bodyOf(b, "/r2/get?key=hot"); + expect(fromA).toBe(fromB); + expect(candidates.has(fromA)).toBe(true); + }); + + test("stress: concurrent large R2 bodies (blob store) from both instances", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeStress(root, "a"); + const b = makeStress(root, "b"); + await a.ready; + await b.ready; + + const LEN = 256 * 1024; // 256 KiB each + const PER = 8; + const ops: Promise[] = []; + for (let i = 0; i < PER; i++) { + ops.push(fire(a, `/r2/putbig?key=blob:a:${i}&len=${LEN}`)); + ops.push(fire(b, `/r2/putbig?key=blob:b:${i}&len=${LEN}`)); + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + for (let i = 0; i < PER; i++) { + expect( + (await jsonOf<{ size: number }>(b, `/r2/size?key=blob:a:${i}`)).size + ).toBe(LEN); + expect( + (await jsonOf<{ size: number }>(a, `/r2/size?key=blob:b:${i}`)).size + ).toBe(LEN); + } + }); + + test("stress: concurrent Cache writes to distinct URLs from both instances", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeStress(root, "a"); + const b = makeStress(root, "b"); + await a.ready; + await b.ready; + + const PER = 40; + const ops: Promise[] = []; + for (let i = 0; i < PER; i++) { + const ua = encodeURIComponent(`http://example.com/c/a/${i}`); + const ub = encodeURIComponent(`http://example.com/c/b/${i}`); + ops.push(fire(a, `/cache/put?url=${ua}`, "v")); + ops.push(fire(b, `/cache/put?url=${ub}`, "v")); + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + // Per-instance cache under the storage owner: each instance reads back + // only its own writes. Without the owner (shared persist root), cache is + // shared, so each instance also sees the other's writes. + const misses: string[] = []; + for (let i = 0; i < PER; i++) { + const ua = encodeURIComponent(`http://example.com/c/a/${i}`); + const ub = encodeURIComponent(`http://example.com/c/b/${i}`); + const reader = sharedOwner ? a : b; + if ((await bodyOf(reader, `/cache/get?url=${ua}`)) !== "v") + misses.push(`a:${i}`); + const readerB = sharedOwner ? b : a; + if ((await bodyOf(readerB, `/cache/get?url=${ub}`)) !== "v") + misses.push(`b:${i}`); + } + expect(misses).toEqual([]); + }); + + test("stress: concurrent D1 batch transactions from both instances keep exact count", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeStress(root, "a"); + const b = makeStress(root, "b"); + await a.ready; + await b.ready; + await fire( + a, + "/d1/exec", + "CREATE TABLE IF NOT EXISTS log (id INTEGER PRIMARY KEY AUTOINCREMENT, src TEXT)" + ); + + const BATCHES = 10; + const ROWS = 10; + const ops: Promise[] = []; + for (let i = 0; i < BATCHES; i++) { + ops.push(fire(a, `/d1/bulkinsert?n=${ROWS}&src=a`)); + ops.push(fire(b, `/d1/bulkinsert?n=${ROWS}&src=b`)); + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + const total = BATCHES * ROWS * 2; + const stats = await jsonOf<{ count: number; distinctIds: number }>( + b, + "/d1/stats" + ); + expect(stats.count).toBe(total); + expect(stats.distinctIds).toBe(total); + }); + + test("stress: D1 readers running concurrently with cross-process writers never error", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeStress(root, "a"); + const b = makeStress(root, "b"); + await a.ready; + await b.ready; + await fire( + a, + "/d1/exec", + "CREATE TABLE IF NOT EXISTS log (id INTEGER PRIMARY KEY AUTOINCREMENT, src TEXT)" + ); + + const PER = 40; + const writes: Promise[] = []; + const reads: Promise[] = []; + for (let i = 0; i < PER; i++) { + writes.push(fire(a, "/d1/insert?src=a")); + writes.push(fire(b, "/d1/insert?src=b")); + // Reads from both processes, interleaved with the writes. + reads.push(fire(a, "/d1/stats")); + reads.push(fire(b, "/d1/stats")); + } + const [writeStatuses, readStatuses] = await Promise.all([ + Promise.all(writes), + Promise.all(reads), + ]); + expect(writeStatuses.filter((s) => s !== 200)).toEqual([]); + expect(readStatuses.filter((s) => s !== 200)).toEqual([]); + expect((await jsonOf<{ count: number }>(a, "/d1/stats")).count).toBe( + PER * 2 + ); + }); + + test("stress: cold-start race touching KV + R2 + D1 + Cache concurrently from both instances", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeStress(root, "a"); + const b = makeStress(root, "b"); + await a.ready; + await b.ready; + + // The very first thing both processes do is hit every binding type at + // once -- this maximises the cold-start open/transition races across all + // of the freshly-created databases simultaneously. + const PER = 20; + const ops: Promise[] = []; + for (let i = 0; i < PER; i++) { + ops.push(fire(a, `/touchall?tag=a-${i}`)); + ops.push(fire(b, `/touchall?tag=b-${i}`)); + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + expect( + (await jsonOf<{ count: number }>(b, "/kv/listcount?prefix=touch:")) + .count + ).toBe(PER * 2); + expect((await jsonOf<{ count: number }>(a, "/d1/stats")).count).toBe( + PER * 2 + ); + expect(await bodyOf(b, "/r2/get?key=touch:a-0")).toBe("v"); + // Cache is per-instance under the storage owner, so A reads back its own + // cached tag rather than B's. Without the owner the cache is shared. + const cacheTag = sharedOwner ? "a-0" : "b-0"; + expect( + await bodyOf( + a, + `/cache/get?url=${encodeURIComponent("http://x/touch/" + cacheTag)}` + ) + ).toBe("v"); + }); + + test("stress: six instances concurrently writing the same KV namespace", async ({ + expect, + }) => { + const root = await useTmp(); + const names = ["a", "b", "c", "d", "e", "f"]; + const mfs = names.map((n) => makeStress(root, n)); + await Promise.all(mfs.map((mf) => mf.ready)); + + const PER = 20; + const statuses = await Promise.all( + mfs.flatMap((mf, idx) => + Array.from({ length: PER }, (_unused, i) => + fire(mf, `/kv/put?key=six-${idx}-${i}`, "v") + ) + ) + ); + expect(statuses.filter((s) => s !== 200)).toEqual([]); + + expect( + (await jsonOf<{ count: number }>(mfs[0], "/kv/listcount?prefix=six-")) + .count + ).toBe(names.length * PER); + }); + + test("stress: concurrent overlapping put/delete/get on a shared KV key space", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeStress(root, "a"); + const b = makeStress(root, "b"); + await a.ready; + await b.ready; + + // Both processes interleave puts, deletes and gets over the same small set + // of keys. Nothing may error and reads must always return a valid state. + const KEYS = 8; + const ROUNDS = 20; + const ops: Promise[] = []; + const badReads: string[] = []; + const readCheck = async (mf: Miniflare, key: string) => { + const v = await bodyOf(mf, `/kv/get?key=${key}`); + if (v !== "v" && v !== "") badReads.push(v); + return 200; + }; + for (let r = 0; r < ROUNDS; r++) { + for (let k = 0; k < KEYS; k++) { + const key = `mix-${k}`; + ops.push(fire(a, `/kv/put?key=${key}`, "v")); + ops.push(fire(b, `/kv/delete?key=${key}`)); + ops.push(readCheck(a, key)); + ops.push(readCheck(b, key)); + } + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + expect(badReads).toEqual([]); + }); + + // ------------------------------------------------------------------- + // High concurrency: many independent workerd processes (each Miniflare + // instance is its own process) hammering one shared store at once. These + // assert exact correctness, not just absence of errors. + // ------------------------------------------------------------------- + + function makeMany(root: string, count: number) { + return Array.from({ length: count }, (_unused, i) => + makeStress(root, `p${i}`) + ); + } + + test("high concurrency: 8 processes increment one D1 counter, total is exact", async ({ + expect, + }) => { + const root = await useTmp(); + const mfs = makeMany(root, 8); + await Promise.all(mfs.map((mf) => mf.ready)); + await fire( + mfs[0], + "/d1/exec", + "CREATE TABLE IF NOT EXISTS counter (id TEXT PRIMARY KEY, v INTEGER)" + ); + + const PER = 25; + const ops = mfs.flatMap((mf) => + Array.from({ length: PER }, () => fire(mf, "/d1/incr?key=hot")) + ); + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + const total = mfs.length * PER; + // Every process must observe the same exact total -- no lost updates. + for (const mf of mfs) { + expect((await jsonOf<{ v: number }>(mf, "/d1/value?key=hot")).v).toBe( + total + ); + } + }); + + test("high concurrency: 8 processes AUTOINCREMENT insert, ids unique and count exact", async ({ + expect, + }) => { + const root = await useTmp(); + const mfs = makeMany(root, 8); + await Promise.all(mfs.map((mf) => mf.ready)); + await fire( + mfs[0], + "/d1/exec", + "CREATE TABLE IF NOT EXISTS log (id INTEGER PRIMARY KEY AUTOINCREMENT, src TEXT)" + ); + + const PER = 25; + const ops = mfs.flatMap((mf, idx) => + Array.from({ length: PER }, () => fire(mf, `/d1/insert?src=p${idx}`)) + ); + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + const total = mfs.length * PER; + const stats = await jsonOf<{ + count: number; + distinctIds: number; + maxId: number; + }>(mfs[3], "/d1/stats"); + expect(stats.count).toBe(total); + expect(stats.distinctIds).toBe(total); + expect(stats.maxId).toBe(total); // contiguous: no gaps, no reused ids + }); + + test("high concurrency: many processes increment many disjoint D1 rows, each exact", async ({ + expect, + }) => { + const root = await useTmp(); + const mfs = makeMany(root, 6); + await Promise.all(mfs.map((mf) => mf.ready)); + await fire( + mfs[0], + "/d1/exec", + "CREATE TABLE IF NOT EXISTS counter (id TEXT PRIMARY KEY, v INTEGER)" + ); + + const ROWS = 5; + const PER = 10; + // Every process increments every counter PER times -> high contention on + // all rows simultaneously. + const ops = mfs.flatMap((mf) => + Array.from({ length: ROWS * PER }, (_unused, i) => + fire(mf, `/d1/incr?key=c${i % ROWS}`) + ) + ); + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + const expected = mfs.length * PER; + for (let r = 0; r < ROWS; r++) { + expect( + (await jsonOf<{ v: number }>(mfs[0], `/d1/value?key=c${r}`)).v + ).toBe(expected); + } + }); + + test("high concurrency: concurrent bank transfers across processes conserve the total", async ({ + expect, + }) => { + const root = await useTmp(); + const mfs = makeMany(root, 6); + await Promise.all(mfs.map((mf) => mf.ready)); + await fire(mfs[0], "/bank/init"); + + const PER = 20; + const ops = mfs.flatMap((mf, idx) => + Array.from({ length: PER }, (_unused, i) => { + // Alternate direction so debits and credits interleave heavily. + const [from, to] = (idx + i) % 2 === 0 ? ["x", "y"] : ["y", "x"]; + return fire(mf, `/bank/transfer?from=${from}&to=${to}&amt=1`); + }) + ); + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + // Each transfer is an atomic batch, so the invariant total == 2000 must + // hold exactly regardless of interleaving across processes. + for (const mf of mfs) { + expect((await jsonOf<{ total: number }>(mf, "/bank/total")).total).toBe( + 2000 + ); + } + }); + + test("high concurrency: 10 processes write distinct KV keys, all present and correct", async ({ + expect, + }) => { + const root = await useTmp(); + const mfs = makeMany(root, 10); + await Promise.all(mfs.map((mf) => mf.ready)); + + const PER = 30; + const ops = mfs.flatMap((mf, idx) => + Array.from({ length: PER }, (_unused, i) => + fire(mf, `/kv/put?key=hc-${idx}-${i}`, `val-${idx}-${i}`) + ) + ); + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + // A fresh reader process sees every key with the exact value written. + const reader = makeStress(root, "reader"); + await reader.ready; + expect( + (await jsonOf<{ count: number }>(reader, "/kv/listcount?prefix=hc-")) + .count + ).toBe(mfs.length * PER); + const bad: string[] = []; + for (let idx = 0; idx < mfs.length; idx++) { + for (let i = 0; i < PER; i++) { + const v = await bodyOf(reader, `/kv/get?key=hc-${idx}-${i}`); + if (v !== `val-${idx}-${i}`) bad.push(`hc-${idx}-${i}=${v}`); + } + } + expect(bad).toEqual([]); + }); + + test("high concurrency: many processes overwrite one KV key, all agree on a real final value", async ({ + expect, + }) => { + const root = await useTmp(); + const mfs = makeMany(root, 8); + await Promise.all(mfs.map((mf) => mf.ready)); + + const PER = 20; + const candidates = new Set(); + const ops = mfs.flatMap((mf, idx) => + Array.from({ length: PER }, (_unused, i) => { + const v = `p${idx}-${i}`; + candidates.add(v); + return fire(mf, "/kv/put?key=hot", v); + }) + ); + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + // After settling, every process reads the identical final value and it is + // one of the values that was actually written (no torn/garbage value). + const finals = await Promise.all( + mfs.map((mf) => bodyOf(mf, "/kv/get?key=hot")) + ); + expect(new Set(finals).size).toBe(1); + expect(candidates.has(finals[0])).toBe(true); + }); + + test("concurrency: a D1 batch is atomic to other processes (never a partial count)", async ({ + expect, + }) => { + const root = await useTmp(); + const writer = makeStress(root, "writer"); + const reader = makeStress(root, "reader"); + await writer.ready; + await reader.ready; + await fire( + writer, + "/d1/exec", + "CREATE TABLE IF NOT EXISTS log (id INTEGER PRIMARY KEY AUTOINCREMENT, src TEXT)" + ); + + const N = 300; + // Kick off the big batch without awaiting, then poll the count from the + // OTHER process while it runs. `finally` guarantees the loop terminates + // even if the batch request rejects. + let done = false; + const batch = fire(writer, `/d1/bigbatch?tag=BIG&n=${N}`).finally(() => { + done = true; + }); + const observed: number[] = []; + while (!done) { + observed.push( + (await jsonOf<{ c: number }>(reader, "/d1/counttag?tag=BIG")).c + ); + } + expect(await batch).toBe(200); + observed.push( + (await jsonOf<{ c: number }>(reader, "/d1/counttag?tag=BIG")).c + ); + + // Other processes may only ever see the batch as not-yet-applied (0) or + // fully applied (N) -- never a partial, mid-transaction count. + expect(observed.every((c) => c === 0 || c === N)).toBe(true); + expect(observed.at(-1)).toBe(N); + }); + + test("high concurrency: 6 processes race cold-start touching all binding types", async ({ + expect, + }) => { + const root = await useTmp(); + const mfs = makeMany(root, 6); + await Promise.all(mfs.map((mf) => mf.ready)); + + const PER = 10; + // First operation each process performs hits every binding type at once. + const ops = mfs.flatMap((mf, idx) => + Array.from({ length: PER }, (_unused, i) => + fire(mf, `/touchall?tag=p${idx}-${i}`) + ) + ); + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + const total = mfs.length * PER; + expect( + (await jsonOf<{ count: number }>(mfs[0], "/kv/listcount?prefix=touch:")) + .count + ).toBe(total); + expect((await jsonOf<{ count: number }>(mfs[1], "/d1/stats")).count).toBe( + total + ); + }); + + test("high concurrency: writers and a deleter race one key set; final state is consistent", async ({ + expect, + }) => { + const root = await useTmp(); + const mfs = makeMany(root, 6); + await Promise.all(mfs.map((mf) => mf.ready)); + + const KEYS = 6; + const ROUNDS = 15; + // Processes 0..4 write the keys; process 5 deletes them; all concurrent. + const ops: Promise[] = []; + for (let r = 0; r < ROUNDS; r++) { + for (let k = 0; k < KEYS; k++) { + for (let idx = 0; idx < mfs.length - 1; idx++) { + ops.push(fire(mfs[idx], `/kv/put?key=race-${k}`, "v")); + } + ops.push(fire(mfs[mfs.length - 1], `/kv/delete?key=race-${k}`)); + } + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + // Every process must agree on the final state of each key, and it must be + // a valid value or absent (never corrupt). + for (let k = 0; k < KEYS; k++) { + const finals = await Promise.all( + mfs.map((mf) => bodyOf(mf, `/kv/get?key=race-${k}`)) + ); + expect(new Set(finals).size).toBe(1); + expect(finals[0] === "v" || finals[0] === "").toBe(true); + } + }); + }); + + // ------------------------------------------------------------- Cross-cutting + describe("cross-cutting", () => { + test("three instances share the same store", async ({ expect }) => { + const root = await useTmp(); + const a = make({ root, name: "a", kvId: "ns" }); + const b = make({ root, name: "b", kvId: "ns" }); + const c = make({ root, name: "c", kvId: "ns" }); + await a.ready; + await b.ready; + await c.ready; + const kvA = await a.getKVNamespace("KV"); + const kvC = await c.getKVNamespace("KV"); + + await kvA.put("k", "v"); + expect(await kvC.get("k")).toBe("v"); + await kvC.put("k2", "v2"); + expect(await (await b.getKVNamespace("KV")).get("k2")).toBe("v2"); + }); + + test("data survives one instance being disposed mid-run", async ({ + expect, + }) => { + const root = await useTmp(); + const a = make({ root, name: "a", kvId: "ns" }); + const b = make({ root, name: "b", kvId: "ns" }); + await a.ready; + await b.ready; + const kvA = await a.getKVNamespace("KV"); + + await kvA.put("k", "written-by-a"); + // Dispose A; B should keep reading the same dataset uninterrupted. + await a.dispose(); + + const kvB = await b.getKVNamespace("KV"); + expect(await kvB.get("k")).toBe("written-by-a"); + await kvB.put("k2", "written-by-b"); + expect(await kvB.get("k2")).toBe("written-by-b"); + }); + + test("data persists across a full restart (new instance, same root)", async ({ + expect, + }) => { + const root = await useTmp(); + const first = make({ root, name: "a", kvId: "ns" }); + await first.ready; + const kv1 = await first.getKVNamespace("KV"); + await kv1.put("k", "persisted"); + await first.dispose(); + + const second = make({ root, name: "a", kvId: "ns" }); + await second.ready; + const kv2 = await second.getKVNamespace("KV"); + expect(await kv2.get("k")).toBe("persisted"); + }); + + test("mixed: shared KV (same id) but isolated R2 (different bucket)", async ({ + expect, + }) => { + const root = await useTmp(); + const a = make({ + root, + name: "a", + kvId: "ns", + r2Bucket: "bucket-a", + }); + const b = make({ + root, + name: "b", + kvId: "ns", + r2Bucket: "bucket-b", + }); + await a.ready; + await b.ready; + + const kvA = await a.getKVNamespace("KV"); + const kvB = await b.getKVNamespace("KV"); + const r2A = await a.getR2Bucket("R2"); + const r2B = await b.getR2Bucket("R2"); + + await kvA.put("k", "shared"); + await r2A.put("obj", "isolated"); + + expect(await kvB.get("k")).toBe("shared"); + expect(await r2B.head("obj")).toBe(null); + }); + }); + + // ----------------------------------------------------- Concurrency / SQLITE_BUSY + // Concurrent cross-process writes must neither fail (no SQLITE_BUSY / read-only + // surfacing as errors) nor corrupt the store: every committed write must remain + // readable, and counts must stay exact. + describe("concurrent writes", () => { + test("KV: concurrent writes to distinct keys from both instances", async ({ + expect, + }) => { + const root = await useTmp(); + const a = make({ root, name: "a", kvId: "ns" }); + const b = make({ root, name: "b", kvId: "ns" }); + await a.ready; + await b.ready; + const kvA = await a.getKVNamespace("KV"); + const kvB = await b.getKVNamespace("KV"); + + const N = 50; + const ops: Promise<{ ok: boolean; key: string }>[] = []; + for (let i = 0; i < N; i++) { + const keyA = `a-${i}`; + const keyB = `b-${i}`; + ops.push( + kvA + .put(keyA, "v") + .then(() => ({ ok: true, key: keyA })) + .catch(() => ({ ok: false, key: keyA })) + ); + ops.push( + kvB + .put(keyB, "v") + .then(() => ({ ok: true, key: keyB })) + .catch(() => ({ ok: false, key: keyB })) + ); + } + const results = await Promise.all(ops); + expect(results.filter((r) => !r.ok)).toEqual([]); + expect(results.some((r) => r.ok)).toBe(true); + + // Integrity: every write that reported success must be readable. + for (const r of results.filter((r) => r.ok)) { + expect(await kvA.get(r.key)).toBe("v"); + } + }); + + test("D1: concurrent inserts into the same table from both instances", async ({ + expect, + }) => { + const root = await useTmp(); + const a = make({ root, name: "a", d1Id: "db" }); + const b = make({ root, name: "b", d1Id: "db" }); + await a.ready; + await b.ready; + const dbA = await a.getD1Database("DB"); + const dbB = await b.getD1Database("DB"); + + await dbA.exec( + "CREATE TABLE log (id INTEGER PRIMARY KEY AUTOINCREMENT, src TEXT);" + ); + + const N = 40; + const ops: Promise[] = []; + for (let i = 0; i < N; i++) { + ops.push( + dbA + .prepare("INSERT INTO log (src) VALUES ('a')") + .run() + .then(() => true) + .catch(() => false) + ); + ops.push( + dbB + .prepare("INSERT INTO log (src) VALUES ('b')") + .run() + .then(() => true) + .catch(() => false) + ); + } + const results = await Promise.all(ops); + const succeeded = results.filter(Boolean).length; + expect(succeeded).toBe(results.length); + expect(succeeded).toBeGreaterThan(0); + + // Integrity: row count equals number of successful inserts (no + // corruption, no phantom/lost committed rows). + const { results: rows } = await dbA + .prepare("SELECT COUNT(*) as c FROM log") + .all<{ c: number }>(); + expect(rows[0].c).toBe(succeeded); + }); + }); + + // --------------------------------------------------- Durable Objects (out of scope) + // Documents the behaviour of sharing a DO via defaultPersistRoot. DOs are out + // of scope for the singleton feature; this test records what happens today. + describe("Durable Objects (behaviour documentation)", () => { + const DO_SCRIPT = ` + export class Counter { + constructor(state) { this.state = state; } + async fetch(request) { + const url = new URL(request.url); + if (request.method === "POST") { + await this.state.storage.put("v", url.searchParams.get("v")); + return new Response("ok"); + } + const v = await this.state.storage.get("v"); + return new Response(String(v ?? "")); + } + } + export default { + async fetch(request, env) { + const id = env.COUNTER.idFromName("singleton"); + return env.COUNTER.get(id).fetch(request); + } + }; + `; + + function makeDO(root: string, name: string) { + const mf = new Miniflare({ + name, + defaultPersistRoot: root, + modules: true, + script: DO_SCRIPT, + compatibilityDate: COMPAT_DATE, + durableObjects: { COUNTER: "Counter" }, + }); + instances.push(mf); + return mf; + } + + test("DO storage is not live-shared between running instances", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeDO(root, "a"); + const b = makeDO(root, "b"); + await a.ready; + await b.ready; + + await ( + await a.dispatchFetch("http://x/?v=42", { method: "POST" }) + ).text(); + const res = await b.dispatchFetch("http://x/"); + const text = await res.text(); + // User Durable Objects are intentionally out of scope for shared local + // binding singletons. Two running instances can have independent live + // actors even when pointed at the same persistence directory. + expect(text).toBe(""); + }); + + const DO_OUTPUT_GATE_SCRIPT = ` + export class GateCounter { + constructor(state) { this.state = state; } + + async fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/write-and-wait") { + await this.state.storage.put("v", "pending"); + await new Promise((resolve) => setTimeout(resolve, 500)); + return new Response("done"); + } + + if (url.pathname === "/write-and-throw") { + await this.state.storage.put("v", "thrown"); + throw new Error("boom"); + } + + const v = await this.state.storage.get("v"); + return new Response(String(v ?? "")); + } + } + + export default { + async fetch(request, env) { + const id = env.COUNTER.idFromName("singleton"); + return env.COUNTER.get(id).fetch(request); + } + }; + `; + + function makeGateDO(root: string) { + const mf = new Miniflare({ + // The default DO uniqueKey is `${name}-${className}`. Using the same + // name in both Miniflare instances deliberately points both live + // actors at the same persisted DO storage. + name: "same-worker", + defaultPersistRoot: root, + modules: true, + script: DO_OUTPUT_GATE_SCRIPT, + compatibilityDate: COMPAT_DATE, + durableObjects: { COUNTER: "GateCounter" }, + }); + instances.push(mf); + return mf; + } + + test("DO output gate does not block another process reading the same DO storage", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeGateDO(root); + const b = makeGateDO(root); + await a.ready; + await b.ready; + + const pendingWrite = a.dispatchFetch("http://x/write-and-wait"); + const writeCompleted = await Promise.race([ + pendingWrite.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 100)), + ]); + expect(writeCompleted).toBe(false); + + // Output gates block A's outgoing response while the write is flushed, but + // they are local to A's live actor. B is a separate workerd process with a + // separate live actor, so it can observe the shared SQLite state before A's + // request has completed. + expect(await (await b.dispatchFetch("http://x/read")).text()).toBe( + "pending" + ); + expect(await (await pendingWrite).text()).toBe("done"); + }); + + test("DO storage writes are not rolled back when the request throws", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeGateDO(root); + const b = makeGateDO(root); + await a.ready; + await b.ready; + + const failed = await a.dispatchFetch("http://x/write-and-throw"); + expect(failed.status).toBe(500); + await failed.text(); + + // Output gates are durability/visibility gates, not request-scoped + // transactions. A write that completed before user code threw can still be + // committed and subsequently observed by another process. + expect(await (await b.dispatchFetch("http://x/read")).text()).toBe( + "thrown" + ); + }); + }); + + // --------------------------------------------- Extra routed storage types + // Streams, Secrets Store and Images also share their backing storage across + // instances (via the owner when enabled; via the persist root otherwise). + describe("extra storage types", () => { + test("Stream: a video uploaded by a worker in A is listed by a worker in B", async ({ + expect, + }) => { + const root = await useTmp(); + const WORKER = `export default { + async fetch(request, env) { + if (request.method === "PUT") { + const body = new Response(new Uint8Array([0,1,2,3,4,5,6,7])).body; + const video = await env.STREAM.upload(body, {}); + return Response.json({ id: video.id }); + } + const videos = await env.STREAM.videos.list(); + return Response.json({ count: videos.length }); + } + }`; + const a = make({ root, name: "a", stream: true, script: WORKER }); + const b = make({ root, name: "b", stream: true, script: WORKER }); + await a.ready; + await b.ready; + + const put = (await ( + await a.dispatchFetch("http://x/", { method: "PUT" }) + ).json()) as { id: string }; + expect(put.id).toBeTruthy(); + + expect( + ( + (await (await b.dispatchFetch("http://x/")).json()) as { + count: number; + } + ).count + ).toBe(1); + }); + + test("Secrets Store: a secret created via A is read by a worker in B", async ({ + expect, + }) => { + const root = await useTmp(); + const secret = { store_id: "store", secret_name: "api_key" }; + const WORKER = `export default { + async fetch(request, env) { + try { return new Response(await env.SECRET.get()); } + catch (e) { return new Response(e.message, { status: 404 }); } + } + }`; + const a = make({ root, name: "a", secret, script: WORKER }); + const b = make({ root, name: "b", secret, script: WORKER }); + await a.ready; + await b.ready; + + await ( + await a.getSecretsStoreSecretAPI("SECRET") + )().create("shared-secret"); + + expect(await (await b.dispatchFetch("http://x/")).text()).toBe( + "shared-secret" + ); + }); + + test("Images: the binding is usable in both instances", async ({ + expect, + }) => { + const root = await useTmp(); + const WORKER = `export default { + async fetch(_request, env) { + return new Response(typeof env.IMAGES.info); + } + }`; + const a = make({ root, name: "a", images: true, script: WORKER }); + const b = make({ root, name: "b", images: true, script: WORKER }); + await a.ready; + await b.ready; + + expect(await (await a.dispatchFetch("http://x/")).text()).toBe( + "function" + ); + expect(await (await b.dispatchFetch("http://x/")).text()).toBe( + "function" + ); + }); + }); +}); diff --git a/packages/miniflare/test/storage-owner.spec.ts b/packages/miniflare/test/storage-owner.spec.ts new file mode 100644 index 00000000000..02c1b66b934 --- /dev/null +++ b/packages/miniflare/test/storage-owner.spec.ts @@ -0,0 +1,680 @@ +import { utimesSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { + clearStorageOwner, + countLiveStorageClients, + heartbeatStorageOwner, + isProcessAlive, + Miniflare, + OWNER_STALE_MS, + readStorageOwner, + registerStorageClient, + tryAcquireOwnerSpawnLock, + unregisterStorageClient, + writeStorageOwner, + type StorageOwnerDefinition, +} from "miniflare"; +import { describe, it, vi } from "vitest"; +import { useTmp } from "./test-shared"; + +// A pid that is essentially guaranteed not to exist on the host. +const DEAD_PID = 0x7fffffff; + +function makeDefinition( + overrides: Partial = {} +): StorageOwnerDefinition { + return { + pid: process.pid, + httpAddress: "127.0.0.1:12345", + updatedAt: Date.now(), + ...overrides, + }; +} + +describe("isProcessAlive", () => { + it("reports the current process as alive", ({ expect }) => { + expect(isProcessAlive(process.pid)).toBe(true); + }); + it("reports a non-existent process as dead", ({ expect }) => { + expect(isProcessAlive(DEAD_PID)).toBe(false); + }); + it("treats invalid pids as dead", ({ expect }) => { + expect(isProcessAlive(0)).toBe(false); + expect(isProcessAlive(-1)).toBe(false); + }); +}); + +describe("storage owner definition", () => { + it("returns undefined when no owner is published", async ({ expect }) => { + const persistRoot = await useTmp(); + expect(readStorageOwner(persistRoot)).toBeUndefined(); + }); + + it("round-trips a published definition", async ({ expect }) => { + const persistRoot = await useTmp(); + const def = makeDefinition(); + writeStorageOwner(persistRoot, def); + expect(readStorageOwner(persistRoot)).toEqual(def); + }); + + it("treats a definition with a dead pid as absent", async ({ expect }) => { + const persistRoot = await useTmp(); + writeStorageOwner(persistRoot, makeDefinition({ pid: DEAD_PID })); + expect(readStorageOwner(persistRoot)).toBeUndefined(); + }); + + it("treats a stale (un-heartbeated) definition as absent", async ({ + expect, + }) => { + const persistRoot = await useTmp(); + writeStorageOwner(persistRoot, makeDefinition()); + // Backdate the mtime well past the staleness window. + const old = new Date(Date.now() - OWNER_STALE_MS - 60_000); + utimesSync(path.join(persistRoot, ".miniflare-owner.json"), old, old); + expect(readStorageOwner(persistRoot)).toBeUndefined(); + // A heartbeat refreshes it back to live. + heartbeatStorageOwner(persistRoot); + expect(readStorageOwner(persistRoot)).toBeDefined(); + }); + + it("ignores a partially-written definition file", async ({ expect }) => { + const persistRoot = await useTmp(); + writeFileSync( + path.join(persistRoot, ".miniflare-owner.json"), + "{ not valid json" + ); + expect(readStorageOwner(persistRoot)).toBeUndefined(); + }); + + it("clearStorageOwner removes our own definition", async ({ expect }) => { + const persistRoot = await useTmp(); + writeStorageOwner(persistRoot, makeDefinition()); + clearStorageOwner(persistRoot, process.pid); + expect(readStorageOwner(persistRoot)).toBeUndefined(); + }); + + it("clearStorageOwner does not stomp a different live owner", async ({ + expect, + }) => { + const persistRoot = await useTmp(); + // Pretend the current process is a *different* live owner. + writeStorageOwner(persistRoot, makeDefinition({ pid: process.pid })); + clearStorageOwner(persistRoot, process.pid + 1); + expect(readStorageOwner(persistRoot)).toBeDefined(); + }); +}); + +describe("owner spawn lock", () => { + it("grants the lock to a single acquirer", async ({ expect }) => { + const persistRoot = await useTmp(); + const first = tryAcquireOwnerSpawnLock(persistRoot); + expect(first).toBeDefined(); + const second = tryAcquireOwnerSpawnLock(persistRoot); + expect(second).toBeUndefined(); + first?.release(); + const third = tryAcquireOwnerSpawnLock(persistRoot); + expect(third).toBeDefined(); + third?.release(); + }); + + it("reclaims a lock held by a dead process", async ({ expect }) => { + const persistRoot = await useTmp(); + // Simulate a crashed holder by writing a dead pid into the lock file. + writeFileSync( + path.join(persistRoot, ".miniflare-owner.lock"), + String(DEAD_PID) + ); + const lock = tryAcquireOwnerSpawnLock(persistRoot); + expect(lock).toBeDefined(); + lock?.release(); + }); + + it("reclaims a stale lock", async ({ expect }) => { + const persistRoot = await useTmp(); + const lockPath = path.join(persistRoot, ".miniflare-owner.lock"); + writeFileSync(lockPath, String(process.pid)); + const old = new Date(Date.now() - OWNER_STALE_MS - 60_000); + utimesSync(lockPath, old, old); + const lock = tryAcquireOwnerSpawnLock(persistRoot); + expect(lock).toBeDefined(); + lock?.release(); + }); +}); + +describe("client presence registry", () => { + it("counts live clients and reclaims dead/stale ones", async ({ expect }) => { + const persistRoot = await useTmp(); + expect(countLiveStorageClients(persistRoot)).toBe(0); + + const clientPath = registerStorageClient(persistRoot); + expect(countLiveStorageClients(persistRoot)).toBe(1); + + // A dead client is reclaimed and not counted. + const deadClient = path.join( + persistRoot, + ".miniflare-owner-clients", + String(DEAD_PID) + ); + writeFileSync(deadClient, String(Date.now())); + expect(countLiveStorageClients(persistRoot)).toBe(1); + + unregisterStorageClient(clientPath); + expect(countLiveStorageClients(persistRoot)).toBe(0); + }); +}); + +describe.sequential("owner presence integration", () => { + it("an owner-role instance publishes a live definition and clears it on dispose", async ({ + expect, + }) => { + const persistRoot = await useTmp(); + const registryPath = await useTmp(); + const owner = new Miniflare({ + unsafeSharedStorageOwner: true, + unsafeStorageOwnerRole: "owner", + defaultPersistRoot: persistRoot, + unsafeDevRegistryPath: registryPath, + compatibilityFlags: ["experimental"], + modules: true, + kvNamespaces: ["NS"], + script: + "export default { async fetch() { return new Response('owner'); } }", + }); + await owner.ready; + + const def = readStorageOwner(persistRoot); + expect(def).toBeDefined(); + expect(def?.pid).toBe(process.pid); + expect(def?.httpAddress).toMatch(/^127\.0\.0\.1:\d+$/); + + await owner.dispose(); + expect(readStorageOwner(persistRoot)).toBeUndefined(); + }); + + it("a client-role instance registers presence and removes it on dispose", async ({ + expect, + }) => { + const persistRoot = await useTmp(); + const registryPath = await useTmp(); + const client = new Miniflare({ + unsafeSharedStorageOwner: true, + unsafeStorageOwnerRole: "client", + defaultPersistRoot: persistRoot, + unsafeDevRegistryPath: registryPath, + compatibilityFlags: ["experimental"], + modules: true, + script: + "export default { async fetch() { return new Response('client'); } }", + }); + await client.ready; + + await vi.waitFor( + () => expect(countLiveStorageClients(persistRoot)).toBe(1), + { + timeout: 5_000, + interval: 100, + } + ); + + await client.dispose(); + expect(countLiveStorageClients(persistRoot)).toBe(0); + }); + + it("routes a client's KV through the owner so storage is shared", async ({ + expect, + }) => { + const persistRoot = await useTmp(); + const registryPath = await useTmp(); + const KV_WORKER = `export default { + async fetch(request, env) { + const url = new URL(request.url); + const key = url.searchParams.get("key") ?? "k"; + if (request.method === "PUT") { + await env.NS.put(key, await request.text()); + return new Response("ok"); + } + const val = await env.NS.get(key); + return new Response(val ?? ""); + } + }`; + const common = { + unsafeSharedStorageOwner: true, + defaultPersistRoot: persistRoot, + unsafeDevRegistryPath: registryPath, + compatibilityFlags: ["experimental"], + compatibilityDate: "2025-01-01", + modules: true, + kvNamespaces: ["NS"], + script: KV_WORKER, + }; + + const owner = new Miniflare({ ...common, unsafeStorageOwnerRole: "owner" }); + await owner.ready; + const client = new Miniflare({ + ...common, + unsafeStorageOwnerRole: "client", + }); + + try { + await client.ready; + + // Write through the client (which routes to the owner). + const putRes = await client.dispatchFetch("http://x/?key=greeting", { + method: "PUT", + body: "hello-from-client", + }); + expect(await putRes.text()).toBe("ok"); + + // The owner can read what the client wrote → storage is shared. + const ownerRes = await owner.dispatchFetch("http://x/?key=greeting"); + expect(await ownerRes.text()).toBe("hello-from-client"); + + // And the client can read it back through the proxy. + const clientRes = await client.dispatchFetch("http://x/?key=greeting"); + expect(await clientRes.text()).toBe("hello-from-client"); + } finally { + await client.dispose(); + await owner.dispose(); + } + }); + + it("routes a client's R2 and D1 through the owner so storage is shared", async ({ + expect, + }) => { + const persistRoot = await useTmp(); + const registryPath = await useTmp(); + const WORKER = `export default { + async fetch(request, env) { + const url = new URL(request.url); + const kind = url.searchParams.get("kind"); + if (kind === "r2") { + if (request.method === "PUT") { + await env.BUCKET.put("obj", await request.text()); + return new Response("ok"); + } + const o = await env.BUCKET.get("obj"); + return new Response(o ? await o.text() : ""); + } + // d1 + if (request.method === "PUT") { + await env.DB.prepare("CREATE TABLE IF NOT EXISTS t(v TEXT)").run(); + await env.DB.prepare("INSERT INTO t(v) VALUES (?)").bind(await request.text()).run(); + return new Response("ok"); + } + const { results } = await env.DB.prepare("SELECT v FROM t").all(); + return new Response(JSON.stringify(results.map((r) => r.v))); + } + }`; + const common = { + unsafeSharedStorageOwner: true, + defaultPersistRoot: persistRoot, + unsafeDevRegistryPath: registryPath, + compatibilityFlags: ["experimental"], + compatibilityDate: "2025-01-01", + modules: true, + r2Buckets: ["BUCKET"], + d1Databases: ["DB"], + script: WORKER, + }; + const owner = new Miniflare({ ...common, unsafeStorageOwnerRole: "owner" }); + await owner.ready; + const client = new Miniflare({ + ...common, + unsafeStorageOwnerRole: "client", + }); + + try { + await client.ready; + + // R2: client write → owner read. + expect( + await ( + await client.dispatchFetch("http://x/?kind=r2", { + method: "PUT", + body: "r2-from-client", + }) + ).text() + ).toBe("ok"); + expect( + await (await owner.dispatchFetch("http://x/?kind=r2")).text() + ).toBe("r2-from-client"); + + // D1: client write → owner read. + expect( + await ( + await client.dispatchFetch("http://x/?kind=d1", { + method: "PUT", + body: "d1-from-client", + }) + ).text() + ).toBe("ok"); + expect( + await (await owner.dispatchFetch("http://x/?kind=d1")).text() + ).toBe(JSON.stringify(["d1-from-client"])); + } finally { + await client.dispose(); + await owner.dispose(); + } + }); + + it("routes a client's Stream through the owner over RPC so storage is shared", async ({ + expect, + }) => { + const persistRoot = await useTmp(); + const registryPath = await useTmp(); + // Exercises the JSRPC (capnweb) path of the owner boundary, including + // nested RpcTargets (`videos.list()`). + const WORKER = `export default { + async fetch(request, env) { + try { + if (request.method === "PUT") { + const body = new Response( + new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]) + ).body; + const video = await env.STREAM.upload(body, {}); + return Response.json({ id: video.id }); + } + const videos = await env.STREAM.videos.list(); + return Response.json({ count: videos.length }); + } catch (e) { + return Response.json({ error: String(e && e.stack || e) }, { status: 500 }); + } + } + }`; + const common = { + unsafeSharedStorageOwner: true, + defaultPersistRoot: persistRoot, + unsafeDevRegistryPath: registryPath, + compatibilityFlags: ["experimental"], + compatibilityDate: "2025-01-01", + modules: true, + stream: { binding: "STREAM" }, + script: WORKER, + }; + const owner = new Miniflare({ ...common, unsafeStorageOwnerRole: "owner" }); + await owner.ready; + const client = new Miniflare({ + ...common, + unsafeStorageOwnerRole: "client", + }); + + try { + await client.ready; + + // Client uploads a video (RPC through the owner)... + const put = (await ( + await client.dispatchFetch("http://x/", { method: "PUT" }) + ).json()) as { id: string }; + expect(put.id).toBeTruthy(); + + // ...and the owner sees it (shared store), proving the RPC round-trip + // and the shared backing storage. + expect( + ( + (await (await owner.dispatchFetch("http://x/")).json()) as { + count: number; + } + ).count + ).toBe(1); + } finally { + await client.dispose(); + await owner.dispose(); + } + }); + + it("routes a client's Secrets Store secret through the owner over RPC", async ({ + expect, + }) => { + const persistRoot = await useTmp(); + const registryPath = await useTmp(); + const WORKER = `export default { + async fetch(request, env) { + try { + return new Response(await env.SECRET.get()); + } catch (e) { + return new Response(e.message, { status: 404 }); + } + } + }`; + const common = { + unsafeSharedStorageOwner: true, + defaultPersistRoot: persistRoot, + unsafeDevRegistryPath: registryPath, + compatibilityFlags: ["experimental"], + compatibilityDate: "2025-01-01", + modules: true, + secretsStoreSecrets: { + SECRET: { store_id: "store_a", secret_name: "api_key" }, + }, + script: WORKER, + }; + const owner = new Miniflare({ ...common, unsafeStorageOwnerRole: "owner" }); + await owner.ready; + const client = new Miniflare({ + ...common, + unsafeStorageOwnerRole: "client", + }); + + try { + await client.ready; + + // Seed the secret value on the owner (which holds the local store)... + await ( + await owner.getSecretsStoreSecretAPI("SECRET") + )().create("super-secret"); + + // ...and the client reads it back over the routed RPC binding. + expect(await (await client.dispatchFetch("http://x/")).text()).toBe( + "super-secret" + ); + } finally { + await client.dispose(); + await owner.dispose(); + } + }); + + it("routes a client's Images store to the owner without dangling services", async ({ + expect, + }) => { + const persistRoot = await useTmp(); + const registryPath = await useTmp(); + const common = { + unsafeSharedStorageOwner: true, + defaultPersistRoot: persistRoot, + unsafeDevRegistryPath: registryPath, + compatibilityFlags: ["experimental"], + compatibilityDate: "2025-01-01", + modules: true, + images: { binding: "IMAGES" }, + script: + "export default { async fetch(_request, env) { return new Response(typeof env.IMAGES.info); } }", + }; + const owner = new Miniflare({ ...common, unsafeStorageOwnerRole: "owner" }); + const client = new Miniflare({ + ...common, + unsafeStorageOwnerRole: "client", + }); + try { + // Both reaching `ready` proves the routed client doesn't reference a + // local images storage service it no longer stands up (the owner does), + // and the transform worker + its routed `IMAGES_STORE` binding resolve. + await owner.ready; + await client.ready; + expect(await (await client.dispatchFetch("http://x/")).text()).toBe( + "function" + ); + } finally { + await client.dispose(); + await owner.dispose(); + } + }); + + it("auto-spawns a detached owner, routes to it, and tears it down when idle", async ({ + expect, + }) => { + const persistRoot = await useTmp(); + const registryPath = await useTmp(); + // Shrink the owner's teardown timings (inherited by the spawned process). + const prevGrace = process.env.MINIFLARE_STORAGE_OWNER_GRACE_MS; + const prevCheck = process.env.MINIFLARE_STORAGE_OWNER_IDLE_CHECK_MS; + process.env.MINIFLARE_STORAGE_OWNER_GRACE_MS = "500"; + process.env.MINIFLARE_STORAGE_OWNER_IDLE_CHECK_MS = "200"; + + let ownerPid: number | undefined; + const client = new Miniflare({ + // No role set → behaves as a client and auto-spawns an owner. + unsafeSharedStorageOwner: true, + defaultPersistRoot: persistRoot, + unsafeDevRegistryPath: registryPath, + compatibilityFlags: ["experimental"], + compatibilityDate: "2025-01-01", + modules: true, + kvNamespaces: ["NS"], + script: `export default { + async fetch(request, env) { + if (request.method === "PUT") { + await env.NS.put("k", await request.text()); + return new Response("ok"); + } + return new Response((await env.NS.get("k")) ?? ""); + } + }`, + }); + + try { + await client.ready; + + // An owner was auto-spawned and published itself. + const def = readStorageOwner(persistRoot); + expect(def).toBeDefined(); + ownerPid = def?.pid; + expect(ownerPid).toBeDefined(); + expect(ownerPid).not.toBe(process.pid); // a separate process + + // Storage works through the routed proxy. + await ( + await client.dispatchFetch("http://x/", { + method: "PUT", + body: "via-auto-owner", + }) + ).text(); + const got = await client.dispatchFetch("http://x/"); + expect(await got.text()).toBe("via-auto-owner"); + + // Disposing the only client should let the owner self-terminate. + await client.dispose(); + await vi.waitFor( + () => expect(readStorageOwner(persistRoot)).toBeUndefined(), + { timeout: 15_000, interval: 200 } + ); + } finally { + await client.dispose().catch(() => {}); + // Safety net: ensure the detached owner isn't leaked if assertions failed. + if (ownerPid !== undefined && isProcessAlive(ownerPid)) { + try { + process.kill(ownerPid); + } catch {} + } + process.env.MINIFLARE_STORAGE_OWNER_GRACE_MS = prevGrace; + process.env.MINIFLARE_STORAGE_OWNER_IDLE_CHECK_MS = prevCheck; + } + }); + + it("lets many client instances write one D1 concurrently without contention", async ({ + expect, + }) => { + // This is the scenario that produces cross-process SQLITE_BUSY today: many + // processes opening the same SQLite file. With a shared owner, only the + // owner opens it, so concurrent writes from all clients succeed. + const persistRoot = await useTmp(); + const registryPath = await useTmp(); + const prevGrace = process.env.MINIFLARE_STORAGE_OWNER_GRACE_MS; + const prevCheck = process.env.MINIFLARE_STORAGE_OWNER_IDLE_CHECK_MS; + process.env.MINIFLARE_STORAGE_OWNER_GRACE_MS = "500"; + process.env.MINIFLARE_STORAGE_OWNER_IDLE_CHECK_MS = "200"; + + const N = 3; // client instances + const M = 20; // inserts per client + const WORKER = `export default { + async fetch(request, env) { + const url = new URL(request.url); + if (url.searchParams.get("init") === "1") { + await env.DB.prepare("CREATE TABLE IF NOT EXISTS t(v INTEGER)").run(); + return new Response("ok"); + } + if (request.method === "PUT") { + await env.DB.prepare("INSERT INTO t(v) VALUES (1)").run(); + return new Response("ok"); + } + const row = await env.DB.prepare("SELECT COUNT(*) AS c FROM t").first(); + return new Response(String(row.c)); + } + }`; + const make = () => + new Miniflare({ + unsafeSharedStorageOwner: true, + defaultPersistRoot: persistRoot, + unsafeDevRegistryPath: registryPath, + compatibilityFlags: ["experimental"], + compatibilityDate: "2025-01-01", + modules: true, + d1Databases: ["DB"], + script: WORKER, + }); + + const clients = Array.from({ length: N }, make); + let ownerPid: number | undefined; + try { + await Promise.all(clients.map((c) => c.ready)); + ownerPid = readStorageOwner(persistRoot)?.pid; + expect(ownerPid).toBeDefined(); + + // Create the table once, then hammer it concurrently from every client. + await clients[0].dispatchFetch("http://x/?init=1").then((r) => r.text()); + + const results = await Promise.all( + clients.flatMap((c) => + Array.from({ length: M }, async () => { + const r = await c.dispatchFetch("http://x/", { method: "PUT" }); + await r.text(); // consume body + return r.status; + }) + ) + ); + // No request failed (e.g. with a 500 from SQLITE_BUSY). + expect(results.every((s) => s === 200)).toBe(true); + + // All writes landed — no lost updates, no contention failures. + const count = await clients[0] + .dispatchFetch("http://x/") + .then((r) => r.text()); + expect(count).toBe(String(N * M)); + } finally { + await Promise.all(clients.map((c) => c.dispose().catch(() => {}))); + if (ownerPid !== undefined && isProcessAlive(ownerPid)) { + try { + process.kill(ownerPid); + } catch {} + } + process.env.MINIFLARE_STORAGE_OWNER_GRACE_MS = prevGrace; + process.env.MINIFLARE_STORAGE_OWNER_IDLE_CHECK_MS = prevCheck; + } + }); + + it("does nothing when the feature flag is off", async ({ expect }) => { + const persistRoot = await useTmp(); + const registryPath = await useTmp(); + const mf = new Miniflare({ + defaultPersistRoot: persistRoot, + unsafeDevRegistryPath: registryPath, + compatibilityFlags: ["experimental"], + modules: true, + script: + "export default { async fetch() { return new Response('plain'); } }", + }); + await mf.ready; + expect(readStorageOwner(persistRoot)).toBeUndefined(); + expect(countLiveStorageClients(persistRoot)).toBe(0); + await mf.dispose(); + }); +}); diff --git a/turbo.json b/turbo.json index 23a264872d2..a6310cde1bd 100644 --- a/turbo.json +++ b/turbo.json @@ -12,6 +12,7 @@ "DOCKER_HOST", "LOCAL_TESTS_WITHOUT_DOCKER", "MINIFLARE_CACHE_DIR", + "MINIFLARE_TEST_SHARED_OWNER", "NODE_EXTRA_CA_CERTS", "PWD", "TEST_REPORT_PATH", From 9e35a0f2aea2fdba1e823c4fa562825ae43547db Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Fri, 3 Jul 2026 12:55:31 +0100 Subject: [PATCH 35/37] [miniflare] Deduplicate the remote-bindings proxy server Extract the shared server logic for the remote-bindings boundary (JSRPC vs fetch dispatch, MF-Binding / MF-URL / MF-Header-* handling) into a single parameterised module, `remote-bindings-proxy-server.ts`, and have both consumers adapt to it: - Miniflare's storage-owner server passes a ":" resolver that forwards the id via the MF-Storage-Owner-Namespace header. - Wrangler's ProxyServerWorker passes the existing env[binding] resolver with its SendEmail / Dispatch Namespace special-cases. The two previously near-identical implementations now share one source of truth; behaviour is unchanged (wrangler remote-bindings and miniflare storage-owner suites both pass). --- .../core/storage-owner-server.worker.ts | 154 ++++++------------ .../shared/remote-bindings-proxy-server.ts | 91 +++++++++++ .../remoteBindings/ProxyServerWorker.ts | 79 ++------- 3 files changed, 161 insertions(+), 163 deletions(-) create mode 100644 packages/miniflare/src/workers/shared/remote-bindings-proxy-server.ts diff --git a/packages/miniflare/src/workers/core/storage-owner-server.worker.ts b/packages/miniflare/src/workers/core/storage-owner-server.worker.ts index 22709ef9677..7d7d80e1ae7 100644 --- a/packages/miniflare/src/workers/core/storage-owner-server.worker.ts +++ b/packages/miniflare/src/workers/core/storage-owner-server.worker.ts @@ -1,111 +1,61 @@ -import { newWorkersRpcResponse } from "capnweb"; import { SharedHeaders } from "../shared/constants"; - -// Owner-side server for the shared "central storage owner". Runs in the -// detached owner process and exposes the owner's local storage services over -// HTTP/WebSocket using the same wire protocol as the remote-bindings proxy -// server (`packages/wrangler/templates/remoteBindings/ProxyServerWorker.ts`). +import { + BindingError, + createRemoteBindingsProxyServer, +} from "../shared/remote-bindings-proxy-server"; + +// Owner-side server for the shared "central storage owner". Runs in the detached +// owner process and exposes the owner's local storage over the remote-bindings +// boundary, reusing the shared proxy server (`remote-bindings-proxy-server.ts`). // // Clients route their storage bindings through the shared remote-proxy client -// worker (`remote-proxy-client.worker.ts`) pointed at this server's address. -// Two transports share the one boundary: -// - Fetch (KV / R2 / D1 / Images): a plain HTTP request carrying -// - `MF-Binding`: a ":" key. The type selects the matching -// object-entry service (bound by `Miniflare`); the id is forwarded via -// `MF-Storage-Owner-Namespace` so a single shared entry service resolves -// any resource — including ids only declared by other clients — without -// a per-id binding. -// - `MF-URL`: the original storage-protocol URL. -// - `MF-Header-*`: the original request headers. -// - JSRPC (Streams / Secrets Store): a capnweb WebSocket session, selected by -// the `Upgrade` header + `MF-Binding` query param, dispatched onto the -// bound entrypoint service. +// worker (`remote-proxy-client.worker.ts`) pointed at this server's address: +// - Fetch (KV / R2 / D1 / Images): `MF-Binding` is a ":" key. The +// type selects the matching object-entry service (bound by `Miniflare`); the +// id is forwarded via `MF-Storage-Owner-Namespace` so a single shared entry +// service resolves any resource — including ids only declared by other +// clients — without a per-id binding. +// - JSRPC (Streams / Secrets Store): the `MF-Binding` query param selects the +// bound entrypoint service directly. type Env = Record; -class BindingError extends Error {} - -// Fetch types (KV/R2/D1/Images) are served by one generic entry service per -// type; the resource id is forwarded separately so a single binding serves any -// id. Returns the entry fetcher and the resource id. -function getFetchBinding( - request: Request, - env: Env -): { fetcher: Fetcher; id: string } { - const bindingKey = request.headers.get("MF-Binding"); - if (!bindingKey) { - throw new BindingError("missing MF-Binding"); - } - const sep = bindingKey.indexOf(":"); - const type = sep === -1 ? bindingKey : bindingKey.slice(0, sep); - const id = sep === -1 ? "" : bindingKey.slice(sep + 1); - const fetcher = env[type]; - if (!fetcher) { - throw new BindingError(`storage type "${type}" not served by owner`); - } - return { fetcher, id }; -} - -function getRpcBinding(request: Request, env: Env): Fetcher { - // For RPC the client (`makeRemoteProxyStub`) puts the binding key in the URL. - const bindingKey = new URL(request.url).searchParams.get("MF-Binding"); - if (!bindingKey) { - throw new BindingError("missing MF-Binding"); - } - const target = env[bindingKey]; - if (!target) { - throw new BindingError( - `storage binding "${bindingKey}" not served by owner` - ); - } - return target; -} - -function isJSRPCBinding(request: Request): boolean { - return ( - request.headers.has("Upgrade") && - new URL(request.url).searchParams.has("MF-Binding") - ); -} - -export default { - async fetch(request, env) { - try { - if (isJSRPCBinding(request)) { - return await newWorkersRpcResponse( - request, - getRpcBinding(request, env) - ); - } - - const { fetcher, id } = getFetchBinding(request, env); - - const originalHeaders = new Headers(); - for (const [name, value] of request.headers) { - if (name.startsWith("mf-header-")) { - originalHeaders.set(name.slice("mf-header-".length), value); - } else if (name === "upgrade") { - // The `Upgrade` header needs to be special-cased to prevent: - // TypeError: Worker tried to return a WebSocket in a response to - // a request which did not contain the header "Upgrade: websocket" - originalHeaders.set(name, value); - } - } - // Tell the shared object-entry service which resource this op targets. - originalHeaders.set(SharedHeaders.STORAGE_OWNER_NAMESPACE, id); - - return await fetcher.fetch( - request.headers.get("MF-URL") ?? "http://example.com", - new Request(request, { - redirect: "manual", - headers: originalHeaders, - }) +export default createRemoteBindingsProxyServer({ + resolveRpcBinding(request, env) { + // For RPC the client (`makeRemoteProxyStub`) puts the binding key in the URL. + const bindingKey = new URL(request.url).searchParams.get("MF-Binding"); + if (!bindingKey) { + throw new BindingError("missing MF-Binding"); + } + const target = env[bindingKey]; + if (!target) { + throw new BindingError( + `storage binding "${bindingKey}" not served by owner` ); - } catch (e) { - if (e instanceof BindingError) { - return new Response(e.message, { status: 400 }); - } - return new Response((e as Error).message, { status: 500 }); } + return target; + }, + resolveFetchBinding(request, env) { + // Fetch types (KV/R2/D1/Images) are served by one generic entry service per + // type; the resource id is forwarded separately so a single binding serves + // any id. + const bindingKey = request.headers.get("MF-Binding"); + if (!bindingKey) { + throw new BindingError("missing MF-Binding"); + } + const sep = bindingKey.indexOf(":"); + const type = sep === -1 ? bindingKey : bindingKey.slice(0, sep); + const id = sep === -1 ? "" : bindingKey.slice(sep + 1); + const fetcher = env[type]; + if (!fetcher) { + throw new BindingError(`storage type "${type}" not served by owner`); + } + return { + fetcher, + rewriteHeaders(headers) { + // Tell the shared object-entry service which resource this op targets. + headers.set(SharedHeaders.STORAGE_OWNER_NAMESPACE, id); + }, + }; }, -} satisfies ExportedHandler; +}); diff --git a/packages/miniflare/src/workers/shared/remote-bindings-proxy-server.ts b/packages/miniflare/src/workers/shared/remote-bindings-proxy-server.ts new file mode 100644 index 00000000000..49f18fc2e50 --- /dev/null +++ b/packages/miniflare/src/workers/shared/remote-bindings-proxy-server.ts @@ -0,0 +1,91 @@ +import { newWorkersRpcResponse } from "capnweb"; + +// Shared server for the remote-bindings boundary. It terminates proxied fetch +// and capnweb JSRPC calls made by the remote-proxy client worker +// (`remote-proxy-client.worker.ts`) and dispatches them onto locally-bound +// services. Two consumers share this one implementation: +// - Wrangler's remote-bindings proxy server +// (`packages/wrangler/templates/remoteBindings/ProxyServerWorker.ts`), which +// exposes a session's remote bindings to a local workerd instance. +// - Miniflare's shared "storage owner" +// (`core/storage-owner-server.worker.ts`), which exposes one process's local +// storage to every other instance sharing a persist root. +// Each consumer supplies its own binding-resolution strategy; the wire protocol +// (MF-Binding / MF-URL / MF-Header-* / capnweb over WebSocket) is identical. + +/** Thrown by a resolver when a requested binding is not served. Yields a 400. */ +export class BindingError extends Error {} + +type RpcTarget = Parameters[1]; + +export type RemoteBindingsProxyConfig = { + /** Resolve the capnweb RPC target for a JSRPC (WebSocket) request. */ + resolveRpcBinding: (request: Request, env: Env) => RpcTarget; + /** + * Resolve the fetcher for a plain fetch request, plus an optional hook to + * rewrite the reconstructed request headers before forwarding. + */ + resolveFetchBinding: ( + request: Request, + env: Env + ) => { fetcher: Fetcher; rewriteHeaders?: (headers: Headers) => void }; + /** Override JSRPC detection (defaults to `isJsRpcRequest`). */ + isJsRpc?: (request: Request) => boolean; +}; + +/** capnweb sessions arrive as a WebSocket upgrade carrying an MF-Binding query. */ +export function isJsRpcRequest(request: Request): boolean { + return ( + request.headers.has("Upgrade") && + new URL(request.url).searchParams.has("MF-Binding") + ); +} + +export function createRemoteBindingsProxyServer( + config: RemoteBindingsProxyConfig +): ExportedHandler { + const isJsRpc = config.isJsRpc ?? isJsRpcRequest; + return { + async fetch(request, env) { + try { + if (isJsRpc(request)) { + return await newWorkersRpcResponse( + request, + config.resolveRpcBinding(request, env) + ); + } + + const { fetcher, rewriteHeaders } = config.resolveFetchBinding( + request, + env + ); + + const originalHeaders = new Headers(); + for (const [name, value] of request.headers) { + if (name.startsWith("mf-header-")) { + originalHeaders.set(name.slice("mf-header-".length), value); + } else if (name === "upgrade") { + // The `Upgrade` header needs to be special-cased to prevent: + // TypeError: Worker tried to return a WebSocket in a response to + // a request which did not contain the header "Upgrade: websocket" + originalHeaders.set(name, value); + } + } + rewriteHeaders?.(originalHeaders); + + return await fetcher.fetch( + request.headers.get("MF-URL") ?? "http://example.com", + new Request(request, { + redirect: "manual", + headers: originalHeaders, + }) + ); + } catch (e) { + if (e instanceof BindingError) { + return new Response(e.message, { status: 400 }); + } + return new Response((e as Error).message, { status: 500 }); + } + }, + }; +} diff --git a/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts b/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts index 0ca9c5b8a6f..da036e579c2 100644 --- a/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts +++ b/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts @@ -1,5 +1,11 @@ -import { newWorkersRpcResponse } from "capnweb"; import { EmailMessage } from "cloudflare:email"; +// The remote-bindings boundary's server logic is shared with Miniflare's storage +// owner. Import the single implementation from Miniflare (a build-time +// dependency; esbuild bundles it into this template) rather than duplicating it. +import { + BindingError, + createRemoteBindingsProxyServer, +} from "../../../miniflare/src/workers/shared/remote-bindings-proxy-server"; type Env = Record; @@ -11,12 +17,6 @@ type SendEmailInput = "EmailMessage::raw": ReadableStream; }; -class BindingNotFoundError extends Error { - constructor(name?: string) { - super(`Binding ${name ? `"${name}"` : ""} not found`); - } -} - /** * For most bindings, we expose them as * - RPC stubs directly to capnweb, or @@ -40,12 +40,12 @@ function getExposedJSRPCBinding(request: Request, env: Env) { const url = new URL(request.url); const bindingName = url.searchParams.get("MF-Binding"); if (!bindingName) { - throw new BindingNotFoundError(); + throw new BindingError("Binding not found"); } const targetBinding = env[bindingName]; if (!targetBinding) { - throw new BindingNotFoundError(bindingName); + throw new BindingError(`Binding "${bindingName}" not found`); } if (targetBinding.constructor.name === "SendEmail") { @@ -79,15 +79,15 @@ function getExposedJSRPCBinding(request: Request, env: Env) { return targetBinding; } -function getExposedFetcher(request: Request, env: Env) { +function getExposedFetcher(request: Request, env: Env): Fetcher { const bindingName = request.headers.get("MF-Binding"); if (!bindingName) { - throw new BindingNotFoundError(); + throw new BindingError("Binding not found"); } const targetBinding = env[bindingName]; if (!targetBinding) { - throw new BindingNotFoundError(bindingName); + throw new BindingError(`Binding "${bindingName}" not found`); } // Special case the Dispatch Namespace binding because it has a top-level synchronous .get() call @@ -101,52 +101,9 @@ function getExposedFetcher(request: Request, env: Env) { return targetBinding as Fetcher; } -/** - * This Worker can proxy two types of remote binding: - * 1. "raw" bindings, where this Worker has been configured to pass through the raw - * fetch from a local workerd instance to the relevant binding - * 2. JSRPC bindings, where this Worker uses capnweb to proxy RPC - * communication in userland. This is always over a WebSocket connection - */ -function isJSRPCBinding(request: Request): boolean { - const url = new URL(request.url); - return request.headers.has("Upgrade") && url.searchParams.has("MF-Binding"); -} - -export default { - async fetch(request, env) { - try { - if (isJSRPCBinding(request)) { - return await newWorkersRpcResponse( - request, - getExposedJSRPCBinding(request, env) - ); - } else { - const fetcher = getExposedFetcher(request, env); - const originalHeaders = new Headers(); - for (const [name, value] of request.headers) { - if (name.startsWith("mf-header-")) { - originalHeaders.set(name.slice("mf-header-".length), value); - } else if (name === "upgrade") { - // The `Upgrade` header needs to be special-cased to prevent: - // TypeError: Worker tried to return a WebSocket in a response to a request which did not contain the header "Upgrade: websocket" - originalHeaders.set(name, value); - } - } - - return await fetcher.fetch( - request.headers.get("MF-URL") ?? "http://example.com", - new Request(request, { - redirect: "manual", - headers: originalHeaders, - }) - ); - } - } catch (e) { - if (e instanceof BindingNotFoundError) { - return new Response(e.message, { status: 400 }); - } - return new Response((e as Error).message, { status: 500 }); - } - }, -} satisfies ExportedHandler; +export default createRemoteBindingsProxyServer({ + resolveRpcBinding: getExposedJSRPCBinding, + resolveFetchBinding: (request, env) => ({ + fetcher: getExposedFetcher(request, env), + }), +}); From d46ce0912d64969108fd2c24ae8f3f15406824ba Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Fri, 17 Jul 2026 16:19:18 +0100 Subject: [PATCH 36/37] [miniflare] Move storage-owner per-plugin logic into the plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Push the storage/remote-owner routing knowledge out of `Miniflare` and into each storage plugin, so `index.ts` no longer type-switches on plugin key. Two optional `PluginBase` hooks own the per-plugin shapes: - `routeBindingToStorageOwner` — rewrite a local storage binding to route through the storage-owner proxy (KV/R2/D1/Stream/Secrets); Images stays self-handled in its `getServices`. - `getStorageOwnerHosting` — describe how a spawned owner hosts the plugin's local storage (owner-process options + owner-server bindings). `extractObjectEntryId` and `storageOwnerProxyDesignator` move to `plugins/shared` (the client-proxy service name goes with them), and `Miniflare` now iterates plugins generically for the client rewrite, the owner bindings, and the owner spawn config. Net `index.ts` change is -263 lines; no behaviour change. --- packages/miniflare/src/index.ts | 343 ++---------------- packages/miniflare/src/plugins/d1/index.ts | 57 +++ .../miniflare/src/plugins/images/index.ts | 19 + packages/miniflare/src/plugins/kv/index.ts | 37 ++ packages/miniflare/src/plugins/r2/index.ts | 33 ++ .../src/plugins/secret-store/index.ts | 56 +++ .../miniflare/src/plugins/shared/constants.ts | 39 ++ .../miniflare/src/plugins/shared/index.ts | 32 ++ .../miniflare/src/plugins/stream/index.ts | 35 ++ 9 files changed, 348 insertions(+), 303 deletions(-) diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index e9bc588de57..768f92e431a 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -45,30 +45,21 @@ import { getPersistPath, HELLO_WORLD_PLUGIN_NAME, HOST_CAPNP_CONNECT, - IMAGES_NS_DATA_SERVICE_NAME, IMAGES_PLUGIN_NAME, - KV_LOCAL_ENTRY_SERVICE_NAME, KV_PLUGIN_NAME, launchBrowser, loadExternalPlugins, - namespaceEntries, namespaceKeys, normaliseDurableObject, PLUGIN_ENTRIES, - buildRemoteProxyProps, - D1_LOCAL_ENTRY_SERVICE_NAME, ProxyClient, ProxyNodeBinding, QUEUES_PLUGIN_NAME, QueuesError, - R2_LOCAL_ENTRY_SERVICE_NAME, R2_PLUGIN_NAME, - getUserBindingServiceName, remoteProxyClientWorker, SECRET_STORE_PLUGIN_NAME, - SECRET_STORE_SECRET_ENTRYPOINT, - STREAM_BINDING_ENTRYPOINT, - STREAM_BINDING_SERVICE_NAME, + SERVICE_STORAGE_OWNER_PROXY, STREAM_PLUGIN_NAME, SERVICE_DEV_REGISTRY_PROXY, SERVICE_ENTRY, @@ -135,7 +126,6 @@ import { CorePaths, LogLevel, Mutex, - SharedBindings, SharedHeaders, SiteBindings, } from "./workers"; @@ -200,12 +190,10 @@ import type { Duplex, Transform, Writable } from "node:stream"; import type { Dispatcher, Response as UndiciResponse } from "undici"; const DEFAULT_HOST = "127.0.0.1"; -// Client-side service that proxies routed storage bindings to the owner over -// HTTP. This reuses the remote-bindings ("mixed-mode") client worker: each -// routed binding carries the owner's address + resource key via props. -const SERVICE_STORAGE_OWNER_PROXY = "storage-owner-proxy"; // Owner-side service + socket exposing the owner's local storage entry services -// over HTTP (the remote-bindings proxy-server protocol). +// over HTTP (the remote-bindings proxy-server protocol). The client-side proxy +// service (`SERVICE_STORAGE_OWNER_PROXY`) is defined in `./plugins`, where the +// storage plugins reference it when routing bindings to the owner. const SERVICE_STORAGE_OWNER_SERVER = "storage-owner-server"; const SOCKET_STORAGE_OWNER = "storage-owner"; @@ -784,212 +772,6 @@ function getExternalServiceEntrypoints(allWorkerOpts: PluginWorkerOptions[]) { return externalServices; } -/** - * Extracts the resource id carried in an object-entry binding's `props.json` - * (written by `buildObjectEntryProps`), or `undefined` if the props don't carry - * one (e.g. remote/mixed-mode bindings). - */ -function extractObjectEntryId( - propsJson: string | undefined -): string | undefined { - if (propsJson === undefined) { - return undefined; - } - try { - const parsed = JSON.parse(propsJson) as Record; - const id = parsed[SharedBindings.TEXT_NAMESPACE]; - return typeof id === "string" ? id : undefined; - } catch { - return undefined; - } -} - -// Resource keys advertised to the owner via `MF-Binding` (see -// `storage-owner-server.worker.ts`). Type-prefixed so KV/R2/D1 ids can never -// collide in the owner's binding namespace. -function storageOwnerResourceKey(type: "kv" | "r2" | "d1", id: string): string { - return `${type}:${id}`; -} - -/** - * Builds a binding designator routing a storage op through the client-side - * remote-proxy worker to the owner. The owner address + resource key travel via - * props (read by `remote-proxy-client.worker.ts`). - */ -function storageOwnerProxyDesignator( - conn: RemoteProxyConnectionString, - resourceKey: string -) { - return { - name: SERVICE_STORAGE_OWNER_PROXY, - props: buildRemoteProxyProps(conn, resourceKey), - }; -} - -/** - * If `binding` is a *local* storage binding pointing at a shared object-entry - * service with a resource id in props, rewrite it to route through the - * client-side storage-owner proxy (so the owner process performs the storage - * I/O). Remote (mixed-mode) bindings — which carry no object-entry id — are - * returned unchanged. - */ -function rewriteStorageOwnerBinding( - binding: Worker_Binding, - conn: RemoteProxyConnectionString, - pluginKey: string -): Worker_Binding { - // Streams: a single per-instance store accessed over RPC. Repoint the whole - // binding at the owner proxy (the remote-proxy client carries the RPC); the - // owner hosts the stream entrypoint + store. - if ( - pluginKey === STREAM_PLUGIN_NAME && - "service" in binding && - binding.service?.name !== undefined - ) { - return { - name: binding.name, - service: storageOwnerProxyDesignator(conn, "stream"), - }; - } - // Secrets Store: per-secret RPC service. Repoint at the owner proxy, keyed by - // "secrets::" (extracted from the local service name). - if ( - pluginKey === SECRET_STORE_PLUGIN_NAME && - "service" in binding && - binding.service?.name !== undefined - ) { - const resource = binding.service.name.slice( - `${SECRET_STORE_PLUGIN_NAME}:`.length - ); - return { - name: binding.name, - service: storageOwnerProxyDesignator(conn, `secrets:${resource}`), - }; - } - // KV namespace bindings. - if ("kvNamespace" in binding && binding.kvNamespace?.name !== undefined) { - const id = extractObjectEntryId(binding.kvNamespace.props?.json); - if (id !== undefined) { - return { - name: binding.name, - kvNamespace: storageOwnerProxyDesignator( - conn, - storageOwnerResourceKey("kv", id) - ), - }; - } - } - // R2 bucket bindings. - if ("r2Bucket" in binding && binding.r2Bucket?.name !== undefined) { - const id = extractObjectEntryId(binding.r2Bucket.props?.json); - if (id !== undefined) { - return { - name: binding.name, - r2Bucket: storageOwnerProxyDesignator( - conn, - storageOwnerResourceKey("r2", id) - ), - }; - } - } - // D1 (pre-Wrangler-3.3 `__D1_BETA__`) service binding. - if ("service" in binding && binding.service?.name !== undefined) { - const id = extractObjectEntryId(binding.service.props?.json); - if (id !== undefined) { - return { - name: binding.name, - service: storageOwnerProxyDesignator( - conn, - storageOwnerResourceKey("d1", id) - ), - }; - } - } - // D1 (post-3.3) wrapped binding: rewrite the inner fetcher service designator. - if ("wrapped" in binding && binding.wrapped?.innerBindings !== undefined) { - let rewrote = false; - const innerBindings = binding.wrapped.innerBindings.map((inner) => { - if ("service" in inner && inner.service?.name !== undefined) { - const id = extractObjectEntryId(inner.service.props?.json); - if (id !== undefined) { - rewrote = true; - return { - ...inner, - service: storageOwnerProxyDesignator( - conn, - storageOwnerResourceKey("d1", id) - ), - }; - } - } - return inner; - }); - if (rewrote) { - return { - ...binding, - wrapped: { ...binding.wrapped, innerBindings }, - }; - } - } - return binding; -} - -/** - * Collects the union of *local* (non-remote) KV/R2/D1 resource ids declared - * across the given workers. Used both to tell a spawned owner which storage to - * stand up and to bind those resources on the owner's HTTP storage server. The - * entry services route by `idFromName`, so the owner additionally serves ids - * declared only by other clients. - */ -function collectLocalStorageIds(workerOpts: PluginWorkerOptions[]): { - kv: Set; - r2: Set; - d1: Set; - stream: boolean; - images: boolean; - secrets: Map; -} { - const kv = new Set(); - const r2 = new Set(); - const d1 = new Set(); - let stream = false; - let images = false; - const secrets = new Map(); - for (const opts of workerOpts) { - for (const [, ns] of namespaceEntries(opts.kv.kvNamespaces)) { - if (!ns.remoteProxyConnectionString) { - kv.add(ns.id); - } - } - for (const [, bucket] of namespaceEntries(opts.r2.r2Buckets)) { - if (!bucket.remoteProxyConnectionString) { - r2.add(bucket.id); - } - } - for (const [, db] of namespaceEntries(opts.d1.d1Databases)) { - if (!db.remoteProxyConnectionString) { - d1.add(db.id); - } - } - if (opts.stream.stream && !opts.stream.stream.remoteProxyConnectionString) { - stream = true; - } - if (opts.images.images && !opts.images.images.remoteProxyConnectionString) { - images = true; - } - const secretsStoreSecrets = - opts[SECRET_STORE_PLUGIN_NAME]?.secretsStoreSecrets; - if (secretsStoreSecrets) { - for (const { store_id, secret_name } of Object.values( - secretsStoreSecrets - )) { - secrets.set(`${store_id}:${secret_name}`, { store_id, secret_name }); - } - } - } - return { kv, r2, d1, stream, images, secrets }; -} - function invalidWrappedAsBound(name: string, bindingType: string): never { const stringName = JSON.stringify(name); throw new MiniflareCoreError( @@ -2480,16 +2262,16 @@ export class Miniflare { ); if (pluginBindings !== undefined) { for (const originalBinding of pluginBindings) { - // When routing this plugin's storage to a shared owner, repoint - // local storage bindings at the storage-owner proxy. + // When routing this plugin's storage to a shared owner, let the + // plugin repoint its local storage bindings at the storage-owner + // proxy (plugins own the knowledge of their binding shapes). const binding = storageOwnerRoutePlugins.has(key) && storageOwnerConn !== undefined - ? rewriteStorageOwnerBinding( + ? (plugin.routeBindingToStorageOwner?.( originalBinding, - storageOwnerConn, - key - ) + storageOwnerConn + ) ?? originalBinding) : originalBinding; // If this is the Workers Sites manifest, we need to add it as a // module for modules workers. For all other bindings, and in @@ -2785,55 +2567,21 @@ export class Miniflare { // Bind one generic entry service per storage type the owner stood up. // The resource id travels per-request (via header), so these serve any // id — including ones only declared by clients that join later. - const ids = collectLocalStorageIds(allWorkerOpts); + // Each storage plugin contributes the bindings exposing its local + // storage, keyed by the resource key clients route to. Fetch-type + // plugins (KV/R2/D1/Images) bind one generic entry service that serves + // any id via `idFromName` (the id travels per-request), so they serve + // ids declared only by clients that join later too. const ownerBindings: Worker_Binding[] = []; - if (ids.kv.size > 0) { - ownerBindings.push({ - name: "kv", - service: { name: KV_LOCAL_ENTRY_SERVICE_NAME }, - }); - } - if (ids.r2.size > 0) { - ownerBindings.push({ - name: "r2", - service: { name: R2_LOCAL_ENTRY_SERVICE_NAME }, - }); - } - if (ids.d1.size > 0) { - ownerBindings.push({ - name: "d1", - service: { name: D1_LOCAL_ENTRY_SERVICE_NAME }, - }); - } - // Images: single fixed store, served via the fetch path like KV. - if (ids.images) { - ownerBindings.push({ - name: "images", - service: { name: IMAGES_NS_DATA_SERVICE_NAME }, - }); - } - // Streams: single RPC entrypoint (one store per owner), exposed under - // the "stream" key and dispatched via the JSRPC branch of the server. - if (ids.stream) { - ownerBindings.push({ - name: "stream", - service: { - name: STREAM_BINDING_SERVICE_NAME, - entrypoint: STREAM_BINDING_ENTRYPOINT, - }, - }); - } - // Secrets Store: one RPC entrypoint per secret, exposed under - // "secrets::". - for (const { store_id, secret_name } of ids.secrets.values()) { - const resource = `${store_id}:${secret_name}`; - ownerBindings.push({ - name: `secrets:${resource}`, - service: { - name: getUserBindingServiceName(SECRET_STORE_PLUGIN_NAME, resource), - entrypoint: SECRET_STORE_SECRET_ENTRYPOINT, - }, - }); + for (const [key, plugin] of this.#mergedPluginEntries) { + const hosting = plugin.getStorageOwnerHosting?.( + // @ts-expect-error each plugin narrows `options` to its own schema; + // safe because `getStorageOwnerHosting` only reads its own options. + allWorkerOpts.map((o) => this.#getWorkerOptsForPlugin(key, o)) + ); + if (hosting !== undefined) { + ownerBindings.push(...hosting.ownerBindings); + } } services.set(SERVICE_STORAGE_OWNER_SERVER, { name: SERVICE_STORAGE_OWNER_SERVER, @@ -3313,39 +3061,28 @@ export class Miniflare { * {@link runStorageOwnerProcess}). */ #spawnStorageOwner(persistRoot: string): void { - // Union of local (non-remote) storage resource ids across all workers, so - // the owner stands up the corresponding storage services. The services are - // generic (keyed by `idFromName`), so they additionally serve ids declared - // only by other clients. - const ids = collectLocalStorageIds(this.#workerOpts); - - const ownerOptions = { + // Each storage plugin describes the options a spawned owner needs to stand + // up its local storage (the union of local, non-remote resources across + // this instance's workers). The owner's entry services are generic (keyed + // by `idFromName`), so they additionally serve ids declared only by other + // clients. + const ownerOptions: Record = { defaultPersistRoot: persistRoot, unsafeDevRegistryPath: this.#sharedOpts.core.unsafeDevRegistryPath, modules: true, script: "export default { async fetch() { return new Response('miniflare storage owner', { status: 404 }); } }", - kvNamespaces: [...ids.kv], - r2Buckets: [...ids.r2], - d1Databases: [...ids.d1], - // One stream store per owner; the binding name is irrelevant (the - // owner exposes it under the canonical "stream" key). - ...(ids.stream ? { stream: { binding: "stream" } } : {}), - // One images store per owner (binding name irrelevant). - ...(ids.images ? { images: { binding: "images" } } : {}), - // Secrets Store: recreate each secret resource so the owner stands up - // the matching per-secret service (binding names are irrelevant). - ...(ids.secrets.size > 0 - ? { - secretsStoreSecrets: Object.fromEntries( - [...ids.secrets.entries()].map(([resource, secret]) => [ - `owner:${resource}`, - secret, - ]) - ), - } - : {}), }; + for (const [key, plugin] of this.#mergedPluginEntries) { + const hosting = plugin.getStorageOwnerHosting?.( + // @ts-expect-error each plugin narrows `options` to its own schema; + // safe because `getStorageOwnerHosting` only reads its own options. + this.#workerOpts.map((o) => this.#getWorkerOptsForPlugin(key, o)) + ); + if (hosting !== undefined) { + Object.assign(ownerOptions, hosting.ownerOptions); + } + } const configPath = path.join( persistRoot, diff --git a/packages/miniflare/src/plugins/d1/index.ts b/packages/miniflare/src/plugins/d1/index.ts index ea11cbb92b2..3d3c90f2ceb 100644 --- a/packages/miniflare/src/plugins/d1/index.ts +++ b/packages/miniflare/src/plugins/d1/index.ts @@ -6,6 +6,7 @@ import { SharedBindings } from "../../workers"; import { buildObjectEntryProps, buildRemoteProxyProps, + extractObjectEntryId, getMiniflareObjectBindings, getPersistPath, migrateDatabase, @@ -16,6 +17,7 @@ import { ProxyNodeBinding, remoteProxyClientWorker, SERVICE_LOOPBACK, + storageOwnerProxyDesignator, } from "../shared"; import type { Service, @@ -216,6 +218,61 @@ export const D1_PLUGIN: Plugin< return services; }, + routeBindingToStorageOwner(binding, conn) { + // Pre-Wrangler-3.3 `__D1_BETA__` binding: a bare service designator. + if ("service" in binding && binding.service?.name !== undefined) { + const id = extractObjectEntryId(binding.service.props?.json); + if (id !== undefined) { + return { + name: binding.name, + service: storageOwnerProxyDesignator(conn, `d1:${id}`), + }; + } + } + // Post-3.3 wrapped binding: rewrite the inner fetcher service designator. + if ("wrapped" in binding && binding.wrapped?.innerBindings !== undefined) { + let rewrote = false; + const innerBindings = binding.wrapped.innerBindings.map((inner) => { + if ("service" in inner && inner.service?.name !== undefined) { + const id = extractObjectEntryId(inner.service.props?.json); + if (id !== undefined) { + rewrote = true; + return { + ...inner, + service: storageOwnerProxyDesignator(conn, `d1:${id}`), + }; + } + } + return inner; + }); + if (rewrote) { + return { + ...binding, + wrapped: { ...binding.wrapped, innerBindings }, + }; + } + } + return undefined; + }, + getStorageOwnerHosting(allOptions) { + const ids = new Set(); + for (const options of allOptions) { + for (const [, db] of namespaceEntries(options.d1Databases)) { + if (!db.remoteProxyConnectionString) { + ids.add(db.id); + } + } + } + if (ids.size === 0) { + return undefined; + } + return { + ownerOptions: { d1Databases: [...ids] }, + ownerBindings: [ + { name: "d1", service: { name: D1_LOCAL_ENTRY_SERVICE_NAME } }, + ], + }; + }, getPersistPath({ d1Persist }, tmpPath) { return getPersistPath(D1_PLUGIN_NAME, tmpPath, undefined, d1Persist); }, diff --git a/packages/miniflare/src/plugins/images/index.ts b/packages/miniflare/src/plugins/images/index.ts index 4e700933e62..3f77f3147e8 100644 --- a/packages/miniflare/src/plugins/images/index.ts +++ b/packages/miniflare/src/plugins/images/index.ts @@ -236,6 +236,25 @@ export const IMAGES_PLUGIN: Plugin< return [storageService, objectService, kvNamespaceService, imagesService]; }, + getStorageOwnerHosting(allOptions) { + const hasLocal = allOptions.some( + (options) => options.images && !options.images.remoteProxyConnectionString + ); + if (!hasLocal) { + return undefined; + } + // One images store per owner (binding name irrelevant). Served via the + // fetch path like KV, under the "images" key. Note the client side is + // handled in `getServices` (the transform worker stays local, only its + // backing KV store is repointed at the owner), so there is no + // `routeBindingToStorageOwner` hook. + return { + ownerOptions: { images: { binding: "images" } }, + ownerBindings: [ + { name: "images", service: { name: IMAGES_NS_DATA_SERVICE_NAME } }, + ], + }; + }, getPersistPath({ imagesPersist }, tmpPath) { return getPersistPath( IMAGES_PLUGIN_NAME, diff --git a/packages/miniflare/src/plugins/kv/index.ts b/packages/miniflare/src/plugins/kv/index.ts index 4e7bb5630f6..b334fdd1ea9 100644 --- a/packages/miniflare/src/plugins/kv/index.ts +++ b/packages/miniflare/src/plugins/kv/index.ts @@ -6,6 +6,7 @@ import { SharedBindings } from "../../workers"; import { buildObjectEntryProps, buildRemoteProxyProps, + extractObjectEntryId, getMiniflareObjectBindings, getPersistPath, migrateDatabase, @@ -16,6 +17,7 @@ import { ProxyNodeBinding, remoteProxyClientWorker, SERVICE_LOOPBACK, + storageOwnerProxyDesignator, } from "../shared"; import { KV_PLUGIN_NAME } from "./constants"; import { @@ -239,6 +241,41 @@ export const KV_PLUGIN: Plugin< return services; }, + routeBindingToStorageOwner(binding, conn) { + if ("kvNamespace" in binding && binding.kvNamespace?.name !== undefined) { + const id = extractObjectEntryId(binding.kvNamespace.props?.json); + if (id !== undefined) { + return { + name: binding.name, + kvNamespace: storageOwnerProxyDesignator(conn, `kv:${id}`), + }; + } + } + return undefined; + }, + + getStorageOwnerHosting(allOptions) { + const ids = new Set(); + for (const options of allOptions) { + for (const [, ns] of namespaceEntries(options.kvNamespaces)) { + if (!ns.remoteProxyConnectionString) { + ids.add(ns.id); + } + } + } + if (ids.size === 0) { + return undefined; + } + // One generic entry service serves any id (routed by `idFromName`), so a + // single binding keyed by type suffices — the id travels per-request. + return { + ownerOptions: { kvNamespaces: [...ids] }, + ownerBindings: [ + { name: "kv", service: { name: KV_LOCAL_ENTRY_SERVICE_NAME } }, + ], + }; + }, + getPersistPath({ kvPersist }, tmpPath) { return getPersistPath(KV_PLUGIN_NAME, tmpPath, undefined, kvPersist); }, diff --git a/packages/miniflare/src/plugins/r2/index.ts b/packages/miniflare/src/plugins/r2/index.ts index 6f37e32e98f..8722db82048 100644 --- a/packages/miniflare/src/plugins/r2/index.ts +++ b/packages/miniflare/src/plugins/r2/index.ts @@ -6,6 +6,7 @@ import { SharedBindings } from "../../workers"; import { buildObjectEntryProps, buildRemoteProxyProps, + extractObjectEntryId, getMiniflareObjectBindings, getPersistPath, migrateDatabase, @@ -16,6 +17,7 @@ import { ProxyNodeBinding, remoteProxyClientWorker, SERVICE_LOOPBACK, + storageOwnerProxyDesignator, } from "../shared"; import type { Service, @@ -220,6 +222,37 @@ export const R2_PLUGIN: Plugin< return services; }, + routeBindingToStorageOwner(binding, conn) { + if ("r2Bucket" in binding && binding.r2Bucket?.name !== undefined) { + const id = extractObjectEntryId(binding.r2Bucket.props?.json); + if (id !== undefined) { + return { + name: binding.name, + r2Bucket: storageOwnerProxyDesignator(conn, `r2:${id}`), + }; + } + } + return undefined; + }, + getStorageOwnerHosting(allOptions) { + const ids = new Set(); + for (const options of allOptions) { + for (const [, bucket] of namespaceEntries(options.r2Buckets)) { + if (!bucket.remoteProxyConnectionString) { + ids.add(bucket.id); + } + } + } + if (ids.size === 0) { + return undefined; + } + return { + ownerOptions: { r2Buckets: [...ids] }, + ownerBindings: [ + { name: "r2", service: { name: R2_LOCAL_ENTRY_SERVICE_NAME } }, + ], + }; + }, getPersistPath({ r2Persist }, tmpPath) { return getPersistPath(R2_PLUGIN_NAME, tmpPath, undefined, r2Persist); }, diff --git a/packages/miniflare/src/plugins/secret-store/index.ts b/packages/miniflare/src/plugins/secret-store/index.ts index 6bd173fdb96..8fbcf055417 100644 --- a/packages/miniflare/src/plugins/secret-store/index.ts +++ b/packages/miniflare/src/plugins/secret-store/index.ts @@ -12,6 +12,7 @@ import { PersistenceSchema, ProxyNodeBinding, SERVICE_LOOPBACK, + storageOwnerProxyDesignator, } from "../shared"; import type { Service, Worker_Binding } from "../../runtime"; import type { Plugin } from "../shared"; @@ -187,6 +188,61 @@ export const SECRET_STORE_PLUGIN: Plugin< return [...services, storageService, objectService]; }, + routeBindingToStorageOwner(binding, conn) { + // Per-secret RPC service. Repoint at the owner proxy, keyed by + // "secrets::" (extracted from the local service name). + if ("service" in binding && binding.service?.name !== undefined) { + const resource = binding.service.name.slice( + `${SECRET_STORE_PLUGIN_NAME}:`.length + ); + return { + name: binding.name, + service: storageOwnerProxyDesignator(conn, `secrets:${resource}`), + }; + } + return undefined; + }, + getStorageOwnerHosting(allOptions) { + // Dedupe by ":" across all workers. + const secrets = new Map< + string, + { store_id: string; secret_name: string } + >(); + for (const options of allOptions) { + if (!options.secretsStoreSecrets) { + continue; + } + for (const { store_id, secret_name } of Object.values( + options.secretsStoreSecrets + )) { + secrets.set(`${store_id}:${secret_name}`, { store_id, secret_name }); + } + } + if (secrets.size === 0) { + return undefined; + } + return { + // Recreate each secret resource so the owner stands up the matching + // per-secret service (binding names are irrelevant). + ownerOptions: { + secretsStoreSecrets: Object.fromEntries( + [...secrets.entries()].map(([resource, secret]) => [ + `owner:${resource}`, + secret, + ]) + ), + }, + // One RPC entrypoint per secret, exposed under + // "secrets::" and dispatched via the JSRPC branch. + ownerBindings: [...secrets.keys()].map((resource) => ({ + name: `secrets:${resource}`, + service: { + name: getUserBindingServiceName(SECRET_STORE_PLUGIN_NAME, resource), + entrypoint: SECRET_STORE_SECRET_ENTRYPOINT, + }, + })), + }; + }, getPersistPath({ secretsStorePersist }, tmpPath) { return getPersistPath( SECRET_STORE_PLUGIN_NAME, diff --git a/packages/miniflare/src/plugins/shared/constants.ts b/packages/miniflare/src/plugins/shared/constants.ts index d586e9dbc01..046124e355a 100644 --- a/packages/miniflare/src/plugins/shared/constants.ts +++ b/packages/miniflare/src/plugins/shared/constants.ts @@ -129,6 +129,45 @@ export function buildRemoteProxyProps( }; } +// Inverse of `buildObjectEntryProps`: reads the resource id carried in an +// object-entry binding's `props.json`, or `undefined` if the props don't carry +// one (e.g. remote/mixed-mode bindings). Used by storage plugins to recognise +// their own local bindings when routing them to a shared storage owner. +export function extractObjectEntryId( + propsJson: string | undefined +): string | undefined { + if (propsJson === undefined) { + return undefined; + } + try { + const parsed = JSON.parse(propsJson) as Record; + const id = parsed[SharedBindings.TEXT_NAMESPACE]; + return typeof id === "string" ? id : undefined; + } catch { + return undefined; + } +} + +// Client-side service that proxies routed storage bindings to the shared storage +// owner over HTTP. Reuses the remote-bindings ("mixed-mode") client worker: each +// routed binding carries the owner's address + resource key via props. Stood up +// by `Miniflare` when acting as a storage-owner client. +export const SERVICE_STORAGE_OWNER_PROXY = "storage-owner-proxy"; + +// Builds a binding designator routing a storage op through the client-side +// storage-owner proxy to the owner. The owner address + resource key travel via +// props (read by `remote-proxy-client.worker.ts`). The `resourceKey` is the key +// the owner's storage server dispatches on (see `storage-owner-server.worker.ts`). +export function storageOwnerProxyDesignator( + conn: RemoteProxyConnectionString, + resourceKey: string +): { name: string; props: { json: string } } { + return { + name: SERVICE_STORAGE_OWNER_PROXY, + props: buildRemoteProxyProps(conn, resourceKey), + }; +} + // Value of `unsafeUniqueKey` that forces the use of "colo local" ephemeral // namespaces. These namespaces only provide a `get(id: string): Fetcher` method // and construct objects without a `state` parameter. See the schema for details: diff --git a/packages/miniflare/src/plugins/shared/index.ts b/packages/miniflare/src/plugins/shared/index.ts index be599a8e7d9..c168e3169a9 100644 --- a/packages/miniflare/src/plugins/shared/index.ts +++ b/packages/miniflare/src/plugins/shared/index.ts @@ -119,6 +119,18 @@ export interface ServicesExtensions { extensions: Extension[]; } +// How a shared storage owner should host one plugin's local storage. Returned by +// `PluginBase.getStorageOwnerHosting`. +export interface StorageOwnerHosting { + // Option fragment merged into the detached owner process's `MiniflareOptions` + // so it stands up the corresponding local storage services. + ownerOptions: Record; + // Bindings exposing this plugin's local storage on the owner's HTTP storage + // server, keyed by the resource key clients route to (see + // `storage-owner-server.worker.ts`). + ownerBindings: Worker_Binding[]; +} + export interface PluginBase< Options extends z.ZodType, SharedOptions extends z.ZodType | undefined, @@ -142,6 +154,26 @@ export interface PluginBase< getExtensions?(options: { options: z.infer[]; }): Awaitable; + // Shared storage owner (experimental `unsafeSharedStorageOwner`) hooks. Only + // implemented by plugins whose local storage can be routed to a single owner + // process. `Miniflare` owns the process/presence/routing orchestration; these + // let each plugin own the knowledge of its own binding + resource shapes. + + // Rewrite one of this plugin's *local* storage bindings so the op is served by + // the owner process (via the client-side storage-owner proxy), or return + // `undefined` to leave `binding` unchanged. Called for each binding this plugin + // emits when its storage is routed to an owner. + routeBindingToStorageOwner?( + binding: Worker_Binding, + conn: RemoteProxyConnectionString + ): Worker_Binding | undefined; + // Given every worker's options for this plugin, describe how a shared owner + // should host its local storage, or `undefined` if there's nothing local to + // share. Used both to configure a spawned owner and to bind that storage on + // the owner's HTTP storage server. + getStorageOwnerHosting?( + allOptions: z.infer[] + ): StorageOwnerHosting | undefined; } export type Plugin< diff --git a/packages/miniflare/src/plugins/stream/index.ts b/packages/miniflare/src/plugins/stream/index.ts index dde01c2a22c..663e6490c7a 100644 --- a/packages/miniflare/src/plugins/stream/index.ts +++ b/packages/miniflare/src/plugins/stream/index.ts @@ -11,6 +11,7 @@ import { PersistenceSchema, ProxyNodeBinding, remoteProxyClientWorker, + storageOwnerProxyDesignator, WORKER_BINDING_SERVICE_LOOPBACK, } from "../shared"; import type { Service } from "../../runtime"; @@ -189,6 +190,40 @@ export const STREAM_PLUGIN: Plugin< return [storageService, objectService, bindingService]; }, + routeBindingToStorageOwner(binding, conn) { + // A single per-instance store accessed over RPC. Repoint the whole binding + // at the owner proxy (the remote-proxy client carries the RPC); the owner + // hosts the stream entrypoint + store under the canonical "stream" key. + if ("service" in binding && binding.service?.name !== undefined) { + return { + name: binding.name, + service: storageOwnerProxyDesignator(conn, "stream"), + }; + } + return undefined; + }, + getStorageOwnerHosting(allOptions) { + const hasLocal = allOptions.some( + (options) => options.stream && !options.stream.remoteProxyConnectionString + ); + if (!hasLocal) { + return undefined; + } + // One stream store per owner; the binding name is irrelevant (the owner + // exposes it under the canonical "stream" key, dispatched via JSRPC). + return { + ownerOptions: { stream: { binding: "stream" } }, + ownerBindings: [ + { + name: "stream", + service: { + name: STREAM_BINDING_SERVICE_NAME, + entrypoint: STREAM_BINDING_ENTRYPOINT, + }, + }, + ], + }; + }, getPersistPath({ streamPersist }, tmpPath) { return getPersistPath( STREAM_PLUGIN_NAME, From 340065c2fe4686d841c887a6eca8e1c4684351b2 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Fri, 17 Jul 2026 18:33:07 +0100 Subject: [PATCH 37/37] [miniflare] Drop redundant startup lock, log the storage owner, parameterise persist-sharing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove `withPersistRootStartupLock` (and its `wait` helper/constants) — with the shared storage owner it guarded nothing: routed storage goes to the owner and everything else is per-instance, so no two instances race the same files. `#startRuntime` now calls `runtime.updateConfig` directly. - Give the detached owner a voice — spawn it with stdio redirected to `.miniflare-owner.log` in the persist root (was `stdio: "ignore"`), and have `runStorageOwnerProcess` log start/ready/shutdown and surface uncaught errors, so a broken owner is diagnosable instead of a silent client fallback. - Parameterise `persist-sharing.spec.ts` — add `startPair`/`startMany` helpers, collapse the KV/R2 "same key" and the 4/6/10-instance "distinct keys" tests into `describe.each`, and drop the duplicate `makeMany` (-116 lines). --- packages/miniflare/src/index.ts | 153 ++++----- .../miniflare/test/persist-sharing.spec.ts | 324 ++++++------------ 2 files changed, 166 insertions(+), 311 deletions(-) diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index 768f92e431a..0621cc77d3c 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -218,74 +218,6 @@ const STORAGE_OWNER_IDLE_CHECK_MS = Number(process.env.MINIFLARE_STORAGE_OWNER_IDLE_CHECK_MS) || 1_000; const STORAGE_OWNER_IDLE_DEBOUNCE = 3; -const PERSIST_ROOT_STARTUP_LOCK = ".miniflare-startup.lock"; -const PERSIST_ROOT_STARTUP_LOCK_STALE_MS = 30_000; -const PERSIST_ROOT_STARTUP_LOCK_RETRY_MS = 50; - -async function wait(ms: number): Promise { - await new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function withPersistRootStartupLock( - persistRoot: string | undefined, - signal: AbortSignal, - callback: () => Promise -): Promise { - if (persistRoot === undefined || signal.aborted) { - return callback(); - } - - await mkdir(persistRoot, { recursive: true }); - const lockPath = path.join(persistRoot, PERSIST_ROOT_STARTUP_LOCK); - let lock: fs.promises.FileHandle | undefined; - let heartbeat: NodeJS.Timeout | undefined; - - while (lock === undefined && !signal.aborted) { - try { - lock = await fs.promises.open(lockPath, "wx"); - await lock.writeFile(`${process.pid}\n${Date.now()}\n`); - heartbeat = setInterval(() => { - fs.promises.utimes(lockPath, new Date(), new Date()).catch(() => {}); - }, 1_000); - break; - } catch (e) { - if ((e as NodeJS.ErrnoException).code !== "EEXIST") { - throw e; - } - - try { - const stats = await fs.promises.stat(lockPath); - if ( - stats.mtime.getTime() < - Date.now() - PERSIST_ROOT_STARTUP_LOCK_STALE_MS - ) { - await fs.promises.rm(lockPath, { force: true }); - continue; - } - } catch (statError) { - if ((statError as NodeJS.ErrnoException).code !== "ENOENT") { - throw statError; - } - } - - await wait(PERSIST_ROOT_STARTUP_LOCK_RETRY_MS); - } - } - if (lock === undefined) { - return callback(); - } - - try { - return await callback(); - } finally { - if (heartbeat !== undefined) { - clearInterval(heartbeat); - } - await lock?.close(); - await fs.promises.rm(lockPath, { force: true }); - } -} - function getURLSafeHost(host: string) { return net.isIPv6(host) ? `[${host}]` : host; } @@ -2776,16 +2708,11 @@ export class Miniflare { handleStructuredLogs: this.#sharedOpts.core.handleStructuredLogs, runtimeEnv: this.#sharedOpts.core.unsafeRuntimeEnv, }; - const maybeSocketPorts = await withPersistRootStartupLock( - this.#sharedOpts.core.defaultPersistRoot, - this.#disposeController.signal, - () => - runtime.updateConfig( - configBuffer, - runtimeOpts, - this.#workerOpts.flatMap((w) => w.core.name ?? []), - this.#disposeController.signal - ) + const maybeSocketPorts = await runtime.updateConfig( + configBuffer, + runtimeOpts, + this.#workerOpts.flatMap((w) => w.core.name ?? []), + this.#disposeController.signal ); if (this.#disposeController.signal.aborted) return; if (maybeSocketPorts === undefined) { @@ -3090,16 +3017,27 @@ export class Miniflare { ); fs.writeFileSync(configPath, JSON.stringify(ownerOptions)); - const child = spawn(process.execPath, ["-e", STORAGE_OWNER_BOOTSTRAP], { - detached: true, - stdio: "ignore", - env: { - ...process.env, - [ENV_STORAGE_OWNER_MAIN]: __filename, - [ENV_STORAGE_OWNER_CONFIG]: configPath, - }, - }); - child.unref(); + // The owner is detached and outlives us, so it can't share our stdio. + // Redirect its output to a log file in the persist root — otherwise a + // crashing or misconfigured owner is invisible and clients just silently + // fall back to local storage. + const logPath = path.join(persistRoot, ".miniflare-owner.log"); + const logFd = fs.openSync(logPath, "a"); + try { + const child = spawn(process.execPath, ["-e", STORAGE_OWNER_BOOTSTRAP], { + detached: true, + stdio: ["ignore", logFd, logFd], + env: { + ...process.env, + [ENV_STORAGE_OWNER_MAIN]: __filename, + [ENV_STORAGE_OWNER_CONFIG]: configPath, + }, + }); + child.unref(); + } finally { + // The child has dup'd the fd; close our copy. + fs.closeSync(logFd); + } } /** @@ -3931,6 +3869,27 @@ export class Miniflare { * grace period), so storage processes don't linger. */ export async function runStorageOwnerProcess(): Promise { + // The owner is detached with its stdio redirected to `.miniflare-owner.log` + // in the persist root (see `#spawnStorageOwner`), so these lines are how a + // broken owner makes itself heard rather than failing silently. Writes go + // straight to the process's own stdout/stderr (there is no host `Log` here). + const tag = `[miniflare storage owner ${process.pid}]`; + const ownerLog = (message: string) => + process.stdout.write(`${tag} ${message}\n`); + const ownerError = (message: string, e: unknown) => + process.stderr.write( + `${tag} ${message} ${e instanceof Error ? (e.stack ?? e.message) : String(e)}\n` + ); + // Surface anything that would otherwise kill the process silently. + process.on("uncaughtException", (e) => { + ownerError("uncaught:", e); + process.exit(1); + }); + process.on("unhandledRejection", (e) => { + ownerError("unhandled:", e); + process.exit(1); + }); + const configPath = process.env[ENV_STORAGE_OWNER_CONFIG]; assert(configPath !== undefined, `${ENV_STORAGE_OWNER_CONFIG} must be set`); const options = JSON.parse( @@ -3945,6 +3904,7 @@ export async function runStorageOwnerProcess(): Promise { persistRoot !== undefined, "storage owner config must set `defaultPersistRoot`" ); + ownerLog(`starting for persist root ${persistRoot}`); const mf = new Miniflare({ ...options, @@ -3955,11 +3915,12 @@ export async function runStorageOwnerProcess(): Promise { let disposing = false; // Holder so `shutdown` (defined before the interval is created) can clear it. const timers: { idle?: NodeJS.Timeout } = {}; - const shutdown = async () => { + const shutdown = async (reason: string) => { if (disposing) { return; } disposing = true; + ownerLog(`shutting down (${reason})`); if (timers.idle !== undefined) { clearInterval(timers.idle); } @@ -3969,10 +3930,16 @@ export async function runStorageOwnerProcess(): Promise { process.exit(0); } }; - process.on("SIGTERM", () => void shutdown()); - process.on("SIGINT", () => void shutdown()); + process.on("SIGTERM", () => void shutdown("SIGTERM")); + process.on("SIGINT", () => void shutdown("SIGINT")); - await mf.ready; + try { + await mf.ready; + } catch (e) { + ownerError("failed to start:", e); + process.exit(1); + } + ownerLog("ready"); // Self-teardown: once past the startup grace, exit after a debounced run of // checks observing zero live clients. @@ -3985,7 +3952,7 @@ export async function runStorageOwnerProcess(): Promise { if (countLiveStorageClients(persistRoot) === 0) { idleChecks++; if (idleChecks >= STORAGE_OWNER_IDLE_DEBOUNCE) { - void shutdown(); + void shutdown("no live clients"); } } else { idleChecks = 0; diff --git a/packages/miniflare/test/persist-sharing.spec.ts b/packages/miniflare/test/persist-sharing.spec.ts index 2b265e31cc6..48122df648a 100644 --- a/packages/miniflare/test/persist-sharing.spec.ts +++ b/packages/miniflare/test/persist-sharing.spec.ts @@ -1388,76 +1388,103 @@ describe.sequential("defaultPersistRoot sharing", () => { return (await (await mf.dispatchFetch(`http://x${path}`)).json()) as T; } - test("stress: four instances concurrently write distinct KV keys via workers", async ({ - expect, - }) => { - const root = await useTmp(); - const names = ["a", "b", "c", "d"]; - const mfs = names.map((n) => makeStress(root, n)); - await Promise.all(mfs.map((mf) => mf.ready)); + // Start two instances sharing `root` and wait for both to be ready. + async function startPair(root: string): Promise<[Miniflare, Miniflare]> { + const a = makeStress(root, "a"); + const b = makeStress(root, "b"); + await Promise.all([a.ready, b.ready]); + return [a, b]; + } - const PER = 25; - const statuses = await Promise.all( - mfs.flatMap((mf, idx) => - Array.from({ length: PER }, (_unused, i) => - fire(mf, `/kv/put?key=k-${idx}-${i}`, "v") - ) - ) + // Start `count` instances sharing `root` (named p0..pN) and wait for ready. + async function startMany( + root: string, + count: number + ): Promise { + const mfs = Array.from({ length: count }, (_unused, i) => + makeStress(root, `p${i}`) ); - expect(statuses.filter((s) => s !== 200)).toEqual([]); + await Promise.all(mfs.map((mf) => mf.ready)); + return mfs; + } - // A fresh reader sees every key written by all four instances. - const reader = makeStress(root, "reader"); - await reader.ready; - const misses: string[] = []; - for (let idx = 0; idx < names.length; idx++) { - for (let i = 0; i < PER; i++) { - const key = `k-${idx}-${i}`; - if ((await bodyOf(reader, `/kv/get?key=${key}`)) !== "v") { - misses.push(key); + describe.each([ + { instances: 4, per: 25 }, + { instances: 6, per: 20 }, + { instances: 10, per: 30 }, + ])( + "stress: $instances instances concurrently write distinct KV keys", + ({ instances, per }) => { + test("all present with exact values", async ({ expect }) => { + const root = await useTmp(); + const mfs = await startMany(root, instances); + + const ops = mfs.flatMap((mf, idx) => + Array.from({ length: per }, (_unused, i) => + fire(mf, `/kv/put?key=dk-${idx}-${i}`, `val-${idx}-${i}`) + ) + ); + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + // A fresh reader process sees every key with the exact value written. + const reader = makeStress(root, "reader"); + await reader.ready; + const total = instances * per; + expect( + ( + await jsonOf<{ count: number }>( + reader, + "/kv/listcount?prefix=dk-" + ) + ).count + ).toBe(total); + const bad: string[] = []; + for (let idx = 0; idx < instances; idx++) { + for (let i = 0; i < per; i++) { + const v = await bodyOf(reader, `/kv/get?key=dk-${idx}-${i}`); + if (v !== `val-${idx}-${i}`) { + bad.push(`dk-${idx}-${i}=${v}`); + } + } } - } + expect(bad).toEqual([]); + }); } - expect(misses).toEqual([]); - }); - - test("stress: concurrent writes to the SAME KV key never error and converge", async ({ - expect, - }) => { - const root = await useTmp(); - const a = makeStress(root, "a"); - const b = makeStress(root, "b"); - await a.ready; - await b.ready; - - const N = 50; - const ops: Promise[] = []; - const candidates = new Set(); - for (let i = 0; i < N; i++) { - const va = `a-${i}`; - const vb = `b-${i}`; - candidates.add(va); - candidates.add(vb); - ops.push(fire(a, "/kv/put?key=hot", va)); - ops.push(fire(b, "/kv/put?key=hot", vb)); - } - expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + ); + + describe.each([ + { kind: "KV", put: "/kv/put", get: "/kv/get", n: 50 }, + { kind: "R2", put: "/r2/put", get: "/r2/get", n: 40 }, + ])("stress: concurrent writes to the SAME $kind key", ({ put, get, n }) => { + test("never error and converge", async ({ expect }) => { + const root = await useTmp(); + const [a, b] = await startPair(root); + + const ops: Promise[] = []; + const candidates = new Set(); + for (let i = 0; i < n; i++) { + const va = `a-${i}`; + const vb = `b-${i}`; + candidates.add(va); + candidates.add(vb); + ops.push(fire(a, `${put}?key=hot`, va)); + ops.push(fire(b, `${put}?key=hot`, vb)); + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); - // After writes settle, both processes read the SAME, valid final value. - const fromA = await bodyOf(a, "/kv/get?key=hot"); - const fromB = await bodyOf(b, "/kv/get?key=hot"); - expect(fromA).toBe(fromB); - expect(candidates.has(fromA)).toBe(true); + // After writes settle, both processes read the SAME, valid final value. + const fromA = await bodyOf(a, `${get}?key=hot`); + const fromB = await bodyOf(b, `${get}?key=hot`); + expect(fromA).toBe(fromB); + expect(candidates.has(fromA)).toBe(true); + }); }); test("correctness: concurrent atomic D1 increments across instances lose no updates", async ({ expect, }) => { const root = await useTmp(); - const a = makeStress(root, "a"); - const b = makeStress(root, "b"); - await a.ready; - await b.ready; + const [a, b] = await startPair(root); await fire( a, "/d1/exec", @@ -1486,10 +1513,7 @@ describe.sequential("defaultPersistRoot sharing", () => { expect, }) => { const root = await useTmp(); - const a = makeStress(root, "a"); - const b = makeStress(root, "b"); - await a.ready; - await b.ready; + const [a, b] = await startPair(root); const PER = 30; const ops: Promise[] = []; @@ -1553,10 +1577,7 @@ describe.sequential("defaultPersistRoot sharing", () => { expect, }) => { const root = await useTmp(); - const a = makeStress(root, "a"); - const b = makeStress(root, "b"); - await a.ready; - await b.ready; + const [a, b] = await startPair(root); // Each instance bulk-writes 600 keys under a shared prefix. expect(await fire(a, "/kv/bulkput?prefix=page:a-&n=600")).toBe(200); @@ -1575,10 +1596,7 @@ describe.sequential("defaultPersistRoot sharing", () => { expect, }) => { const root = await useTmp(); - const a = makeStress(root, "a"); - const b = makeStress(root, "b"); - await a.ready; - await b.ready; + const [a, b] = await startPair(root); expect(await fire(a, "/kv/putbin?key=bin&len=4096")).toBe(200); const got = await jsonOf<{ len: number; ok: boolean }>( @@ -1593,10 +1611,7 @@ describe.sequential("defaultPersistRoot sharing", () => { expect, }) => { const root = await useTmp(); - const a = makeStress(root, "a"); - const b = makeStress(root, "b"); - await a.ready; - await b.ready; + const [a, b] = await startPair(root); await fire(a, "/kv/put?key=empty", ""); await fire(a, "/r2/put?key=empty", ""); @@ -1636,10 +1651,7 @@ describe.sequential("defaultPersistRoot sharing", () => { expect, }) => { const root = await useTmp(); - const a = makeStress(root, "a"); - const b = makeStress(root, "b"); - await a.ready; - await b.ready; + const [a, b] = await startPair(root); await fire( a, "/d1/exec", @@ -1672,10 +1684,7 @@ describe.sequential("defaultPersistRoot sharing", () => { expect, }) => { const root = await useTmp(); - const a = makeStress(root, "a"); - const b = makeStress(root, "b"); - await a.ready; - await b.ready; + const [a, b] = await startPair(root); const N = 40; const ops: Promise[] = []; @@ -1697,10 +1706,7 @@ describe.sequential("defaultPersistRoot sharing", () => { expect, }) => { const root = await useTmp(); - const a = makeStress(root, "a"); - const b = makeStress(root, "b"); - await a.ready; - await b.ready; + const [a, b] = await startPair(root); const ddl = "CREATE TABLE IF NOT EXISTS log (id INTEGER PRIMARY KEY AUTOINCREMENT, src TEXT)"; @@ -1720,10 +1726,7 @@ describe.sequential("defaultPersistRoot sharing", () => { expect, }) => { const root = await useTmp(); - const a = makeStress(root, "a"); - const b = makeStress(root, "b"); - await a.ready; - await b.ready; + const [a, b] = await startPair(root); const PER = 40; const ops: Promise[] = []; @@ -1743,42 +1746,11 @@ describe.sequential("defaultPersistRoot sharing", () => { expect(misses).toEqual([]); }); - test("stress: concurrent R2 writes to the SAME key never error and converge", async ({ - expect, - }) => { - const root = await useTmp(); - const a = makeStress(root, "a"); - const b = makeStress(root, "b"); - await a.ready; - await b.ready; - - const N = 40; - const candidates = new Set(); - const ops: Promise[] = []; - for (let i = 0; i < N; i++) { - const va = `a-${i}`; - const vb = `b-${i}`; - candidates.add(va); - candidates.add(vb); - ops.push(fire(a, "/r2/put?key=hot", va)); - ops.push(fire(b, "/r2/put?key=hot", vb)); - } - expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); - - const fromA = await bodyOf(a, "/r2/get?key=hot"); - const fromB = await bodyOf(b, "/r2/get?key=hot"); - expect(fromA).toBe(fromB); - expect(candidates.has(fromA)).toBe(true); - }); - test("stress: concurrent large R2 bodies (blob store) from both instances", async ({ expect, }) => { const root = await useTmp(); - const a = makeStress(root, "a"); - const b = makeStress(root, "b"); - await a.ready; - await b.ready; + const [a, b] = await startPair(root); const LEN = 256 * 1024; // 256 KiB each const PER = 8; @@ -1803,10 +1775,7 @@ describe.sequential("defaultPersistRoot sharing", () => { expect, }) => { const root = await useTmp(); - const a = makeStress(root, "a"); - const b = makeStress(root, "b"); - await a.ready; - await b.ready; + const [a, b] = await startPair(root); const PER = 40; const ops: Promise[] = []; @@ -1839,10 +1808,7 @@ describe.sequential("defaultPersistRoot sharing", () => { expect, }) => { const root = await useTmp(); - const a = makeStress(root, "a"); - const b = makeStress(root, "b"); - await a.ready; - await b.ready; + const [a, b] = await startPair(root); await fire( a, "/d1/exec", @@ -1871,10 +1837,7 @@ describe.sequential("defaultPersistRoot sharing", () => { expect, }) => { const root = await useTmp(); - const a = makeStress(root, "a"); - const b = makeStress(root, "b"); - await a.ready; - await b.ready; + const [a, b] = await startPair(root); await fire( a, "/d1/exec", @@ -1906,10 +1869,7 @@ describe.sequential("defaultPersistRoot sharing", () => { expect, }) => { const root = await useTmp(); - const a = makeStress(root, "a"); - const b = makeStress(root, "b"); - await a.ready; - await b.ready; + const [a, b] = await startPair(root); // The very first thing both processes do is hit every binding type at // once -- this maximises the cold-start open/transition races across all @@ -1941,38 +1901,11 @@ describe.sequential("defaultPersistRoot sharing", () => { ).toBe("v"); }); - test("stress: six instances concurrently writing the same KV namespace", async ({ - expect, - }) => { - const root = await useTmp(); - const names = ["a", "b", "c", "d", "e", "f"]; - const mfs = names.map((n) => makeStress(root, n)); - await Promise.all(mfs.map((mf) => mf.ready)); - - const PER = 20; - const statuses = await Promise.all( - mfs.flatMap((mf, idx) => - Array.from({ length: PER }, (_unused, i) => - fire(mf, `/kv/put?key=six-${idx}-${i}`, "v") - ) - ) - ); - expect(statuses.filter((s) => s !== 200)).toEqual([]); - - expect( - (await jsonOf<{ count: number }>(mfs[0], "/kv/listcount?prefix=six-")) - .count - ).toBe(names.length * PER); - }); - test("stress: concurrent overlapping put/delete/get on a shared KV key space", async ({ expect, }) => { const root = await useTmp(); - const a = makeStress(root, "a"); - const b = makeStress(root, "b"); - await a.ready; - await b.ready; + const [a, b] = await startPair(root); // Both processes interleave puts, deletes and gets over the same small set // of keys. Nothing may error and reads must always return a valid state. @@ -2004,18 +1937,11 @@ describe.sequential("defaultPersistRoot sharing", () => { // assert exact correctness, not just absence of errors. // ------------------------------------------------------------------- - function makeMany(root: string, count: number) { - return Array.from({ length: count }, (_unused, i) => - makeStress(root, `p${i}`) - ); - } - test("high concurrency: 8 processes increment one D1 counter, total is exact", async ({ expect, }) => { const root = await useTmp(); - const mfs = makeMany(root, 8); - await Promise.all(mfs.map((mf) => mf.ready)); + const mfs = await startMany(root, 8); await fire( mfs[0], "/d1/exec", @@ -2041,8 +1967,7 @@ describe.sequential("defaultPersistRoot sharing", () => { expect, }) => { const root = await useTmp(); - const mfs = makeMany(root, 8); - await Promise.all(mfs.map((mf) => mf.ready)); + const mfs = await startMany(root, 8); await fire( mfs[0], "/d1/exec", @@ -2070,8 +1995,7 @@ describe.sequential("defaultPersistRoot sharing", () => { expect, }) => { const root = await useTmp(); - const mfs = makeMany(root, 6); - await Promise.all(mfs.map((mf) => mf.ready)); + const mfs = await startMany(root, 6); await fire( mfs[0], "/d1/exec", @@ -2101,8 +2025,7 @@ describe.sequential("defaultPersistRoot sharing", () => { expect, }) => { const root = await useTmp(); - const mfs = makeMany(root, 6); - await Promise.all(mfs.map((mf) => mf.ready)); + const mfs = await startMany(root, 6); await fire(mfs[0], "/bank/init"); const PER = 20; @@ -2124,44 +2047,11 @@ describe.sequential("defaultPersistRoot sharing", () => { } }); - test("high concurrency: 10 processes write distinct KV keys, all present and correct", async ({ - expect, - }) => { - const root = await useTmp(); - const mfs = makeMany(root, 10); - await Promise.all(mfs.map((mf) => mf.ready)); - - const PER = 30; - const ops = mfs.flatMap((mf, idx) => - Array.from({ length: PER }, (_unused, i) => - fire(mf, `/kv/put?key=hc-${idx}-${i}`, `val-${idx}-${i}`) - ) - ); - expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); - - // A fresh reader process sees every key with the exact value written. - const reader = makeStress(root, "reader"); - await reader.ready; - expect( - (await jsonOf<{ count: number }>(reader, "/kv/listcount?prefix=hc-")) - .count - ).toBe(mfs.length * PER); - const bad: string[] = []; - for (let idx = 0; idx < mfs.length; idx++) { - for (let i = 0; i < PER; i++) { - const v = await bodyOf(reader, `/kv/get?key=hc-${idx}-${i}`); - if (v !== `val-${idx}-${i}`) bad.push(`hc-${idx}-${i}=${v}`); - } - } - expect(bad).toEqual([]); - }); - test("high concurrency: many processes overwrite one KV key, all agree on a real final value", async ({ expect, }) => { const root = await useTmp(); - const mfs = makeMany(root, 8); - await Promise.all(mfs.map((mf) => mf.ready)); + const mfs = await startMany(root, 8); const PER = 20; const candidates = new Set(); @@ -2226,8 +2116,7 @@ describe.sequential("defaultPersistRoot sharing", () => { expect, }) => { const root = await useTmp(); - const mfs = makeMany(root, 6); - await Promise.all(mfs.map((mf) => mf.ready)); + const mfs = await startMany(root, 6); const PER = 10; // First operation each process performs hits every binding type at once. @@ -2252,8 +2141,7 @@ describe.sequential("defaultPersistRoot sharing", () => { expect, }) => { const root = await useTmp(); - const mfs = makeMany(root, 6); - await Promise.all(mfs.map((mf) => mf.ready)); + const mfs = await startMany(root, 6); const KEYS = 6; const ROUNDS = 15;