Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/hyperdrive-remote-bindings-local-dev.md
Original file line number Diff line number Diff line change
@@ -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": "<your-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.
5 changes: 5 additions & 0 deletions packages/config/src/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
120 changes: 120 additions & 0 deletions packages/miniflare/src/plugins/hyperdrive/hyperdrive-proxy.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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:<port>`; 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<number> {
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<number>((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
Expand Down
87 changes: 79 additions & 8 deletions packages/miniflare/src/plugins/hyperdrive/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -71,24 +71,71 @@ 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<RemoteProxyConnectionString>()
.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<typeof HyperdriveEntrySchema>
): 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<typeof HyperdriveInputOptionsSchema> = {
options: HyperdriveInputOptionsSchema,
bindingTypeDescription: "Hyperdrive",
getBindings(options) {
return Object.entries(options.hyperdrives ?? {}).map<Worker_Binding>(
([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:<name>` 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),
Expand All @@ -100,7 +147,8 @@ export const HYPERDRIVE_PLUGIN: Plugin<typeof HyperdriveInputOptionsSchema> = {
},
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<string | symbol, string | number> = {
connectionString: `${url}`,
port: Number.parseInt(url.port),
Expand All @@ -119,7 +167,30 @@ export const HYPERDRIVE_PLUGIN: Plugin<typeof HyperdriveInputOptionsSchema> = {
},
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:<name>` 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);
Expand Down
Loading
Loading