From 51610fe680d1fe0e85af7aa3120708ca085aafa0 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 16 Apr 2026 16:11:15 +0100 Subject: [PATCH 1/4] [miniflare] Distinguish 'worker not running' from 'incompatible version' in proxy errors When a registry entry exists but lacks debugPortAddress (written by an older wrangler), the proxy now returns a specific message asking the user to update all Wrangler instances, instead of the generic 'not found' message. --- .../dev-registry/tests/dev-registry.test.ts | 2 +- .../core/dev-registry-proxy-shared.worker.ts | 33 ++++++++++++++----- .../workers/core/dev-registry-proxy.worker.ts | 16 ++++----- 3 files changed, 32 insertions(+), 19 deletions(-) diff --git a/fixtures/dev-registry/tests/dev-registry.test.ts b/fixtures/dev-registry/tests/dev-registry.test.ts index a48cc16caf..29316410d9 100644 --- a/fixtures/dev-registry/tests/dev-registry.test.ts +++ b/fixtures/dev-registry/tests/dev-registry.test.ts @@ -934,7 +934,7 @@ describe("Dev Registry: getPlatformProxy -> wrangler / vite dev", () => { expect(response.status).toBe(503); expect(await response.text()).toEqual( - `Worker "worker-entrypoint-with-assets" not found. Make sure it is running locally.` + `Worker "service-worker" is not compatible with this version of the dev server. Please update all Worker instances to the same version.` ); }, waitForTimeout); diff --git a/packages/miniflare/src/workers/core/dev-registry-proxy-shared.worker.ts b/packages/miniflare/src/workers/core/dev-registry-proxy-shared.worker.ts index dcf47db371..ce7dbfd3be 100644 --- a/packages/miniflare/src/workers/core/dev-registry-proxy-shared.worker.ts +++ b/packages/miniflare/src/workers/core/dev-registry-proxy-shared.worker.ts @@ -48,7 +48,29 @@ export function setRegistry(data: Record): void { * Look up a worker's registry entry by service name. */ export function resolveTarget(service: string): RegistryEntry | undefined { - return registry.get(service); + const entry = registry.get(service); + if (!entry || !("debugPortAddress" in entry)) { + return undefined; + } + return entry; +} + +/** + * Check whether a registry entry exists for the given service, even if it's + * from an incompatible wrangler version. + */ +export function hasRegistryEntry(service: string): boolean { + return registry.has(service); +} + +/** + * Return an appropriate error message for a worker that can't be resolved. + */ +export function workerNotFoundMessage(service: string): string { + if (hasRegistryEntry(service)) { + return `Worker "${service}" is not compatible with this version of the dev server. Please update all Worker instances to the same version.`; + } + return `Worker "${service}" not found. Make sure it is running locally.`; } /** @@ -130,9 +152,7 @@ export function createProxyDurableObjectClass({ // workerd probes DO properties (fetch, alarm, etc.) via the get // trap, and throwing here would crash those internal checks. return () => { - throw new Error( - `Worker "${scriptName}" not found. Make sure it is running locally.` - ); + throw new Error(workerNotFoundMessage(scriptName)); }; } return Reflect.get(fetcher, prop); @@ -144,10 +164,7 @@ export function createProxyDurableObjectClass({ const fetcher = this._resolve(); if (!fetcher) { return Promise.resolve( - new Response( - `Worker "${scriptName}" not found. Make sure it is running locally.`, - { status: 503 } - ) + new Response(workerNotFoundMessage(scriptName), { status: 503 }) ); } return fetcher.fetch(request); diff --git a/packages/miniflare/src/workers/core/dev-registry-proxy.worker.ts b/packages/miniflare/src/workers/core/dev-registry-proxy.worker.ts index 6bacde9144..b27e3cf723 100644 --- a/packages/miniflare/src/workers/core/dev-registry-proxy.worker.ts +++ b/packages/miniflare/src/workers/core/dev-registry-proxy.worker.ts @@ -3,6 +3,7 @@ import { resolveTarget, tailEventsReplacer, tailEventsReviver, + workerNotFoundMessage, } from "./dev-registry-proxy-shared.worker"; import type { WorkerdDebugPortConnector } from "./dev-registry-proxy-shared.worker"; @@ -75,9 +76,7 @@ export class ExternalServiceProxy extends WorkerEntrypoint { } if (!target._fetcher) { - throw new Error( - `Worker "${ctx.props.service}" not found. Make sure it is running locally.` - ); + throw new Error(workerNotFoundMessage(ctx.props.service)); } return Reflect.get(target._fetcher, prop); }, @@ -86,19 +85,16 @@ export class ExternalServiceProxy extends WorkerEntrypoint { fetch(request: Request): Promise | Response { if (!this._fetcher) { - return new Response( - `Worker "${this.ctx.props.service}" not found. Make sure it is running locally.`, - { status: 503 } - ); + return new Response(workerNotFoundMessage(this.ctx.props.service), { + status: 503, + }); } return this._fetcher.fetch(request); } async scheduled(controller: ScheduledController) { if (!this._entryFetcher) { - throw new Error( - `Worker "${this.ctx.props.service}" not found. Make sure it is running locally.` - ); + throw new Error(workerNotFoundMessage(this.ctx.props.service)); } const params = new URLSearchParams(); if (controller.cron) { From ed4559560aa66a001b6991a507480a8d80dc35e6 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 16 Apr 2026 16:11:24 +0100 Subject: [PATCH 2/4] [miniflare] Use debug port RPC for explorer cross-instance aggregation Replace HTTP loopback aggregation with debug port RPC. Remove loopbackAddress from WorkerDefinition since the explorer now connects to peers via the debug port directly. Add DEV_REGISTRY_DEBUG_PORT binding to the explorer worker. --- fixtures/dev-registry/tests/dev-registry.test.ts | 2 +- packages/miniflare/src/index.ts | 8 +------- packages/miniflare/src/plugins/core/explorer.ts | 12 +++++++++++- packages/miniflare/src/shared/DEV_REGISTRY.md | 1 - packages/miniflare/src/shared/dev-registry-types.ts | 5 ----- packages/miniflare/src/workers/core/constants.ts | 1 + .../src/workers/local-explorer/aggregation.ts | 11 ++++++++--- .../src/workers/local-explorer/explorer.worker.ts | 2 ++ 8 files changed, 24 insertions(+), 18 deletions(-) diff --git a/fixtures/dev-registry/tests/dev-registry.test.ts b/fixtures/dev-registry/tests/dev-registry.test.ts index 29316410d9..a48cc16caf 100644 --- a/fixtures/dev-registry/tests/dev-registry.test.ts +++ b/fixtures/dev-registry/tests/dev-registry.test.ts @@ -934,7 +934,7 @@ describe("Dev Registry: getPlatformProxy -> wrangler / vite dev", () => { expect(response.status).toBe(503); expect(await response.text()).toEqual( - `Worker "service-worker" is not compatible with this version of the dev server. Please update all Worker instances to the same version.` + `Worker "worker-entrypoint-with-assets" not found. Make sure it is running locally.` ); }, waitForTimeout); diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index 34f7ba24a0..1edb8fe360 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -2161,7 +2161,7 @@ export class Miniflare { ], bindings: [ { - name: "DEV_REGISTRY_DEBUG_PORT", + name: CoreBindings.DEV_REGISTRY_DEBUG_PORT, // workerdDebugPort bindings don't have any additional configuration workerdDebugPort: kVoid, }, @@ -2540,11 +2540,6 @@ export class Miniflare { this.#runtimeEntryURL !== undefined, "Runtime entry URL must be set before registering workers" ); - // The loopback address is the workerd entry URL (host:port), used by the - // local explorer for cross-instance HTTP aggregation. - const loopbackAddress = `${this.#runtimeEntryURL.hostname}:${ - this.#runtimeEntryURL.port - }`; const entries: [string, WorkerDefinition][] = []; for (const workerOpts of this.#workerOpts) { @@ -2569,7 +2564,6 @@ export class Miniflare { debugPortAddress, defaultEntrypointService, userWorkerService: getUserServiceName(workerOpts.core.name), - loopbackAddress, }, ]); } diff --git a/packages/miniflare/src/plugins/core/explorer.ts b/packages/miniflare/src/plugins/core/explorer.ts index c5105f9b5c..022e3ca69f 100644 --- a/packages/miniflare/src/plugins/core/explorer.ts +++ b/packages/miniflare/src/plugins/core/explorer.ts @@ -1,6 +1,12 @@ import assert from "node:assert"; import SCRIPT_DO_WRAPPER from "worker:core/do-wrapper"; import SCRIPT_LOCAL_EXPLORER from "worker:local-explorer/explorer"; +import { + kVoid, + type Service, + type Worker_Binding, + type Worker_Module, +} from "../../runtime"; import { CoreBindings } from "../../workers"; import { normaliseDurableObject } from "../do"; import { @@ -14,7 +20,6 @@ import { SERVICE_LOCAL_EXPLORER, } from "./constants"; import type { PluginWorkerOptions } from ".."; -import type { Service, Worker_Binding, Worker_Module } from "../../runtime"; import type { DurableObjectClassNames, WorkflowOption } from "../shared"; import type { BindingIdMap, @@ -81,6 +86,11 @@ export function getExplorerServices( name: CoreBindings.JSON_TELEMETRY_CONFIG, json: JSON.stringify(telemetry), }, + { + name: CoreBindings.DEV_REGISTRY_DEBUG_PORT, + // workerdDebugPort bindings don't have any additional configuration + workerdDebugPort: kVoid, + }, ]; if (hasDurableObjects) { diff --git a/packages/miniflare/src/shared/DEV_REGISTRY.md b/packages/miniflare/src/shared/DEV_REGISTRY.md index 7fc45fdd55..5d226361b7 100644 --- a/packages/miniflare/src/shared/DEV_REGISTRY.md +++ b/packages/miniflare/src/shared/DEV_REGISTRY.md @@ -51,7 +51,6 @@ type WorkerDefinition = { debugPortAddress: string; // e.g. "127.0.0.1:12345" defaultEntrypointService: string; // workerd service name for default entrypoint userWorkerService: string; // workerd service name bypassing asset proxies - loopbackAddress: string; // e.g. "127.0.0.1:8787" }; ``` diff --git a/packages/miniflare/src/shared/dev-registry-types.ts b/packages/miniflare/src/shared/dev-registry-types.ts index 7a193ef73c..8e81b37308 100644 --- a/packages/miniflare/src/shared/dev-registry-types.ts +++ b/packages/miniflare/src/shared/dev-registry-types.ts @@ -19,9 +19,4 @@ export type WorkerDefinition = { * workers it bypasses the Assets proxy (whether built-in or userland) */ userWorkerService: string; - /** - * HTTP loopback address for this miniflare instance (e.g. "127.0.0.1:8787"). - * Used by the local explorer for cross-instance aggregation. - */ - loopbackAddress: string; }; diff --git a/packages/miniflare/src/workers/core/constants.ts b/packages/miniflare/src/workers/core/constants.ts index 7fd91d5dc2..21dba37550 100644 --- a/packages/miniflare/src/workers/core/constants.ts +++ b/packages/miniflare/src/workers/core/constants.ts @@ -74,6 +74,7 @@ export const CoreBindings = { SERVICE_CACHE: "MINIFLARE_CACHE", SERVICE_DEV_REGISTRY_PROXY: "MINIFLARE_DEV_REGISTRY_PROXY", JSON_TELEMETRY_CONFIG: "MINIFLARE_TELEMETRY_CONFIG", + DEV_REGISTRY_DEBUG_PORT: "DEV_REGISTRY_DEBUG_PORT", } as const; export const ProxyOps = { diff --git a/packages/miniflare/src/workers/local-explorer/aggregation.ts b/packages/miniflare/src/workers/local-explorer/aggregation.ts index f8e4cb617a..a7948f0d8a 100644 --- a/packages/miniflare/src/workers/local-explorer/aggregation.ts +++ b/packages/miniflare/src/workers/local-explorer/aggregation.ts @@ -5,6 +5,7 @@ * any one instance can aggregate data from all instances. */ +import { env } from "cloudflare:workers"; import { CorePaths } from "../core"; import type { WorkerRegistry } from "../../shared/dev-registry-types"; import type { AppContext } from "./common"; @@ -28,7 +29,7 @@ function getPeerUrls( const selfSet = new Set(selfWorkerNames); const urls = Object.entries(registry) .filter(([name]) => !selfSet.has(name)) - .map(([, def]) => `http://${def.loopbackAddress}`); + .map(([, def]) => def.debugPortAddress); // A single Miniflare process with multiple workers registers multiple // entries in the registry, all sharing the same host:port. We deduplicate // to avoid fetching from the same peer multiple times. @@ -62,8 +63,12 @@ export async function fetchFromPeer( init?: RequestInit ): Promise { try { - const url = new URL(`${EXPLORER_API_PATH}${apiPath}`, peerUrl); - const response = await fetch(url.toString(), { + const client = (env as AppContext["env"]).DEV_REGISTRY_DEBUG_PORT.connect( + peerUrl + ); + const fetcher = client.getEntrypoint("core:entry"); + const url = new URL(`http://localhost${EXPLORER_API_PATH}${apiPath}`); + const response = await fetcher.fetch(url.toString(), { ...init, headers: { ...(init?.headers as Record | undefined), diff --git a/packages/miniflare/src/workers/local-explorer/explorer.worker.ts b/packages/miniflare/src/workers/local-explorer/explorer.worker.ts index 44e44ff49d..e646154182 100644 --- a/packages/miniflare/src/workers/local-explorer/explorer.worker.ts +++ b/packages/miniflare/src/workers/local-explorer/explorer.worker.ts @@ -55,6 +55,7 @@ import type { } from "../../plugins/core/types"; import type { WorkerRegistry } from "../../shared/dev-registry-types"; import type { CoreBindings } from "../core"; +import type { WorkerdDebugPortConnector } from "../core/dev-registry-proxy-shared.worker"; import type { LocalExplorerWorker } from "./generated"; export type Env = { @@ -70,6 +71,7 @@ export type Env = { // Per-worker resource bindings for the /local/workers endpoint [CoreBindings.JSON_EXPLORER_WORKER_OPTS]: ExplorerWorkerOpts; [CoreBindings.JSON_TELEMETRY_CONFIG]: { enabled: boolean; deviceId?: string }; + [CoreBindings.DEV_REGISTRY_DEBUG_PORT]: WorkerdDebugPortConnector; }; export type AppBindings = { Bindings: Env }; From 7e96864fea2b6268d58faf0d7b1085e87a071460 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 16 Apr 2026 17:57:09 +0100 Subject: [PATCH 3/4] [miniflare] Fix stale registry test to assert incompatible version message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test was passing by accident — vi.waitFor succeeded before the file watcher picked up the old-format entry, so it matched the generic 'not found' message. Now waits specifically for the incompatible version message to verify the detection works end-to-end. --- fixtures/dev-registry/tests/dev-registry.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/fixtures/dev-registry/tests/dev-registry.test.ts b/fixtures/dev-registry/tests/dev-registry.test.ts index a48cc16caf..7170068472 100644 --- a/fixtures/dev-registry/tests/dev-registry.test.ts +++ b/fixtures/dev-registry/tests/dev-registry.test.ts @@ -1216,7 +1216,9 @@ describe("Dev Registry: error handling", () => { ); // The old-format entry has no debugPortAddress, so the proxy should - // return a 503 (not connected) rather than crashing. + // return a 503 with the incompatible version message rather than crashing. + // We wait specifically for the incompatible message (not the generic "not + // found" message) to ensure the file watcher has picked up the entry. await vi.waitFor(async () => { const searchParams = new URLSearchParams({ "test-service": "service-worker", @@ -1226,7 +1228,7 @@ describe("Dev Registry: error handling", () => { expect(response.status).toBe(503); expect(await response.text()).toEqual( - `Worker "service-worker" not found. Make sure it is running locally.` + `Worker "service-worker" is not compatible with this version of the dev server. Please update all Worker instances to the same version.` ); }, waitForTimeout); }); From 16c844238b7a032c38f2140454ed0c327b82d247 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 16 Apr 2026 18:01:32 +0100 Subject: [PATCH 4/4] [miniflare] Filter out incompatible registry entries in explorer aggregation Entries from older wrangler versions lack debugPortAddress. Filter them out in getPeerDebugPortAddresses() to avoid passing undefined to connect(). Also rename peerUrl parameter to peerDebugPortAddress to match the new semantics. --- .../src/workers/local-explorer/aggregation.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/miniflare/src/workers/local-explorer/aggregation.ts b/packages/miniflare/src/workers/local-explorer/aggregation.ts index a7948f0d8a..3e5ca07106 100644 --- a/packages/miniflare/src/workers/local-explorer/aggregation.ts +++ b/packages/miniflare/src/workers/local-explorer/aggregation.ts @@ -22,18 +22,19 @@ export const NO_AGGREGATE_HEADER = "X-Miniflare-Explorer-No-Aggregate"; * Get the unique base URLs of peer instances from the dev registry, * excluding the current instance (identified by worker names). */ -function getPeerUrls( +function getPeerDebugPortAddresses( registry: WorkerRegistry, selfWorkerNames: string[] ): string[] { const selfSet = new Set(selfWorkerNames); - const urls = Object.entries(registry) + const addresses = Object.entries(registry) .filter(([name]) => !selfSet.has(name)) - .map(([, def]) => def.debugPortAddress); + .map(([, def]) => def.debugPortAddress) + .filter((addr): addr is string => typeof addr === "string"); // A single Miniflare process with multiple workers registers multiple // entries in the registry, all sharing the same host:port. We deduplicate // to avoid fetching from the same peer multiple times. - return [...new Set(urls)]; + return [...new Set(addresses)]; } export async function getPeerUrlsIfAggregating( @@ -46,25 +47,25 @@ export async function getPeerUrlsIfAggregating( const workerNames = c.env.LOCAL_EXPLORER_WORKER_NAMES; const response = await loopback.fetch("http://localhost/core/dev-registry"); const registry = (await response.json()) as WorkerRegistry; - return getPeerUrls(registry, workerNames); + return getPeerDebugPortAddresses(registry, workerNames); } /** * Fetch data from a peer instance's explorer API. * Returns null on any error (silent omission policy). * - * @param peerUrl - Base URL of the peer instance (e.g., "http://127.0.0.1:8788") + * @param peerDebugPortAddress - Debug port address of the peer instance (e.g., "127.0.0.1:12345") * @param apiPath - API path relative to the explorer API base (e.g., "/d1/database") * @param init - Optional fetch init options */ export async function fetchFromPeer( - peerUrl: string, + peerDebugPortAddress: string, apiPath: string, init?: RequestInit ): Promise { try { const client = (env as AppContext["env"]).DEV_REGISTRY_DEBUG_PORT.connect( - peerUrl + peerDebugPortAddress ); const fetcher = client.getEntrypoint("core:entry"); const url = new URL(`http://localhost${EXPLORER_API_PATH}${apiPath}`);