Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .changeset/v1-remote-hyperdrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@cloudflare/vite-plugin": minor
---

Support remote Hyperdrive bindings in Vite plugin v1 development and preview

Set `remote: true` on a Hyperdrive binding in `wrangler.jsonc` to connect to its deployed configuration without a local database connection string. The retained remote proxy exposes the binding's connection string and socket streams, and remote session cleanup preserves startup errors and shares one disposal attempt.
80 changes: 69 additions & 11 deletions packages/remote-bindings/src/start-remote-proxy-session.test.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,33 @@
import { APIError, UserError } from "@cloudflare/workers-utils";
import { describe, it, vi } from "vitest";
import { afterEach, describe, it, vi } from "vitest";
import { startRemoteProxySession } from "./start-remote-proxy-session";
import { RemoteSessionAuthenticationError } from "./utils/remote";
import type { RemoteBindingsLogger } from "./logger";
import type { ErrorEvent } from "./startDevWorker/events";

// The error that the mocked `DevEnv` emits asynchronously from `start()`.
const mockDevEnv = vi.hoisted(() => ({ error: undefined as unknown }));

// Replace `DevEnv` with a stub that never becomes ready and instead emits a
// configurable error event just after `start()`, so we can exercise the async
// error-handling path in `startRemoteProxySession` without any real Miniflare.
const mockDevEnv = vi.hoisted(() => ({
error: undefined as unknown,
startError: undefined as unknown,
teardown: vi.fn(async () => {}),
}));

// Replace `DevEnv` with a configurable stub so we can exercise startup errors
// and cleanup without any real Miniflare.
vi.mock("./startDevWorker/DevEnv", async () => {
const { EventEmitter } = await import("node:events");
class DevEnv extends EventEmitter {
proxy = {
// Never resolves — the startup race is always settled by the error event.
localServerReady: { promise: new Promise<void>(() => {}) },
ready: { promise: new Promise(() => {}) },
localServerReady: {
// With an error configured, the error event settles startup instead.
promise:
mockDevEnv.error === undefined
? Promise.resolve()
: new Promise<void>(() => {}),
},
ready: {
promise: Promise.resolve({ url: new URL("http://localhost:8787") }),
},
runtimeMessageMutex: { drained: async () => {} },
};
constructor() {
Expand All @@ -26,10 +36,15 @@ vi.mock("./startDevWorker/DevEnv", async () => {
this.on("error", () => {});
}
start() {
queueMicrotask(() => this.emit("error", mockDevEnv.error));
if (mockDevEnv.startError !== undefined) {
throw mockDevEnv.startError;
}
if (mockDevEnv.error !== undefined) {
queueMicrotask(() => this.emit("error", mockDevEnv.error));
}
}
update() {}
async teardown() {}
teardown = mockDevEnv.teardown;
}
return { DevEnv };
});
Expand Down Expand Up @@ -81,6 +96,49 @@ describe("startRemoteProxySession error handling", () => {
});
});

afterEach(() => {
mockDevEnv.error = undefined;
mockDevEnv.startError = undefined;
mockDevEnv.teardown.mockReset();
mockDevEnv.teardown.mockResolvedValue(undefined);
});

it("disposes only once even when teardown rejects", async ({ expect }) => {
const error = new Error("cleanup failure");
mockDevEnv.teardown.mockRejectedValueOnce(error);
const session = await startRemoteProxySession(
{},
{ cliDisplayName: "Test CLI", logger: createTestLogger() }
);
const first = session.dispose();
const second = session.dispose();
expect(second).toBe(first);
await expect(first).rejects.toBe(error);
expect(session.dispose()).toBe(first);
expect(mockDevEnv.teardown).toHaveBeenCalledOnce();
});

it.for(["start", "async"])(
"preserves the %s startup error when cleanup fails",
async (stage, { expect }) => {
const original = new UserError("original startup failure", {
telemetryMessage: "test startup error",
});
if (stage === "start") {
mockDevEnv.startError = original;
} else {
mockDevEnv.error = original;
}
mockDevEnv.teardown.mockRejectedValueOnce(new Error("cleanup failure"));
await expect(
startRemoteProxySession(
{},
{ cliDisplayName: "Test CLI", logger: createTestLogger() }
)
).rejects.toBe(original);
expect(mockDevEnv.teardown).toHaveBeenCalledOnce();
}
);
// Regression: a `UserError` emitted asynchronously (e.g. the Miniflare
// user-error case in `DevEnv.handleErrorEvent`) used to fall through to being
// wrapped in a generic `Error`, which makes Wrangler treat it as an
Expand Down
19 changes: 16 additions & 3 deletions packages/remote-bindings/src/start-remote-proxy-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,11 @@ export async function startRemoteProxySession(
devEnv = new DevEnv(workerConfig);
devEnv.start();
} catch (startWorkerError: unknown) {
await devEnv?.teardown();
try {
await devEnv?.teardown();
} catch {
// Preserve the original startup error if cleanup also fails.
}
if (startWorkerError instanceof UserError) {
throw startWorkerError;
}
Expand Down Expand Up @@ -193,11 +197,16 @@ export async function startRemoteProxySession(
remoteProxyConnectionString = (await devEnv.proxy.ready.promise)
.url as RemoteProxyConnectionString;
} catch (error) {
await devEnv.teardown();
try {
await devEnv.teardown();
} catch {
// Preserve the original startup error if cleanup also fails.
}
throw error;
} finally {
devEnv.removeListener("error", onStartupError);
}
let disposePromise: Promise<void> | undefined;
const updateBindings = async (
newBindings: StartDevWorkerInput["bindings"]
) => {
Expand Down Expand Up @@ -225,7 +234,11 @@ export async function startRemoteProxySession(
ready: Promise.resolve(),
remoteProxyConnectionString,
updateBindings,
dispose: () => devEnv.teardown(),
dispose: () => {
// Share the same cleanup attempt across calls, even if it fails.
disposePromise ??= devEnv.teardown();
return disposePromise;
},
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,19 @@ function getExposedJSRPCBinding(request: Request, env: Env) {
throw new BindingNotFoundError(bindingName);
}

if (targetBinding.constructor.name === "Hyperdrive") {
const hyperdrive = targetBinding as Hyperdrive;
return {
getConnectionString() {
return hyperdrive.connectionString;
},
connect() {
const socket = hyperdrive.connect();
return { readable: socket.readable, writable: socket.writable };
},
};
}

if (targetBinding.constructor.name === "SendEmail") {
return {
async send(e: SendEmailInput) {
Expand Down
Loading