diff --git a/.changeset/hyperdrive-remote-bindings-local-dev.md b/.changeset/hyperdrive-remote-bindings-local-dev.md new file mode 100644 index 00000000000..0285601b959 --- /dev/null +++ b/.changeset/hyperdrive-remote-bindings-local-dev.md @@ -0,0 +1,23 @@ +--- +"miniflare": minor +"wrangler": minor +--- + +Support remote Hyperdrive bindings in local development + +Hyperdrive bindings were local-only in `wrangler dev`, so exercising the database behind a deployed Hyperdrive configuration meant running `wrangler dev --remote` or standing up a local copy of the database. Setting `remote: true` on a `hyperdrive` binding now connects local dev to the deployed configuration instead: + +```jsonc +{ + "hyperdrive": [ + { + "binding": "HYPERDRIVE", + "id": "", + // connect to the deployed Hyperdrive configuration in `wrangler dev` + "remote": true, + }, + ], +} +``` + +Miniflare stands up a local TCP bridge and points the binding's designator at it, relaying each connection to the edge Hyperdrive binding over the existing remote bindings proxy, so database clients such as `mysql2` and `pg` work unchanged. The edge session's connection string is fetched once per session so the local binding presents credentials the edge proxy accepts, which also makes `localConnectionString` optional whenever that session can be established. When it cannot — you are logged out, offline, or running with remote bindings turned off — the binding falls back to its `localConnectionString` with a warning, or explains what to fix if there is none. This is opt-in — bindings without `remote: true` keep the existing local-only behaviour and need no configuration changes. diff --git a/packages/config/src/bindings.ts b/packages/config/src/bindings.ts index 8881ddd6353..c9497f7c451 100644 --- a/packages/config/src/bindings.ts +++ b/packages/config/src/bindings.ts @@ -221,6 +221,11 @@ interface HyperdriveBindingOptions { id: string; /** The local database connection string used during local development. */ localConnectionString?: string; + /** + * Connect to the deployed Hyperdrive configuration at the edge during local + * development (via the remote-bindings proxy) instead of a local database. + */ + remote?: boolean; } /** diff --git a/packages/miniflare/src/plugins/hyperdrive/hyperdrive-proxy.ts b/packages/miniflare/src/plugins/hyperdrive/hyperdrive-proxy.ts index cd3f9732aeb..16b1d7e4e7a 100644 --- a/packages/miniflare/src/plugins/hyperdrive/hyperdrive-proxy.ts +++ b/packages/miniflare/src/plugins/hyperdrive/hyperdrive-proxy.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import net from "node:net"; import tls from "node:tls"; +import WebSocket from "ws"; import type { Log } from "../../shared"; export interface HyperdriveProxyConfig { @@ -225,6 +226,125 @@ export class HyperdriveProxyController { dbSocket.pipe(clientSocket); } + /** + * Creates a local TCP bridge for a *remote* Hyperdrive binding. + * + * The bridge is a plain `net.Server` on `127.0.0.1:`; the binding's + * `external.tcp` designator points at it, so workerd stays unmodified — + * pointing a Hyperdrive designator at a Worker service crashes workerd + * (SIGSEGV). Each inbound TCP connection is relayed, byte-for-byte, over a + * WebSocket to the remote proxy's `connect` handler (which calls + * `env[binding].connect()` at the edge and pipes the Hyperdrive proxy socket + * back). Mirrors the raw-TCP relay in ProxyServerWorker's `handleConnect`, + * but runs in the Miniflare Node process instead of inside workerd. + * + * @returns the local bridge port the designator should target. + */ + async createRemoteTcpBridge(config: { + // Hyperdrive binding name (used for the server key and the `MF-Binding` + // header the edge relay dispatches on). + name: string; + // The remote proxy connection string (a local URL that upgrades to a + // WebSocket relaying to the edge Hyperdrive binding). + remoteProxyConnectionString: URL; + }): Promise { + const { name, remoteProxyConnectionString } = config; + const wsUrl = new URL(remoteProxyConnectionString.href); + wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:"; + const wsHref = wsUrl.href; + + const server = net.createServer((clientSocket) => { + this.#handleRemoteBridgeConnection(clientSocket, wsHref, name); + }); + server.on("error", (err) => { + this.log?.error( + new Error( + `Hyperdrive remote bridge error for binding "${name}": ${err.message}` + ) + ); + }); + const port = await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + const address = server.address() as net.AddressInfo; + if (address && typeof address !== "string") { + resolve(address.port); + } else { + reject(new Error("Invalid port")); + } + }); + }); + this.#servers.set(`remote:${name}`, server); + return port; + } + + /** + * Relays one bridged TCP connection to the edge over a WebSocket. The socket + * is paused until the WebSocket opens so no client bytes (the MySQL/Postgres + * handshake) are dropped; both directions are torn down together on any + * close/error. + */ + #handleRemoteBridgeConnection( + clientSocket: net.Socket, + wsHref: string, + bindingName: string + ): void { + clientSocket.pause(); + const ws = new WebSocket(wsHref, { + headers: { + "MF-Binding": bindingName, + // Hyperdrive's edge `connect()` ignores the address, but the raw-TCP + // relay path requires the header to be present. + "MF-Connect-Address": "hyperdrive.local:0", + ...(process.env.CF_TRACE_ID + ? { "cf-trace-id": process.env.CF_TRACE_ID } + : {}), + }, + }); + + let closed = false; + const teardown = () => { + if (closed) { + return; + } + closed = true; + try { + clientSocket.destroy(); + } catch { + // ignore + } + try { + ws.close(); + } catch { + // ignore + } + }; + + ws.on("open", () => { + clientSocket.on("data", (chunk: Buffer) => { + try { + ws.send(chunk); + } catch { + teardown(); + } + }); + clientSocket.resume(); + }); + ws.on("message", (data: WebSocket.RawData) => { + const buf = Array.isArray(data) + ? Buffer.concat(data) + : Buffer.isBuffer(data) + ? data + : Buffer.from(data as ArrayBuffer); + clientSocket.write(buf); + }); + ws.on("close", teardown); + ws.on("error", teardown); + clientSocket.on("close", teardown); + clientSocket.on("error", teardown); + } + /** Disposes of the proxy servers when shutting down the worker.*/ dispose(): void { // Stop accepting new connections on each proxy server. We don't await diff --git a/packages/miniflare/src/plugins/hyperdrive/index.ts b/packages/miniflare/src/plugins/hyperdrive/index.ts index 3530245d164..2850069cfc1 100644 --- a/packages/miniflare/src/plugins/hyperdrive/index.ts +++ b/packages/miniflare/src/plugins/hyperdrive/index.ts @@ -2,7 +2,7 @@ import assert from "node:assert"; import { z } from "zod"; import { ProxyNodeBinding } from "../shared"; import type { Worker_Binding } from "../../runtime"; -import type { Plugin } from "../shared"; +import type { Plugin, RemoteProxyConnectionString } from "../shared"; export const HYPERDRIVE_PLUGIN_NAME = "hyperdrive"; @@ -71,8 +71,50 @@ export const HyperdriveSchema = z return url; }); +// A Hyperdrive entry is either the legacy plain connection string (local dev), +// or an object that additionally carries a `remoteProxyConnectionString`, opting +// the binding into remote-bindings mode: `connect()` traffic is tunnelled through +// the shared remote-proxy-client service to the edge Hyperdrive binding. +const HyperdriveEntrySchema = z.union([ + HyperdriveSchema, + z.object({ + localConnectionString: HyperdriveSchema.optional(), + remoteProxyConnectionString: z + .custom() + .optional(), + }), +]); + +// Placeholder connection string used to synthesise the local Hyperdrive binding +// when a remote binding has no local connection string. workerd still needs a +// scheme/database/user/password to build the magic `connectionString` it exposes +// to the Worker; the real origin lives at the edge. In normal `wrangler dev` +// usage the caller seeds `localConnectionString` with the edge session's +// credentials before this runs (so a database client authenticates through the +// proxy), and this placeholder is only reached when no seeding has occurred. +const REMOTE_PLACEHOLDER_URL = new URL( + "mysql://user:password@hyperdrive.local:3306/database" +); + +type NormalizedHyperdrive = { + url: URL; + remoteProxyConnectionString?: RemoteProxyConnectionString; +}; + +function normalizeHyperdriveEntry( + value: z.infer +): NormalizedHyperdrive { + if (value instanceof URL) { + return { url: value }; + } + return { + url: value.localConnectionString ?? REMOTE_PLACEHOLDER_URL, + remoteProxyConnectionString: value.remoteProxyConnectionString, + }; +} + export const HyperdriveInputOptionsSchema = z.object({ - hyperdrives: z.record(z.string(), HyperdriveSchema).optional(), + hyperdrives: z.record(z.string(), HyperdriveEntrySchema).optional(), }); export const HYPERDRIVE_PLUGIN: Plugin = { @@ -80,15 +122,20 @@ export const HYPERDRIVE_PLUGIN: Plugin = { bindingTypeDescription: "Hyperdrive", getBindings(options) { return Object.entries(options.hyperdrives ?? {}).map( - ([name, url]) => { + ([name, entry]) => { + const { url } = normalizeHyperdriveEntry(entry); const database = url.pathname.replace("/", ""); const scheme = url.protocol.replace(":", ""); + // Both local and remote bindings use the per-binding + // `hyperdrive:` external.tcp designator. For remote bindings + // that service points at the local TCP bridge (see `getServices`), + // which relays to the edge. workerd is unmodified either way — + // pointing a Hyperdrive designator at a Worker service SIGSEGVs. + const designator = { name: `${HYPERDRIVE_PLUGIN_NAME}:${name}` }; return { name, hyperdrive: { - designator: { - name: `${HYPERDRIVE_PLUGIN_NAME}:${name}`, - }, + designator, database: decodeURIComponent(database), user: decodeURIComponent(url.username), password: decodeURIComponent(url.password), @@ -100,7 +147,8 @@ export const HYPERDRIVE_PLUGIN: Plugin = { }, getNodeBindings(options) { return Object.fromEntries( - Object.entries(options.hyperdrives ?? {}).map(([name, url]) => { + Object.entries(options.hyperdrives ?? {}).map(([name, entry]) => { + const { url } = normalizeHyperdriveEntry(entry); const connectionOverrides: Record = { connectionString: `${url}`, port: Number.parseInt(url.port), @@ -119,7 +167,30 @@ export const HYPERDRIVE_PLUGIN: Plugin = { }, async getServices({ options, hyperdriveProxyController }) { const services = []; - for (const [name, url] of Object.entries(options.hyperdrives ?? {})) { + for (const [name, entry] of Object.entries(options.hyperdrives ?? {})) { + const { url, remoteProxyConnectionString } = + normalizeHyperdriveEntry(entry); + + // Remote bindings: stand up a local TCP bridge that relays `connect()` + // bytes to the edge Hyperdrive binding over a WebSocket, and point the + // `hyperdrive:` external.tcp designator at it. This keeps workerd + // unmodified (the Worker-service designator path SIGSEGVs). + if (remoteProxyConnectionString) { + const bridgePort = + await hyperdriveProxyController.createRemoteTcpBridge({ + name, + remoteProxyConnectionString, + }); + services.push({ + name: `${HYPERDRIVE_PLUGIN_NAME}:${name}`, + external: { + address: `127.0.0.1:${bridgePort}`, + tcp: {}, + }, + }); + continue; + } + const scheme = url.protocol.replace(":", ""); const sslmode = parseSslMode(url, scheme); const targetPort = getPort(url); diff --git a/packages/miniflare/test/plugins/shared/remote-bindings-connect.spec.ts b/packages/miniflare/test/plugins/shared/remote-bindings-connect.spec.ts index 44fbe11dfa6..68f08f2a1f7 100644 --- a/packages/miniflare/test/plugins/shared/remote-bindings-connect.spec.ts +++ b/packages/miniflare/test/plugins/shared/remote-bindings-connect.spec.ts @@ -1,3 +1,4 @@ +import net from "node:net"; import path from "node:path"; // The relay helper under unit test. It is the same implementation bundled into // miniflare's dist and mirrored (byte-for-byte, comments aside) into the edge @@ -14,6 +15,7 @@ import { VPC_SERVICES_PLUGIN, } from "miniflare"; import { beforeAll, describe, test } from "vitest"; +import { HyperdriveProxyController } from "../../../src/plugins/hyperdrive/hyperdrive-proxy"; import { useDispose } from "../../test-shared"; import type { RemoteProxyConnectionString } from "miniflare"; @@ -739,3 +741,163 @@ describe("VPC_SERVICES plugin: raw TCP opt-in", () => { expect(serviceList[0].worker?.compatibilityFlags).toEqual(["experimental"]); }); }); + +// Reads from a raw TCP socket until the accumulated output contains `sentinel`, +// or rejects after `timeoutMs`. Used to observe bytes coming back through the +// Hyperdrive remote bridge. +function readFromSocket( + socket: net.Socket, + sentinel: string, + timeoutMs = 10_000 +): Promise { + return new Promise((resolve, reject) => { + let out = ""; + const cleanup = () => { + clearTimeout(timer); + socket.off("data", onData); + socket.off("error", onError); + }; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`timed out waiting for ${JSON.stringify(sentinel)}`)); + }, timeoutMs); + const onData = (chunk: Buffer) => { + out += chunk.toString("utf8"); + if (out.includes(sentinel)) { + cleanup(); + resolve(out); + } + }; + const onError = (err: Error) => { + cleanup(); + reject(err); + }; + socket.on("data", onData); + socket.on("error", onError); + }); +} + +// The remote Hyperdrive path replaces the `connect()` model with a local TCP +// bridge (`HyperdriveProxyController.createRemoteTcpBridge`): workerd's +// `external.tcp` designator points at a Node `net.Server` on 127.0.0.1, which +// relays each connection to the edge over a WebSocket into the real edge +// binding via the same `handleConnect` path exercised above. A database client +// (mysql2/pg) then speaks its wire protocol straight through the bridge. +describe("Hyperdrive remote binding: local TCP bridge", () => { + test("relays bytes between a local TCP client and the edge binding", async ({ + expect, + }) => { + // "Edge" running the real ProxyServerWorker, with a `HYPERDRIVE` binding + // wired to a connect-capable target that reflects the address and echoes. + const edge = new Miniflare({ + workers: [ + { + name: "proxy-server", + compatibilityDate: COMPAT_DATE, + compatibilityFlags: ["experimental"], + modules: [ + { + type: "ESModule", + path: "ProxyServerWorker.js", + contents: proxyServerBundle, + }, + ], + serviceBindings: { HYPERDRIVE: "hd-target" }, + }, + { + name: "hd-target", + compatibilityDate: COMPAT_DATE, + compatibilityFlags: ["experimental"], + modules: true, + script: VPC_TARGET_SCRIPT, + }, + ], + }); + useDispose(edge); + const edgeUrl = await edge.ready; + + const controller = new HyperdriveProxyController(); + try { + const bridgePort = await controller.createRemoteTcpBridge({ + name: "HYPERDRIVE", + remoteProxyConnectionString: edgeUrl, + }); + + const socket = net.connect(bridgePort, "127.0.0.1"); + try { + await new Promise((resolve, reject) => { + socket.once("connect", resolve); + socket.once("error", reject); + }); + socket.write("PING\n"); + const received = await readFromSocket(socket, "PING\n"); + // The bridge sends a fixed connect address; the target reflects it. + expect(received).toContain("ADDR:hyperdrive.local:0|"); + expect(received).toContain("PING\n"); + } finally { + socket.destroy(); + } + } finally { + controller.dispose(); + } + }); +}); + +// The edge mints per-session credentials for a remote Hyperdrive binding, so the +// local binding config must be seeded with the edge session's `connectionString` +// (fetched via the `MF-HD-Seed` guard endpoint) or a database client can't +// authenticate through the proxy. +describe("Hyperdrive remote binding: MF-HD-Seed endpoint", () => { + function makeSeedEdge(): Miniflare { + return new Miniflare({ + compatibilityDate: COMPAT_DATE, + compatibilityFlags: ["experimental"], + modules: [ + { + type: "ESModule", + path: "ProxyServerWorker.js", + contents: proxyServerBundle, + }, + ], + hyperdrives: { + HYPERDRIVE: "mysql://hduser:hdpass@127.0.0.1:3306/testdb", + }, + }); + } + + test("returns the binding's connectionString", async ({ expect }) => { + const edge = makeSeedEdge(); + useDispose(edge); + const edgeUrl = await edge.ready; + + const res = await fetch(edgeUrl, { + headers: { "MF-HD-Seed": "true", "MF-Binding": "HYPERDRIVE" }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { connectionString?: string }; + expect(typeof body.connectionString).toBe("string"); + // The connection string workerd exposes preserves the configured database + // (and credentials), which is exactly what the local binding must replay. + expect(body.connectionString).toContain("testdb"); + }); + + test("rejects a request with no MF-Binding header", async ({ expect }) => { + const edge = makeSeedEdge(); + useDispose(edge); + const edgeUrl = await edge.ready; + + const res = await fetch(edgeUrl, { headers: { "MF-HD-Seed": "true" } }); + expect(res.status).toBe(400); + }); + + test("404s an unknown binding", async ({ expect }) => { + const edge = makeSeedEdge(); + useDispose(edge); + const edgeUrl = await edge.ready; + + const res = await fetch(edgeUrl, { + headers: { "MF-HD-Seed": "true", "MF-Binding": "DOES_NOT_EXIST" }, + }); + expect(res.status).toBe(404); + }); +}); diff --git a/packages/remote-bindings/src/index.ts b/packages/remote-bindings/src/index.ts index df418124afb..67253662d39 100644 --- a/packages/remote-bindings/src/index.ts +++ b/packages/remote-bindings/src/index.ts @@ -8,6 +8,7 @@ export type { WorkerConfigObject, } from "./maybe-start-or-update-session"; export { startRemoteProxySession } from "./start-remote-proxy-session"; +export { seedRemoteHyperdriveBindings } from "./seed-hyperdrive-bindings"; export type { RemoteBindingsLogger } from "./logger"; export type { RemoteProxySession, diff --git a/packages/remote-bindings/src/seed-hyperdrive-bindings.ts b/packages/remote-bindings/src/seed-hyperdrive-bindings.ts new file mode 100644 index 00000000000..f29a8b1681a --- /dev/null +++ b/packages/remote-bindings/src/seed-hyperdrive-bindings.ts @@ -0,0 +1,86 @@ +import type { Binding } from "@cloudflare/workers-utils"; +import type { RemoteProxyConnectionString } from "miniflare"; + +/** + * A remote Hyperdrive binding is reached from local dev through a TCP bridge + * that relays the connection to the edge Hyperdrive proxy (see + * `HyperdriveProxyController.createRemoteTcpBridge` in miniflare). The edge + * proxy mints per-session dummy credentials and uses the config id as the + * database name, so a database client must present *those* values to + * authenticate through the proxy — the user's local placeholder credentials + * only get as far as the server greeting. + * + * This fetches the edge binding's `connectionString` from the remote-proxy + * worker's `MF-HD-Seed` endpoint so the local Hyperdrive binding can be + * configured with credentials that match the live session. + * + * The returned value is a live credential: callers MUST treat it as a secret + * and MUST NOT log it. + */ +async function fetchEdgeConnectionString( + remoteProxyConnectionString: RemoteProxyConnectionString, + bindingName: string +): Promise { + const response = await fetch(String(remoteProxyConnectionString), { + headers: { + "MF-HD-Seed": "true", + "MF-Binding": bindingName, + }, + }); + if (!response.ok) { + throw new Error( + `Failed to seed remote Hyperdrive binding "${bindingName}": ` + + `the remote proxy responded with status ${response.status}.` + ); + } + const body = (await response.json()) as { connectionString?: unknown }; + if (typeof body.connectionString !== "string") { + throw new Error( + `Failed to seed remote Hyperdrive binding "${bindingName}": ` + + `the remote proxy did not return a connection string.` + ); + } + return body.connectionString; +} + +/** + * Seeds every remote Hyperdrive binding's `localConnectionString` with the + * connection string of its edge session, so that the local binding presents + * credentials the edge Hyperdrive proxy will accept. + * + * `buildMiniflareBindingOptions` is synchronous, so this async step must run + * once the remote proxy session is ready and before miniflare options are + * built. The passed `bindings` objects are mutated in place. + * + * No-op when there is no remote proxy session or no remote Hyperdrive bindings. + */ +export async function seedRemoteHyperdriveBindings( + bindings: Record | undefined, + remoteProxyConnectionString: RemoteProxyConnectionString | undefined +): Promise { + if (!remoteProxyConnectionString || !bindings) { + return; + } + + const remoteHyperdrives = Object.entries(bindings).filter( + (entry): entry is [string, Binding & { remote?: boolean }] => { + const [, binding] = entry; + return ( + binding.type === "hyperdrive" && + "remote" in binding && + Boolean(binding.remote) + ); + } + ); + + await Promise.all( + remoteHyperdrives.map(async ([name, binding]) => { + const connectionString = await fetchEdgeConnectionString( + remoteProxyConnectionString, + name + ); + (binding as { localConnectionString?: string }).localConnectionString = + connectionString; + }) + ); +} diff --git a/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts b/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts index 055bfeda91d..84eb7807a7d 100644 --- a/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts +++ b/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts @@ -309,9 +309,55 @@ async function pipeSocketOverWebSocket( await Promise.all([toWebSocket, fromWebSocket]); } +/** + * Hyperdrive credential-seeding endpoint, guarded behind the `MF-HD-Seed` + * header so it never intercepts ordinary connect/RPC/fetch traffic. + * + * A remote Hyperdrive binding is reached from local dev through the TCP bridge + * in `HyperdriveProxyController.createRemoteTcpBridge` (see the miniflare + * hyperdrive plugin). The edge Hyperdrive proxy mints per-session dummy + * credentials (user/password) and uses the config id as the database name; a + * database client must present *those* values to authenticate through the + * proxy — the local placeholder credentials only get as far as the server + * greeting. This endpoint returns the edge binding's `connectionString` so the + * local host can seed the local binding config with the matching credentials + * once per session. + * + * The response carries live credentials: callers MUST treat it as a secret and + * MUST NOT log it. + */ +function handleSeedConnectionString(request: Request, env: Env): Response { + const bindingName = request.headers.get("MF-Binding"); + if (!bindingName) { + return new Response( + JSON.stringify({ error: "Missing MF-Binding header" }), + { + status: 400, + headers: { "content-type": "application/json" }, + } + ); + } + const binding = env[bindingName] as + | { connectionString?: unknown } + | undefined; + if (!binding || typeof binding.connectionString !== "string") { + return new Response( + JSON.stringify({ error: "Binding has no connectionString" }), + { status: 404, headers: { "content-type": "application/json" } } + ); + } + return new Response( + JSON.stringify({ connectionString: binding.connectionString }), + { status: 200, headers: { "content-type": "application/json" } } + ); +} + export default { async fetch(request, env) { try { + if (request.headers.has("MF-HD-Seed")) { + return handleSeedConnectionString(request, env); + } if (isConnectBinding(request)) { return handleConnect(request, env); } else if (isJSRPCBinding(request)) { diff --git a/packages/workers-utils/src/config/binding-local-support.ts b/packages/workers-utils/src/config/binding-local-support.ts index 3d36d001e87..dae9c658e31 100644 --- a/packages/workers-utils/src/config/binding-local-support.ts +++ b/packages/workers-utils/src/config/binding-local-support.ts @@ -35,7 +35,6 @@ const BINDING_LOCAL_SUPPORT: Record< assets: "local-only", unsafe_hello_world: "local-only", durable_object_namespace: "local-only", - hyperdrive: "local-only", fetcher: "local-only", analytics_engine: "local-only", secrets_store_secret: "local-only", @@ -45,6 +44,9 @@ const BINDING_LOCAL_SUPPORT: Record< kv_namespace: "local-and-remote", r2_bucket: "local-and-remote", d1: "local-and-remote", + // Hyperdrive has a local simulator (connects to a local database); `remote: + // true` opts into tunnelling to the deployed Hyperdrive config at the edge. + hyperdrive: "local-and-remote", workflow: "local-and-remote", browser: "local-and-remote", images: "local-and-remote", diff --git a/packages/workers-utils/src/config/environment.ts b/packages/workers-utils/src/config/environment.ts index 197670de7f5..2415fba1c23 100644 --- a/packages/workers-utils/src/config/environment.ts +++ b/packages/workers-utils/src/config/environment.ts @@ -1222,6 +1222,12 @@ export interface EnvironmentNonInheritable { id: string; /** The local database connection string for `wrangler dev` */ localConnectionString?: string; + /** + * Connect to the deployed Hyperdrive configuration at the edge during + * `wrangler dev` (via the remote-bindings proxy) instead of a local + * database. + */ + remote?: boolean; }[]; /** diff --git a/packages/workers-utils/src/config/validation.ts b/packages/workers-utils/src/config/validation.ts index 366650a8d49..d7504cdaeec 100644 --- a/packages/workers-utils/src/config/validation.ts +++ b/packages/workers-utils/src/config/validation.ts @@ -4462,10 +4462,18 @@ const validateHyperdriveBinding: ValidatorFn = (diagnostics, field, value) => { isValid = false; } + // `remote: true` opts the binding into connecting to the deployed Hyperdrive + // configuration at the edge during local dev (via the remote-bindings proxy). + // When set, a `localConnectionString` is optional. + if (!isRemoteValid(value, field, diagnostics)) { + isValid = false; + } + validateAdditionalProperties(diagnostics, field, Object.keys(value), [ "binding", "id", "localConnectionString", + "remote", ]); return isValid; diff --git a/packages/workers-utils/src/worker.ts b/packages/workers-utils/src/worker.ts index 1d3a75c9bdf..4d237fa9a96 100644 --- a/packages/workers-utils/src/worker.ts +++ b/packages/workers-utils/src/worker.ts @@ -306,6 +306,12 @@ export interface CfHyperdrive { binding: string; id: string; localConnectionString?: string; + /** + * Opt the binding into remote mode: instead of connecting to a local + * database, `wrangler dev` tunnels the binding's connection through the + * remote-bindings proxy to the deployed Hyperdrive configuration at the edge. + */ + remote?: boolean; } export interface CfDevPluginCfg { diff --git a/packages/wrangler/src/__tests__/dev/miniflare-hyperdrive.test.ts b/packages/wrangler/src/__tests__/dev/miniflare-hyperdrive.test.ts new file mode 100644 index 00000000000..fdb9f7378b3 --- /dev/null +++ b/packages/wrangler/src/__tests__/dev/miniflare-hyperdrive.test.ts @@ -0,0 +1,100 @@ +import { describe, it } from "vitest"; +import { buildMiniflareBindingOptions } from "../../dev/miniflare"; +import { mockConsoleMethods } from "../helpers/mock-console"; +import type { Binding } from "@cloudflare/workers-utils"; +import type { RemoteProxyConnectionString } from "miniflare"; + +const remoteProxyConnectionString = new URL( + "http://localhost:52222/" +) as RemoteProxyConnectionString; + +function buildHyperdriveOptions( + binding: Extract, + connectionString?: RemoteProxyConnectionString +) { + const { bindingOptions } = buildMiniflareBindingOptions( + { + name: "test-worker", + complianceRegion: undefined, + bindings: { HYPERDRIVE: binding }, + queueConsumers: undefined, + migrations: undefined, + exports: undefined, + tails: [], + streamingTails: [], + containerDOClassNames: undefined, + containerBuildId: undefined, + enableContainers: false, + }, + connectionString + ); + return bindingOptions.hyperdrives; +} + +describe("hyperdrive bindings in local dev", () => { + const std = mockConsoleMethods(); + + it("passes the local connection string through for a local binding", ({ + expect, + }) => { + expect( + buildHyperdriveOptions({ + type: "hyperdrive", + id: "hyperdrive-id", + localConnectionString: "postgres://user:pass@localhost:5432/db", + }) + ).toEqual({ HYPERDRIVE: "postgres://user:pass@localhost:5432/db" }); + expect(std.warn).toBe(""); + }); + + it("hands miniflare the remote proxy connection string for a remote binding", ({ + expect, + }) => { + expect( + buildHyperdriveOptions( + { + type: "hyperdrive", + id: "hyperdrive-id", + remote: true, + }, + remoteProxyConnectionString + ) + ).toEqual({ + HYPERDRIVE: { + localConnectionString: undefined, + remoteProxyConnectionString, + }, + }); + expect(std.warn).toBe(""); + }); + + it("explains how to fix a remote binding that has neither a session nor a local database", ({ + expect, + }) => { + expect(() => + buildHyperdriveOptions({ + type: "hyperdrive", + id: "hyperdrive-id", + remote: true, + }) + ).toThrowErrorMatchingInlineSnapshot( + `[Error: The Hyperdrive binding "HYPERDRIVE" is configured with "remote": true, but no remote connection could be established, and it has no local database to fall back to. Please make sure you are authenticated (run \`wrangler login\`) and connected to the internet so that Wrangler can reach your Hyperdrive configuration, or set the value of the 'CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE' variable or "HYPERDRIVE"'s "localConnectionString" to a local Postgres connection string.]` + ); + }); + + it("warns when a remote binding without a session falls back to its local database", ({ + expect, + }) => { + expect( + buildHyperdriveOptions({ + type: "hyperdrive", + id: "hyperdrive-id", + remote: true, + localConnectionString: "postgres://user:pass@localhost:5432/db", + }) + ).toEqual({ HYPERDRIVE: "postgres://user:pass@localhost:5432/db" }); + expect(std.warn).toContain( + `The Hyperdrive binding "HYPERDRIVE" is configured with "remote": true, but no remote connection could be established. Falling back to its "localConnectionString".` + ); + }); +}); diff --git a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts index 9997e287cb1..4eaad0d68d4 100644 --- a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts @@ -308,8 +308,11 @@ export class LocalRuntimeController extends RuntimeController { if (data.config.dev?.remote !== false) { // note: remote bindings use (transitively) LocalRuntimeController, so we need to import // from the module lazily in order to avoid circular dependency issues - const { maybeStartOrUpdateRemoteProxySession, pickRemoteBindings } = - await import("../remoteBindings"); + const { + maybeStartOrUpdateRemoteProxySession, + pickRemoteBindings, + seedRemoteHyperdriveBindings, + } = await import("../remoteBindings"); const remoteBindings = pickRemoteBindings(configBundle.bindings ?? {}); @@ -326,6 +329,15 @@ export class LocalRuntimeController extends RuntimeController { undefined : data.config.dev.auth ); + + // Remote Hyperdrive bindings need the edge session's connection + // string seeded into their local config before miniflare options are + // built (that step is synchronous). No-op unless a remote proxy + // session is running and there are remote Hyperdrive bindings. + await seedRemoteHyperdriveBindings( + configBundle.bindings ?? undefined, + this.#remoteProxySessionData?.session?.remoteProxyConnectionString + ); } // Bail out if a newer bundle arrived while we were setting up diff --git a/packages/wrangler/src/dev.ts b/packages/wrangler/src/dev.ts index 3a47a5bac8e..a873da84aa8 100644 --- a/packages/wrangler/src/dev.ts +++ b/packages/wrangler/src/dev.ts @@ -483,9 +483,12 @@ function applyHyperdriveEnvVars(config: Config, local: boolean): void { connectionStringFromEnv = process.env[varName]; } - // only require a local connection string in the wrangler file or the env if not using dev --remote + // only require a local connection string in the wrangler file or the env + // if not using dev --remote, and not opted into a remote Hyperdrive + // binding (which tunnels to the edge instead of a local database) if ( local && + !hyperdrive.remote && connectionStringFromEnv === undefined && hyperdrive.localConnectionString === undefined ) { diff --git a/packages/wrangler/src/dev/miniflare/index.ts b/packages/wrangler/src/dev/miniflare/index.ts index 15cebdcf76f..3a06a4428bf 100644 --- a/packages/wrangler/src/dev/miniflare/index.ts +++ b/packages/wrangler/src/dev/miniflare/index.ts @@ -344,7 +344,54 @@ function pipelineEntry( throw new Error("Pipeline must have either a stream"); } } -function hyperdriveEntry(hyperdrive: CfHyperdrive): [string, string] { +function hyperdriveEntry( + hyperdrive: CfHyperdrive, + remoteProxyConnectionString?: RemoteProxyConnectionString +): + | [string, string] + | [ + string, + { + localConnectionString?: string; + remoteProxyConnectionString: RemoteProxyConnectionString; + }, + ] { + // Remote binding: tunnel the connection through the remote-bindings proxy to + // the edge Hyperdrive configuration. `localConnectionString` is still passed + // (seeded with the edge session's credentials, see + // `seedRemoteHyperdriveBindings`) so workerd can synthesise the binding's + // `connectionString`; miniflare uses `remoteProxyConnectionString` to stand + // up the local TCP bridge. + if (hyperdrive.remote && remoteProxyConnectionString) { + return [ + hyperdrive.binding, + { + localConnectionString: hyperdrive.localConnectionString, + remoteProxyConnectionString, + }, + ]; + } + + // Opted into a remote binding, but no remote proxy session is available + // (not logged in, offline, or remote bindings turned off). There is nothing + // to tunnel to, so fall back to the local database if one was configured and + // otherwise explain how to get either half working — without this the empty + // connection string below fails miniflare's URL validation with an opaque + // error. + if (hyperdrive.remote) { + if (hyperdrive.localConnectionString === undefined) { + throw new UserError( + `The Hyperdrive binding "${hyperdrive.binding}" is configured with "remote": true, but no remote connection could be established, and it has no local database to fall back to. Please make sure you are authenticated (run \`wrangler login\`) and connected to the internet so that Wrangler can reach your Hyperdrive configuration, or set the value of the 'CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_${hyperdrive.binding}' variable or "${hyperdrive.binding}"'s "localConnectionString" to a local Postgres connection string.`, + { + telemetryMessage: "no remote hyperdrive session or connection string", + } + ); + } + logger.once.warn( + `The Hyperdrive binding "${hyperdrive.binding}" is configured with "remote": true, but no remote connection could be established. Falling back to its "localConnectionString".` + ); + } + return [hyperdrive.binding, hyperdrive.localConnectionString ?? ""]; } function workflowEntry( @@ -892,7 +939,11 @@ export function buildMiniflareBindingOptions( pipelineEntry(pipeline, remoteProxyConnectionString) ) ), - hyperdrives: Object.fromEntries(hyperdrives.map(hyperdriveEntry)), + hyperdrives: Object.fromEntries( + hyperdrives.map((hyperdrive) => + hyperdriveEntry(hyperdrive, remoteProxyConnectionString) + ) + ), analyticsEngineDatasets: Object.fromEntries( analyticsEngineDatasets.map((binding) => [ binding.binding,