Skip to content
Merged
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
9 changes: 9 additions & 0 deletions src/cloudflare/internal/hyperdrive-mysql-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Copyright (c) 2026 Cloudflare, Inc.
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
// https://opensource.org/licenses/Apache-2.0

import { Hyperdrive, type HyperdriveEnv } from 'cloudflare-internal:hyperdrive';

export default function makeBinding(env: HyperdriveEnv): Hyperdrive {
return new Hyperdrive(env, 'mysql');
}
9 changes: 9 additions & 0 deletions src/cloudflare/internal/hyperdrive-postgres-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Copyright (c) 2026 Cloudflare, Inc.
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
// https://opensource.org/licenses/Apache-2.0

import { Hyperdrive, type HyperdriveEnv } from 'cloudflare-internal:hyperdrive';

export default function makeBinding(env: HyperdriveEnv): Hyperdrive {
return new Hyperdrive(env, 'postgresql');
}
104 changes: 104 additions & 0 deletions src/cloudflare/internal/hyperdrive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Copyright (c) 2026 Cloudflare, Inc.
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
// https://opensource.org/licenses/Apache-2.0

import entrypoints from 'cloudflare-internal:workers';
import wrappedBinding from 'cloudflare-internal:wrapped-binding';
import { type Socket } from 'cloudflare-internal:sockets';

// Shared implementation of the Hyperdrive bindings. Each database scheme has its own wrapped binding
// module (`cloudflare-internal:hyperdrive-postgres-api` and `cloudflare-internal:hyperdrive-mysql-api`)
// so that the scheme, and therefore the port, is implied by the module name. The module name is
// preserved when a wrapped binding is serialized, so the binding's only inner binding is `fetcher`.

// A service binding to the Hyperdrive RPC worker. A random virtual host is mapped to it, so that
// `cloudflare:sockets` connect() calls to that host and the database port route through it.
interface HyperdriveFetcher {
connect(address: string): Socket;
}

export interface HyperdriveEnv {
fetcher: HyperdriveFetcher;
}

export type HyperdriveScheme = 'postgresql' | 'mysql';

const PORTS: Record<HyperdriveScheme, number> = {
postgresql: 5432,
mysql: 3306,
};

// 16 random bytes as 32 lowercase hex characters, which need no escaping in a connection string.
function randomHex(): string {
return Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) =>
b.toString(16).padStart(2, '0')
).join('');
}

