diff --git a/.changeset/fix-ipv6-localhost-spin.md b/.changeset/fix-ipv6-localhost-spin.md new file mode 100644 index 0000000000..622e22240b --- /dev/null +++ b/.changeset/fix-ipv6-localhost-spin.md @@ -0,0 +1,9 @@ +--- +"miniflare": patch +--- + +fix(miniflare): use 127.0.0.1 for internal loopback when localhost is configured + +When `localhost` is configured as the host, Node.js may bind to `[::1]` (IPv6) while workerd resolves `localhost` to `127.0.0.1` (IPv4) first. This mismatch causes connection refused errors and 100% CPU spins. + +This fix ensures the internal loopback communication between Node.js and workerd always uses `127.0.0.1` when `localhost` is configured, while preserving the user-facing URL as `localhost`. diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index a043167bd8..20b433349b 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -166,6 +166,11 @@ const DEFAULT_HOST = "127.0.0.1"; function getURLSafeHost(host: string) { return net.isIPv6(host) ? `[${host}]` : host; } + +function resolveLocalhost(host: string) { + return host === "localhost" ? "127.0.0.1" : undefined; +} + function maybeGetLocallyAccessibleHost( h: string ): "localhost" | "127.0.0.1" | "[::1]" | undefined { @@ -1676,7 +1681,8 @@ export class Miniflare { // Start loopback server (how the runtime accesses Node.js) using the same // host as the main runtime server. This means we can use the loopback // server for live reload updates too. - const loopbackHost = this.#sharedOpts.core.host ?? DEFAULT_HOST; + const configuredHost = this.#sharedOpts.core.host ?? DEFAULT_HOST; + const loopbackHost = resolveLocalhost(configuredHost) ?? configuredHost; // If we've already started the loopback server... if (this.#loopbackServer !== undefined) { // ...and it's using the correct host, reuse it @@ -1782,6 +1788,7 @@ export class Miniflare { } async #assembleConfig( + loopbackHost: string, loopbackPort: number, proxyAddress: string | null ): Promise { @@ -1972,6 +1979,7 @@ export class Miniflare { tmpPath: this.#tmpPath, defaultPersistRoot: sharedOpts.core.defaultPersistRoot, workerNames, + loopbackHost, loopbackPort, unsafeStickyBlobs, wrappedBindingNames, @@ -2230,12 +2238,21 @@ export class Miniflare { const initial = !this.#runtimeEntryURL; assert(this.#runtime !== undefined); 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 + // where Node.js binds to [::1] but workerd resolves localhost to 127.0.0.1. + // See: https://github.com/cloudflare/workers-sdk/issues/12910 const loopbackHost = + resolveLocalhost(configuredHost) ?? maybeGetLocallyAccessibleHost(configuredHost) ?? getURLSafeHost(configuredHost); const loopbackPort = await this.#getLoopbackPort(); const proxyAddress = await this.#devRegistry.initializeProxyWorker(); - const config = await this.#assembleConfig(loopbackPort, proxyAddress); + const config = await this.#assembleConfig( + loopbackHost, + loopbackPort, + proxyAddress + ); const configBuffer = serializeConfig(config); // Get all socket names we expect to get ports for diff --git a/packages/miniflare/src/plugins/core/index.ts b/packages/miniflare/src/plugins/core/index.ts index 610f34132c..1a7187976f 100644 --- a/packages/miniflare/src/plugins/core/index.ts +++ b/packages/miniflare/src/plugins/core/index.ts @@ -685,6 +685,7 @@ export const CORE_PLUGIN: Plugin< wrappedBindingNames, durableObjectClassNames, additionalModules, + loopbackHost, loopbackPort, }) { // Define regular user worker @@ -862,7 +863,7 @@ export const CORE_PLUGIN: Plugin< moduleFallback: options.unsafeUseModuleFallbackService && sharedOptions.unsafeModuleFallbackService !== undefined - ? `localhost:${loopbackPort}` + ? `${loopbackHost}:${loopbackPort}` : undefined, tails: options.tails?.map((service) => { return getCustomServiceDesignator( diff --git a/packages/miniflare/src/plugins/shared/index.ts b/packages/miniflare/src/plugins/shared/index.ts index 3b19b444d9..f8cef6816e 100644 --- a/packages/miniflare/src/plugins/shared/index.ts +++ b/packages/miniflare/src/plugins/shared/index.ts @@ -79,6 +79,7 @@ export interface PluginServicesOptions< tmpPath: string; defaultPersistRoot: string | undefined; workerNames: string[]; + loopbackHost: string; loopbackPort: number; unsafeStickyBlobs: boolean; diff --git a/packages/miniflare/test/index.spec.ts b/packages/miniflare/test/index.spec.ts index 65d101f32a..474ec3ff0d 100644 --- a/packages/miniflare/test/index.spec.ts +++ b/packages/miniflare/test/index.spec.ts @@ -280,6 +280,31 @@ const localInterface = (interfaces["en0"] ?? interfaces["eth0"])?.find( expect(await res.text()).toBe("body"); } ); + +test("Miniflare: can use localhost as host", async ({ expect }) => { + const mf = new Miniflare({ + host: "localhost", + modules: true, + script: `export default { fetch(request, env) { return env.SERVICE.fetch(request); } }`, + serviceBindings: { + SERVICE() { + return new Response("body"); + }, + }, + }); + useDispose(mf); + + const url = await mf.ready; + expect(url.hostname).toBe("localhost"); + + let res = await mf.dispatchFetch("https://example.com"); + expect(await res.text()).toBe("body"); + + const worker = await mf.getWorker(); + res = await worker.fetch("https://example.com"); + expect(await res.text()).toBe("body"); +}); + test("Miniflare: can use IPv6 loopback as host", async ({ expect }) => { const mf = new Miniflare({ host: "::1", @@ -3407,6 +3432,7 @@ test("Miniflare: can use module fallback service", async ({ expect }) => { }; const mf = new Miniflare({ + host: "localhost", unsafeModuleFallbackService(request) { const resolveMethod = request.headers.get("X-Resolve-Method"); assert(resolveMethod === "import" || resolveMethod === "require");