diff --git a/.changeset/v1-remote-analytics-engine.md b/.changeset/v1-remote-analytics-engine.md new file mode 100644 index 00000000000..5eb02831eb9 --- /dev/null +++ b/.changeset/v1-remote-analytics-engine.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/vite-plugin": minor +--- + +Support remote Analytics Engine bindings in Vite plugin v1 + +Set `remote: true` on an `analytics_engine_datasets` entry in `wrangler.jsonc` to send `writeDataPoint()` calls from local development and preview to the real dataset. diff --git a/packages/remote-bindings/src/analytics-engine-proxy.test.ts b/packages/remote-bindings/src/analytics-engine-proxy.test.ts new file mode 100644 index 00000000000..d2b8656569a --- /dev/null +++ b/packages/remote-bindings/src/analytics-engine-proxy.test.ts @@ -0,0 +1,62 @@ +import { newWebSocketRpcSession } from "capnweb"; +import { Miniflare } from "miniflare"; +import { test } from "vitest"; +import remoteBindingsWorkerSource from "worker:remoteBindings/ProxyServerWorker"; + +test("forwards Analytics Engine writes through the retained remote proxy", async ({ + expect, + onTestFinished, +}) => { + const miniflare = new Miniflare({ + workers: [ + { + config: { + name: "analytics-engine-proxy", + compatibilityDate: "2026-10-23", + manifest: { + mainModule: "index.mjs", + modules: { + "proxy.mjs": { + type: "esm", + contents: remoteBindingsWorkerSource, + }, + "index.mjs": { + type: "esm", + contents: ` +import proxy from "./proxy.mjs"; +const writes = []; +// Like the native binding, this has a custom prototype that capnweb cannot expose directly. +class AnalyticsEngine { + writeDataPoint(event) { writes.push(event); } +} +const dataset = new AnalyticsEngine(); +export default { + fetch(request, env, ctx) { + if (new URL(request.url).pathname === "/writes") { + return Response.json(writes); + } + return proxy.fetch(request, { EVENTS: dataset }, ctx); + } +};`, + }, + }, + }, + env: {}, + }, + }, + ], + }); + onTestFinished(() => miniflare.dispose()); + const url = await miniflare.ready; + const remote = newWebSocketRpcSession<{ + writeDataPoint(event?: { blobs: string[]; indexes: string[] }): void; + }>(`ws://${url.host}/?MF-Binding=EVENTS`); + try { + const event = { blobs: ["vite-v1"], indexes: ["retained-proxy"] }; + await remote.writeDataPoint(event); + const response = await miniflare.dispatchFetch(new URL("/writes", url)); + expect(await response.json()).toEqual([event]); + } finally { + remote[Symbol.dispose](); + } +}); diff --git a/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts b/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts index cd37f48e130..a9ea5fb708e 100644 --- a/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts +++ b/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts @@ -25,6 +25,8 @@ class BindingNotFoundError extends Error { * - SendEmail bindings need to take EmailMessage as their first parameter, * which is not serialisable. As such, we reconstruct it before sending it * on to the binding. See packages/miniflare/src/workers/email/email.worker.ts + * - Hyperdrive and Analytics Engine bindings are native objects that capnweb + * can't expose directly, so we wrap their methods in a plain object. * - Dispatch Namespace bindings have a synchronous .get() method. Since we * can't emulate that over an async boundary, we mock it locally and _actually_ * perform the .get() remotely at the first appropriate async point. See @@ -61,6 +63,15 @@ function getExposedJSRPCBinding(request: Request, env: Env) { }; } + if (targetBinding.constructor.name === "AnalyticsEngine") { + const dataset = targetBinding as AnalyticsEngineDataset; + return { + writeDataPoint(event?: AnalyticsEngineDataPoint) { + dataset.writeDataPoint(event); + }, + }; + } + if (targetBinding.constructor.name === "SendEmail") { return { async send(e: SendEmailInput) {