export class Hyperdrive extends wrappedBinding.WrappedBinding {
readonly #fetcher: HyperdriveFetcher;
readonly #port: number;
readonly #scheme: HyperdriveScheme;
readonly #host: string;
#database: string | undefined;
#user: string | undefined;
#password: string | undefined;

declare readonly database: string;
declare readonly user: string;
declare readonly password: string;
declare readonly scheme: HyperdriveScheme;
declare readonly connectionString: string;
declare readonly host: string;
declare readonly port: number;
declare readonly connect: () => Socket;

constructor(env: HyperdriveEnv, scheme: HyperdriveScheme) {
super(env.fetcher);
this.#fetcher = env.fetcher;
this.#scheme = scheme;
this.#port = PORTS[scheme];
const authority = entrypoints.mapVirtualHost(this.#fetcher, this.#port);
this.#host = authority.slice(0, authority.lastIndexOf(':'));

// Own, read-only properties, matching the shape of the native Hyperdrive binding.
Object.defineProperties(this, {
database: { enumerable: true, get: (): string => this.#getDatabase() },
user: { enumerable: true, get: (): string => this.#getUser() },
password: { enumerable: true, get: (): string => this.#getPassword() },
scheme: { enumerable: true, get: (): HyperdriveScheme => this.#scheme },
connectionString: {
enumerable: true,
get: (): string => this.#getConnectionString(),
},
host: { enumerable: true, get: (): string => this.#host },
port: { enumerable: true, get: (): number => this.#port },
connect: {
value: (): Socket =>
this.#fetcher.connect(`${this.#host}:${this.#port}`),
},
});
}

// The worker never sees the real database credentials; the Hyperdrive RPC worker holds them and
// the binding exposes independent random placeholders instead. Each is generated once per binding,
// on first access: bindings are constructed at global scope, where generating random values is
// disallowed.
#getDatabase(): string {
return (this.#database ??= randomHex());
}

#getUser(): string {
return (this.#user ??= randomHex());
}

#getPassword(): string {
return (this.#password ??= randomHex());
}

#getConnectionString(): string {
const sslOption =
this.#scheme === 'postgresql' ? 'sslmode=disable' : 'ssl-mode=disabled';
return `${this.#scheme}://${this.#getUser()}:${this.#getPassword()}@${this.#host}:${this.#port}/${this.#getDatabase()}?${sslOption}`;
}
}
7 changes: 7 additions & 0 deletions src/cloudflare/internal/test/hyperdrive/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
load("//:build/wd_test.bzl", "wd_test")

wd_test(
src = "hyperdrive-test.wd-test",
args = ["--experimental"],
data = glob(["*.js"]),
)
17 changes: 17 additions & 0 deletions src/cloudflare/internal/test/hyperdrive/hyperdrive-mock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright (c) 2026 Cloudflare, Inc.
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
// https://opensource.org/licenses/Apache-2.0

// Simulates the Hyperdrive RPC worker's TCP side: replies with the authority the socket was
// opened against, then echoes everything written to it.
export default {
async connect(socket) {
const { localAddress } = await socket.opened;
const writer = socket.writable.getWriter();
await writer.write(new TextEncoder().encode(`${localAddress}\n`));
for await (const chunk of socket.readable) {
await writer.write(chunk);
}
await writer.close();
},
};
246 changes: 246 additions & 0 deletions src/cloudflare/internal/test/hyperdrive/hyperdrive-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
// Copyright (c) 2026 Cloudflare, Inc.
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
// https://opensource.org/licenses/Apache-2.0

import assert from 'node:assert';
import { connect } from 'cloudflare:sockets';
import { WorkerEntrypoint } from 'cloudflare:workers';

const HOST_PATTERN = /^[0-9a-f]{32}\.workers\.alt$/;
const CREDENTIAL_PATTERN = /^[0-9a-f]{32}$/;

const PROPERTY_NAMES = [
'database',
'user',
'password',
'scheme',
'connectionString',
'host',
'port',
];

async function readLine(reader) {
const decoder = new TextDecoder();
let text = '';
while (!text.includes('\n')) {
const { value, done } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
}
const newline = text.indexOf('\n');
return { line: text.slice(0, newline), rest: text.slice(newline + 1) };
}

export const postgresProperties = {
test(_ctrl, env) {
const hd = env.HYPERDRIVE_PG;
assert.match(hd.database, CREDENTIAL_PATTERN);
assert.match(hd.user, CREDENTIAL_PATTERN);
assert.strictEqual(hd.scheme, 'postgresql');
assert.strictEqual(hd.port, 5432);
assert.match(hd.host, HOST_PATTERN);
// The host is stable across reads.
assert.strictEqual(hd.host, hd.host);
},
};

export const mysqlProperties = {
test(_ctrl, env) {
const hd = env.HYPERDRIVE_MYSQL;
assert.match(hd.database, CREDENTIAL_PATTERN);
assert.match(hd.user, CREDENTIAL_PATTERN);
assert.strictEqual(hd.scheme, 'mysql');
assert.strictEqual(hd.port, 3306);
assert.match(hd.host, HOST_PATTERN);
},
};

// database, user, and password are independent random placeholders: the real credentials only
// reach the Hyperdrive RPC worker. They are not embedded in the host.
export const generatedCredentials = {
test(_ctrl, env) {
const pg = env.HYPERDRIVE_PG;
const mysql = env.HYPERDRIVE_MYSQL;
for (const name of ['database', 'user', 'password']) {
assert.ok(!pg.host.includes(pg[name]), name);
assert.match(pg[name], CREDENTIAL_PATTERN, name);
assert.match(mysql[name], CREDENTIAL_PATTERN, name);
// Generated once per binding instance and then stable.
assert.strictEqual(pg[name], pg[name], name);
// Independent per binding instance.
assert.notStrictEqual(pg[name], mysql[name], name);
}
// Independent of each other.
assert.strictEqual(new Set([pg.database, pg.user, pg.password]).size, 3);
},
};

export const postgresConnectionString = {
test(_ctrl, env) {
const hd = env.HYPERDRIVE_PG;
assert.strictEqual(
hd.connectionString,
`postgresql://${hd.user}:${hd.password}@${hd.host}:5432/${hd.database}?sslmode=disable`
);

const url = new URL(hd.connectionString);
assert.strictEqual(url.protocol, 'postgresql:');
assert.strictEqual(url.username, hd.user);
assert.strictEqual(url.password, hd.password);
assert.strictEqual(url.hostname, hd.host);
assert.strictEqual(url.port, '5432');
assert.strictEqual(url.pathname, `/${hd.database}`);
assert.strictEqual(url.searchParams.get('sslmode'), 'disable');
},
};

export const mysqlConnectionString = {
test(_ctrl, env) {
const hd = env.HYPERDRIVE_MYSQL;
assert.strictEqual(
hd.connectionString,
`mysql://${hd.user}:${hd.password}@${hd.host}:3306/${hd.database}?ssl-mode=disabled`
);

// Drivers parse the generated credentials back out unchanged; hex needs no URL escaping.
const url = new URL(hd.connectionString);
assert.strictEqual(url.protocol, 'mysql:');
assert.strictEqual(url.username, hd.user);
assert.strictEqual(url.password, hd.password);
assert.strictEqual(url.hostname, hd.host);
assert.strictEqual(url.port, '3306');
assert.strictEqual(url.pathname, `/${hd.database}`);
assert.strictEqual(url.searchParams.get('ssl-mode'), 'disabled');
},
};

export const propertyShape = {
test(_ctrl, env) {
const hd = env.HYPERDRIVE_PG;

// Data properties are own, enumerable, read-only accessors; connect is not enumerable.
assert.deepStrictEqual(Object.keys(hd), PROPERTY_NAMES);
for (const name of PROPERTY_NAMES) {
const desc = Object.getOwnPropertyDescriptor(hd, name);
assert.ok(desc, name);
assert.strictEqual(desc.enumerable, true, name);
assert.strictEqual(desc.configurable, false, name);
assert.strictEqual(typeof desc.get, 'function', name);
assert.strictEqual(desc.set, undefined, name);
}

const connectDesc = Object.getOwnPropertyDescriptor(hd, 'connect');
assert.ok(connectDesc);
assert.strictEqual(typeof connectDesc.value, 'function');
assert.strictEqual(connectDesc.enumerable, false);
assert.strictEqual(connectDesc.writable, false);
assert.strictEqual(connectDesc.configurable, false);

// Modules are strict, so writes to read-only properties throw.
assert.throws(() => {
hd.password = 'nope';
}, TypeError);
assert.throws(() => {
hd.connect = () => {};
}, TypeError);
assert.throws(() => {
delete hd.host;
}, TypeError);

// connect is a single stable function that can't be redefined.
assert.strictEqual(hd.connect, hd.connect);
assert.throws(
() => Object.defineProperty(hd, 'connect', { value: () => {} }),
TypeError
);

// The inner fetcher is not exposed: no fetch(), no RPC wildcard, no private helpers.
assert.strictEqual(hd.fetch, undefined);
assert.strictEqual(hd.unexpectedRpcMethod, undefined);
assert.strictEqual('getPassword' in hd, false);
assert.strictEqual('fetcher' in hd, false);

// Private state isn't leaked through JSON or spread.
assert.deepStrictEqual(Object.keys({ ...hd }), PROPERTY_NAMES);
assert.deepStrictEqual(Object.keys(JSON.parse(JSON.stringify(hd))), [
...PROPERTY_NAMES,
]);
},
};

async function testConnect(socket, hd) {
await socket.opened;

const writer = socket.writable.getWriter();
const reader = socket.readable.getReader();

// The mock reports the authority the socket was opened against.
const { line, rest } = await readLine(reader);
assert.strictEqual(line, `${hd.host}:${hd.port}`);

// Round-trip data through the socket.
const payload = 'SELECT 1;';
await writer.write(new TextEncoder().encode(payload));
const decoder = new TextDecoder();
let echoed = rest;
while (echoed.length < payload.length) {
const { value, done } = await reader.read();
if (done) break;
echoed += decoder.decode(value, { stream: true });
}
assert.strictEqual(echoed, payload);

await writer.close();
reader.releaseLock();
await socket.close();
}

export const postgresConnect = {
async test(_ctrl, env) {
// connect() is usable detached from the binding.
const { connect } = env.HYPERDRIVE_PG;
await testConnect(connect(), env.HYPERDRIVE_PG);
},
};

export const mysqlConnect = {
async test(_ctrl, env) {
const { connect } = env.HYPERDRIVE_MYSQL;
await testConnect(connect(), env.HYPERDRIVE_MYSQL);
},
};

// The host is a virtual host mapped to the Hyperdrive service, so drivers that connect through
// `cloudflare:sockets` reach it too.
export const socketsConnect = {
async test(_ctrl, env) {
for (const hd of [env.HYPERDRIVE_PG, env.HYPERDRIVE_MYSQL]) {
await testConnect(connect(`${hd.host}:${hd.port}`), hd);
}
},
};

// Receives Hyperdrive bindings over RPC. The wrapped binding is rebuilt from its fetcher and module
// name alone, so the scheme and port must come from the module name.
export class Receiver extends WorkerEntrypoint {
async inspect(hd) {
assert.match(hd.host, HOST_PATTERN);
assert.match(hd.database, CREDENTIAL_PATTERN);
await testConnect(hd.connect(), hd);
await testConnect(connect(`${hd.host}:${hd.port}`), hd);
return { scheme: hd.scheme, port: hd.port };
}
}

export const rpcSerialization = {
async test(_ctrl, env) {
assert.deepStrictEqual(await env.RECEIVER.inspect(env.HYPERDRIVE_PG), {
scheme: 'postgresql',
port: 5432,
});
assert.deepStrictEqual(await env.RECEIVER.inspect(env.HYPERDRIVE_MYSQL), {
scheme: 'mysql',
port: 3306,
});
},
};
Loading
Loading