diff --git a/.changeset/v1-remote-hyperdrive.md b/.changeset/v1-remote-hyperdrive.md new file mode 100644 index 00000000000..0a0de6ed040 --- /dev/null +++ b/.changeset/v1-remote-hyperdrive.md @@ -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. diff --git a/packages/remote-bindings/src/start-remote-proxy-session.test.ts b/packages/remote-bindings/src/start-remote-proxy-session.test.ts index d3d0cfa247e..428fb27b414 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.test.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.test.ts @@ -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(() => {}) }, - ready: { promise: new Promise(() => {}) }, + localServerReady: { + // With an error configured, the error event settles startup instead. + promise: + mockDevEnv.error === undefined + ? Promise.resolve() + : new Promise(() => {}), + }, + ready: { + promise: Promise.resolve({ url: new URL("http://localhost:8787") }), + }, runtimeMessageMutex: { drained: async () => {} }, }; constructor() { @@ -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 }; }); @@ -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 diff --git a/packages/remote-bindings/src/start-remote-proxy-session.ts b/packages/remote-bindings/src/start-remote-proxy-session.ts index 08e1fb869dd..aa4d9d78daa 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.ts @@ -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; } @@ -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 | undefined; const updateBindings = async ( newBindings: StartDevWorkerInput["bindings"] ) => { @@ -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; + }, }; } diff --git a/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts b/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts index 055bfeda91d..cd37f48e130 100644 --- a/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts +++ b/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts @@ -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) {