From 93554d29838792305c8db7a22cc0905177ba5e0b Mon Sep 17 00:00:00 2001 From: John Royal <34844819+john-royal@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:01:56 -0400 Subject: [PATCH 1/8] fix(vite-plugin): use module fallback service to handle wasm imports --- .../src/plugins/additional-modules.ts | 2 +- .../cloudflare-runtime/src/RuntimeWorker.ts | 2 + .../cloudflare-vite-plugin/src/dev-server.ts | 114 +++++++++++++++++- .../src/module-runner/env.worker.ts | 2 + 4 files changed, 118 insertions(+), 2 deletions(-) diff --git a/packages/cloudflare-rolldown-plugin/src/plugins/additional-modules.ts b/packages/cloudflare-rolldown-plugin/src/plugins/additional-modules.ts index d2f530b8..339902a0 100644 --- a/packages/cloudflare-rolldown-plugin/src/plugins/additional-modules.ts +++ b/packages/cloudflare-rolldown-plugin/src/plugins/additional-modules.ts @@ -17,7 +17,7 @@ export const MODULE_RULES = [ ] as const; const MODULE_REFERENCE_PATTERN = `__CLOUDFLARE_MODULE__(${MODULE_RULES.map((rule) => rule.type).join("|")})__(.*?)__CLOUDFLARE_MODULE__`; -const MODULE_REFERENCE_REGEX = new RegExp(MODULE_REFERENCE_PATTERN); +export const MODULE_REFERENCE_REGEX = new RegExp(MODULE_REFERENCE_PATTERN); const MODULE_REFERENCE_GLOBAL_REGEX = new RegExp(MODULE_REFERENCE_PATTERN, "g"); export const additionalModulesPlugin = createPlugin("additional-modules", () => { diff --git a/packages/cloudflare-runtime/src/RuntimeWorker.ts b/packages/cloudflare-runtime/src/RuntimeWorker.ts index 8801b7f7..2cdcf1bc 100644 --- a/packages/cloudflare-runtime/src/RuntimeWorker.ts +++ b/packages/cloudflare-runtime/src/RuntimeWorker.ts @@ -2,6 +2,7 @@ import type { HyperdriveOrigin } from "./bindings/hyperdrive/HyperdriveOrigin.sh import type { QueueConsumer } from "./bindings/queue/QueueOptions.shared.ts"; import type { ContainerImage } from "./Docker.ts"; import type { BindingHook } from "./PluginContext.ts"; +import type * as WorkerdConfig from "./workerd/Config.ts"; export interface RuntimeWorker { readonly name: string; @@ -18,6 +19,7 @@ export interface RuntimeWorker { * (local or in other dev instances) deliver to it. */ readonly queueConsumers?: ReadonlyArray; + readonly unsafe?: Partial; } export type BindingHooks = ReadonlyArray>; diff --git a/packages/cloudflare-vite-plugin/src/dev-server.ts b/packages/cloudflare-vite-plugin/src/dev-server.ts index 0c895a56..fbe18708 100644 --- a/packages/cloudflare-vite-plugin/src/dev-server.ts +++ b/packages/cloudflare-vite-plugin/src/dev-server.ts @@ -1,3 +1,4 @@ +import { MODULE_REFERENCE_REGEX } from "@distilled.cloud/cloudflare-rolldown-plugin/plugins"; import type { BindingHooks, Module } from "@distilled.cloud/cloudflare-runtime"; import * as Runtime from "@distilled.cloud/cloudflare-runtime/Runtime"; import * as RuntimeServices from "@distilled.cloud/cloudflare-runtime/RuntimeServices"; @@ -9,6 +10,7 @@ import * as Credentials from "@distilled.cloud/cloudflare/Credentials"; import type * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Scope from "effect/Scope"; @@ -30,7 +32,7 @@ export const startServer = async ( options: CloudflareVitePluginOptions, entryEnvironment: EntryEnvironment, server: vite.ViteDevServer, - context: Context.Context, + context: Context.Context, ) => { const scope = Scope.makeUnsafe(); const address = await serve(options, entryEnvironment, server).pipe( @@ -86,6 +88,8 @@ const serve = Effect.fn(function* ( server: vite.ViteDevServer, ) { const runtime = yield* Runtime.Runtime; + const fs = yield* FileSystem.FileSystem; + const name = options.worker?.name ?? `vite-dev-${crypto.randomUUID()}`; return yield* runtime.start({ name, @@ -115,6 +119,46 @@ const serve = Effect.fn(function* ( return HttpServerResponse.jsonUnsafe(result); }), }), + Loopback.local({ + binding: "__DISTILLED_MODULE_FALLBACK__", + name: `vite:module-fallback:${name}`, + handler: Effect.gen(function* () { + const request = yield* ModuleFallbackRequest.pipe(Effect.orElseSucceed(() => undefined)); + if (!request) { + return HttpServerResponse.text("Invalid module fallback request", { status: 400 }); + } + const match = MODULE_REFERENCE_REGEX.exec(request.specifier); + if (!match) { + return HttpServerResponse.text(`No match for module: ${request.specifier}`, { + status: 400, + }); + } + const [full, moduleType, modulePath] = match; + if (!moduleType || !modulePath) { + return HttpServerResponse.text(`Invalid module type or path: ${full}`, { status: 400 }); + } + const content = yield* fs + .readFile(modulePath) + .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined)); + if (!content) { + return HttpServerResponse.text(`Module not found: ${modulePath}`, { status: 404 }); + } + switch (moduleType) { + case "CompiledWasm": { + return yield* HttpServerResponse.json({ wasm: Array.from(content) }); + } + case "Data": { + return yield* HttpServerResponse.json({ data: Array.from(content) }); + } + case "Text": { + return yield* HttpServerResponse.json({ text: content.toString() }); + } + default: { + return HttpServerResponse.text(`Invalid module type: ${moduleType}`, { status: 400 }); + } + } + }), + }), ...(options.worker?.bindings ?? []), ], durableObjectNamespaces: [ @@ -127,9 +171,77 @@ const serve = Effect.fn(function* ( ], hyperdrives: options.worker?.hyperdrives, assets: options.worker?.assets, + unsafe: { + moduleFallback: `vite:module-fallback:${name}`, + ...(options.worker?.unsafe ?? {}), + }, }); }); +const ModuleFallbackRequest = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + if (request.method === "GET") { + const url = new URL(request.url, "http://localhost"); + const specifier = url.searchParams.get("specifier"); + + if (!specifier) { + return; + } + + const resolveMethod = Headers.get(request.headers, "X-Resolve-Method").pipe(Option.getOrThrow); + + return { + protocol: "v1", + type: resolveMethod === "require" ? "require" : "import", + specifier, + rawSpecifier: url.searchParams.get("rawSpecifier") ?? undefined, + referrer: url.searchParams.get("referrer") ?? undefined, + } as ModuleFallbackRequest.V1; + } else if (request.method === "POST") { + const json = yield* request.json; + if (!isV2ModuleFallbackProtocol(json)) { + return; + } + return { protocol: "v2", ...json } as ModuleFallbackRequest.V2; + } +}); + +const isV2ModuleFallbackProtocol = ( + json: unknown, +): json is Omit => { + return typeof json === "object" && json !== null && "specifier" in json; +}; + +declare namespace ModuleFallbackRequest { + /** V1 protocol request (legacy module registry) */ + export interface V1 { + protocol: "v1"; + /** Import type: "import" for ES modules, "require" for CommonJS */ + type: "import" | "require"; + /** Module specifier as a path (e.g., "/my-module.js") */ + specifier: string; + /** Original specifier as written in source code */ + rawSpecifier?: string; + /** Referrer module path */ + referrer?: string; + } + + /** V2 protocol request (new module registry) */ + export interface V2 { + protocol: "v2"; + /** Import type: includes "internal" for runtime-originated imports */ + type: "import" | "require" | "internal"; + /** Module specifier as a URL (e.g., "file:///bundle/my-module.js") */ + specifier: string; + /** Original specifier as written in source code */ + rawSpecifier?: string; + /** Referrer module URL */ + referrer?: string; + /** Import attributes from the import statement */ + attributes?: Array<{ name: string; value: string }>; + } +} + async function makeWorkerModules(options: CloudflareVitePluginOptions): Promise> { const [moduleRunnerWorker, wrapperWorker] = await Promise.all([ ModuleRunnerWorker.worker(), diff --git a/packages/cloudflare-vite-plugin/src/module-runner/env.worker.ts b/packages/cloudflare-vite-plugin/src/module-runner/env.worker.ts index bb84d3c2..f864a50f 100644 --- a/packages/cloudflare-vite-plugin/src/module-runner/env.worker.ts +++ b/packages/cloudflare-vite-plugin/src/module-runner/env.worker.ts @@ -10,6 +10,7 @@ export interface Env extends Cloudflare.Env { }; __DISTILLED_INVOKE_MODULE__: Fetcher; __DISTILLED_ENVIRONMENT__: EntryEnvironment; + __DISTILLED_MODULE_FALLBACK__: Fetcher; [key: string]: unknown; } @@ -19,6 +20,7 @@ export function stripInternalEnv(env: Env) { __DISTILLED_UNSAFE_EVAL__, __DISTILLED_INVOKE_MODULE__, __DISTILLED_ENVIRONMENT__, + __DISTILLED_MODULE_FALLBACK__, ...userEnv } = env; return userEnv; From a016d0651a278ddaf1ec03bcef397eae7d09f6d0 Mon Sep 17 00:00:00 2001 From: John Royal <34844819+john-royal@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:03:37 -0400 Subject: [PATCH 2/8] what's going on... --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9028948b..78d0770c 100644 --- a/package.json +++ b/package.json @@ -69,5 +69,5 @@ "turbo": "^2.9.16", "typescript": "catalog:" }, - "packageManager": "bun@1.3.13" + "packageManager": "bun@1.3.14" } From 1029e44ed61f738ebdc9e207d3aa3b8dde45f4d4 Mon Sep 17 00:00:00 2001 From: John Royal <34844819+john-royal@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:05:22 -0400 Subject: [PATCH 3/8] fix platform services --- packages/cloudflare-vite-plugin/src/dev-server.ts | 5 ++--- packages/cloudflare-vite-plugin/src/plugin.ts | 3 ++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cloudflare-vite-plugin/src/dev-server.ts b/packages/cloudflare-vite-plugin/src/dev-server.ts index fbe18708..594bfd6b 100644 --- a/packages/cloudflare-vite-plugin/src/dev-server.ts +++ b/packages/cloudflare-vite-plugin/src/dev-server.ts @@ -70,9 +70,8 @@ export const createDefaultContext = async (): Promise< accountId: process.env.CLOUDFLARE_ACCOUNT_ID!, }, }).pipe( - Layer.provide( - Layer.mergeAll(Credentials.fromEnv(), importPlatformServices, FetchHttpClient.layer), - ), + Layer.provideMerge(importPlatformServices), + Layer.provide(Layer.merge(Credentials.fromEnv(), FetchHttpClient.layer)), Layer.buildWithScope(scope), Effect.runPromise, ); diff --git a/packages/cloudflare-vite-plugin/src/plugin.ts b/packages/cloudflare-vite-plugin/src/plugin.ts index 116482fa..0f3be34b 100644 --- a/packages/cloudflare-vite-plugin/src/plugin.ts +++ b/packages/cloudflare-vite-plugin/src/plugin.ts @@ -15,6 +15,7 @@ import type { RuntimeWorker, } from "@distilled.cloud/cloudflare-runtime"; import type * as Context from "effect/Context"; +import type * as FileSystem from "effect/FileSystem"; import type * as vite from "vite"; import { dev } from "./dev-plugin.ts"; @@ -22,7 +23,7 @@ export interface CloudflareVitePluginOptions< B extends BindingHooks = BindingHooks, > extends BasePluginOptions { worker?: Omit, "compatibilityDate" | "compatibilityFlags" | "modules">; - context?: Context.Context; + context?: Context.Context; } export default function cloudflareVitePlugin( From c3e73ac18014839502b09083ade10725f3ca8da7 Mon Sep 17 00:00:00 2001 From: John Royal <34844819+john-royal@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:15:22 -0400 Subject: [PATCH 4/8] nah nah honey i'm good --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 78d0770c..9028948b 100644 --- a/package.json +++ b/package.json @@ -69,5 +69,5 @@ "turbo": "^2.9.16", "typescript": "catalog:" }, - "packageManager": "bun@1.3.14" + "packageManager": "bun@1.3.13" } From a8c46ed3cbad997a1fae5aaa7ffdf47e07ff83c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20M=C3=BCller?= Date: Mon, 20 Jul 2026 17:23:39 +0200 Subject: [PATCH 5/8] fix(cloudflare-runtime): drop tag from digest-pinned docker pull refs (#70) --- packages/cloudflare-runtime/src/Docker.ts | 9 +- .../cloudflare-runtime/test/Docker.test.ts | 95 +++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 packages/cloudflare-runtime/test/Docker.test.ts diff --git a/packages/cloudflare-runtime/src/Docker.ts b/packages/cloudflare-runtime/src/Docker.ts index c25c680f..2f67ea5d 100644 --- a/packages/cloudflare-runtime/src/Docker.ts +++ b/packages/cloudflare-runtime/src/Docker.ts @@ -71,6 +71,13 @@ const ContainerEgressInterceptorImage = Config.string("CONTAINER_EGRESS_INTERCEP ), ); +/** + * Docker's containerd image store rejects combined `repo:tag@digest` pull + * refs ("cannot overwrite digest"); the digest fully pins the image, so the + * tag is dropped when both are present. + */ +export const toPullRef = (imageUri: string) => imageUri.replace(/:[^@\/]+(?=@sha256:)/, ""); + export const DockerLive = Layer.effect( Docker, Effect.gen(function* () { @@ -183,7 +190,7 @@ export const DockerLive = Layer.effect( ); const pull = ({ imageUri }: ContainerImage.Pull) => - run(["pull", imageUri, "--platform", "linux/amd64"]).pipe( + run(["pull", toPullRef(imageUri), "--platform", "linux/amd64"]).pipe( Effect.mapError( (cause) => new SystemError({ diff --git a/packages/cloudflare-runtime/test/Docker.test.ts b/packages/cloudflare-runtime/test/Docker.test.ts new file mode 100644 index 00000000..3a89aaa5 --- /dev/null +++ b/packages/cloudflare-runtime/test/Docker.test.ts @@ -0,0 +1,95 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it, layer } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { Docker, DockerLive, toPullRef } from "../src/Docker.ts"; + +const PINNED = + "cloudflare/proxy-everything:3cb1195@sha256:0ef6716c52430096900b150d84a3302057d6cd2319dae7987128c85d0733e3c8"; +const PINNED_WITHOUT_TAG = + "cloudflare/proxy-everything@sha256:0ef6716c52430096900b150d84a3302057d6cd2319dae7987128c85d0733e3c8"; + +describe("Docker", () => { + describe("toPullRef", () => { + it("drops the tag when a digest pins the image", () => { + expect(toPullRef(PINNED)).toBe(PINNED_WITHOUT_TAG); + }); + + it("keeps tag-only refs unchanged", () => { + expect(toPullRef("rocicorp/zero:1.8.0")).toBe("rocicorp/zero:1.8.0"); + }); + + it("keeps digest-only refs unchanged", () => { + expect(toPullRef("repo@sha256:abc")).toBe("repo@sha256:abc"); + }); + + it("preserves registry ports", () => { + expect(toPullRef("registry.example.com:5000/repo:v1@sha256:abc")).toBe( + "registry.example.com:5000/repo@sha256:abc", + ); + }); + }); +}); + +/** Records every spawned argv and pretends each command exited 0 with no output. */ +const spawned: Array> = []; +const SpawnerStub = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + if (command._tag === "StandardCommand") { + spawned.push([command.command, ...command.args]); + } + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }), + ); + }), +); + +layer(Layer.provide(DockerLive, Layer.merge(NodeServices.layer, SpawnerStub)))((it) => { + it.effect("pull strips the tag from digest-pinned refs", () => + Effect.gen(function* () { + spawned.length = 0; + const docker = yield* Docker; + yield* docker.pull("alchemy-test:latest", { imageUri: PINNED }); + expect(spawned).toContainEqual([ + "docker", + "pull", + PINNED_WITHOUT_TAG, + "--platform", + "linux/amd64", + ]); + // only the pull ref is rewritten — the local tag alias keeps the original uri + expect(spawned).toContainEqual(["docker", "tag", PINNED, "alchemy-test:latest"]); + }), + ); + + it.effect("pull passes refs without a digest through unchanged", () => + Effect.gen(function* () { + spawned.length = 0; + const docker = yield* Docker; + yield* docker.pull("alchemy-test:latest", { imageUri: "rocicorp/zero:1.8.0" }); + expect(spawned).toContainEqual([ + "docker", + "pull", + "rocicorp/zero:1.8.0", + "--platform", + "linux/amd64", + ]); + }), + ); +}); From cf02012b61591d194fffb8acf4a5ec8878fcef6c Mon Sep 17 00:00:00 2001 From: John Royal <34844819+john-royal@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:51:27 -0400 Subject: [PATCH 6/8] make it work and add test --- fixtures/tanstack-start/src/routeTree.gen.ts | 24 +- .../tanstack-start/src/routes/api.wasm.ts | 21 ++ fixtures/tanstack-start/src/wasm-example.wasm | Bin 0 -> 41 bytes fixtures/tanstack-start/test/smoke.test.ts | 5 + packages/cloudflare-runtime/src/Runtime.ts | 1 + .../src/dev-environment.ts | 8 +- .../cloudflare-vite-plugin/src/dev-server.ts | 212 ++++++++++-------- .../src/module-runner/module-runner.worker.ts | 1 - packages/tools/e2e/src/Vite.ts | 12 +- 9 files changed, 183 insertions(+), 101 deletions(-) create mode 100644 fixtures/tanstack-start/src/routes/api.wasm.ts create mode 100644 fixtures/tanstack-start/src/wasm-example.wasm diff --git a/fixtures/tanstack-start/src/routeTree.gen.ts b/fixtures/tanstack-start/src/routeTree.gen.ts index 75aa8b4a..658ce2bb 100644 --- a/fixtures/tanstack-start/src/routeTree.gen.ts +++ b/fixtures/tanstack-start/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as AboutRouteImport } from './routes/about' import { Route as IndexRouteImport } from './routes/index' +import { Route as ApiWasmRouteImport } from './routes/api.wasm' import { Route as ApiDbRouteImport } from './routes/api.db' const AboutRoute = AboutRouteImport.update({ @@ -23,6 +24,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const ApiWasmRoute = ApiWasmRouteImport.update({ + id: '/api/wasm', + path: '/api/wasm', + getParentRoute: () => rootRouteImport, +} as any) const ApiDbRoute = ApiDbRouteImport.update({ id: '/api/db', path: '/api/db', @@ -33,30 +39,34 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/about': typeof AboutRoute '/api/db': typeof ApiDbRoute + '/api/wasm': typeof ApiWasmRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/about': typeof AboutRoute '/api/db': typeof ApiDbRoute + '/api/wasm': typeof ApiWasmRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/about': typeof AboutRoute '/api/db': typeof ApiDbRoute + '/api/wasm': typeof ApiWasmRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/about' | '/api/db' + fullPaths: '/' | '/about' | '/api/db' | '/api/wasm' fileRoutesByTo: FileRoutesByTo - to: '/' | '/about' | '/api/db' - id: '__root__' | '/' | '/about' | '/api/db' + to: '/' | '/about' | '/api/db' | '/api/wasm' + id: '__root__' | '/' | '/about' | '/api/db' | '/api/wasm' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute AboutRoute: typeof AboutRoute ApiDbRoute: typeof ApiDbRoute + ApiWasmRoute: typeof ApiWasmRoute } declare module '@tanstack/react-router' { @@ -75,6 +85,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/api/wasm': { + id: '/api/wasm' + path: '/api/wasm' + fullPath: '/api/wasm' + preLoaderRoute: typeof ApiWasmRouteImport + parentRoute: typeof rootRouteImport + } '/api/db': { id: '/api/db' path: '/api/db' @@ -89,6 +106,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AboutRoute: AboutRoute, ApiDbRoute: ApiDbRoute, + ApiWasmRoute: ApiWasmRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/fixtures/tanstack-start/src/routes/api.wasm.ts b/fixtures/tanstack-start/src/routes/api.wasm.ts new file mode 100644 index 00000000..10ab17fd --- /dev/null +++ b/fixtures/tanstack-start/src/routes/api.wasm.ts @@ -0,0 +1,21 @@ +import { createFileRoute } from "@tanstack/react-router"; + +interface Instance { + exports: { + add(a: number, b: number): number; + }; +} + +export const Route = createFileRoute("/api/wasm")({ + server: { + handlers: { + GET: async () => { + const mod = await import("../wasm-example.wasm").then( + (m) => WebAssembly.instantiate(m.default) as Promise, + ); + const result = mod.exports.add(1, 2); + return Response.json({ result }); + }, + }, + }, +}); diff --git a/fixtures/tanstack-start/src/wasm-example.wasm b/fixtures/tanstack-start/src/wasm-example.wasm new file mode 100644 index 0000000000000000000000000000000000000000..357f72da7a0db8add83699082fd51d46bf3352fb GIT binary patch literal 41 wcmZQbEY4+QU|?WmXG~zKuV<`hW@2PuXJ=$iOi5v2;NoOtXHZ~JV9eqM0DJxgJ^%m! literal 0 HcmV?d00001 diff --git a/fixtures/tanstack-start/test/smoke.test.ts b/fixtures/tanstack-start/test/smoke.test.ts index 6afda102..9a7062f4 100644 --- a/fixtures/tanstack-start/test/smoke.test.ts +++ b/fixtures/tanstack-start/test/smoke.test.ts @@ -29,5 +29,10 @@ for (const mode of Playwright.SERVER_METHODS) { const response = await server.fetchJson<[{ "?column?": number }]>("/api/db"); expect(response).toMatchObject([{ "?column?": 1 }]); }); + + it("fetches WASM", async ({ server }) => { + const response = await server.fetchJson<{ result: number }>("/api/wasm"); + expect(response).toMatchObject({ result: 3 }); + }); }); } diff --git a/packages/cloudflare-runtime/src/Runtime.ts b/packages/cloudflare-runtime/src/Runtime.ts index f875b0cb..7180b523 100644 --- a/packages/cloudflare-runtime/src/Runtime.ts +++ b/packages/cloudflare-runtime/src/Runtime.ts @@ -149,6 +149,7 @@ export const RuntimeLive = Layer.effect( localDisk: storage.name, }, containerEngine, + ...worker.unsafe, }, }, ...config.services, diff --git a/packages/cloudflare-vite-plugin/src/dev-environment.ts b/packages/cloudflare-vite-plugin/src/dev-environment.ts index ceb7d17c..fe3d3c4b 100644 --- a/packages/cloudflare-vite-plugin/src/dev-environment.ts +++ b/packages/cloudflare-vite-plugin/src/dev-environment.ts @@ -1,4 +1,4 @@ -import { MODULE_RULES } from "@distilled.cloud/cloudflare-rolldown-plugin/plugins"; +import { MODULE_REFERENCE_REGEX } from "@distilled.cloud/cloudflare-rolldown-plugin/plugins"; import assert from "node:assert"; import * as vite from "vite"; import type { FetchFunctionOptions } from "vite/module-runner"; @@ -42,8 +42,10 @@ export class DistilledDevEnvironment extends vite.DevEnvironment { importer?: string, options?: FetchFunctionOptions, ): Promise { - // Additional modules (CompiledWasm, Data, Text) - if (MODULE_RULES.some((rule) => rule.pattern.test(id))) { + // Additional modules (CompiledWasm, Data, Text) are resolved to + // `__CLOUDFLARE_MODULE__...` ids and must be externalized so the module + // runner loads them via native `import()` → workerd's module fallback. + if (MODULE_REFERENCE_REGEX.test(id)) { return { externalize: id, type: "module", diff --git a/packages/cloudflare-vite-plugin/src/dev-server.ts b/packages/cloudflare-vite-plugin/src/dev-server.ts index 594bfd6b..348d4b27 100644 --- a/packages/cloudflare-vite-plugin/src/dev-server.ts +++ b/packages/cloudflare-vite-plugin/src/dev-server.ts @@ -10,7 +10,7 @@ import * as Credentials from "@distilled.cloud/cloudflare/Credentials"; import type * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; -import * as FileSystem from "effect/FileSystem"; +import type * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Scope from "effect/Scope"; @@ -18,6 +18,8 @@ import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; import * as Headers from "effect/unstable/http/Headers"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import * as NodeFs from "node:fs/promises"; +import * as NodeHttp from "node:http"; import type * as vite from "vite"; import * as ModuleRunnerWorker from "worker:./module-runner/module-runner.worker.ts"; import * as WrapperWorker from "worker:./module-runner/wrapper.worker.ts"; @@ -81,13 +83,90 @@ const closeScope = async (scope: Scope.Scope) => { await Effect.runPromiseExit(Scope.closeUnsafe(scope, Exit.void) ?? Effect.void); }; +const makeModuleFallbackService = Effect.gen(function* () { + const server = NodeHttp.createServer(async (req, res) => { + try { + const request = await parseModuleFallbackRequest(req); + if (!request) { + res.writeHead(400, { "Content-Type": "text/plain" }); + res.end("Invalid module fallback request"); + return; + } + + const candidate = request.rawSpecifier ?? request.specifier; + const match = MODULE_REFERENCE_REGEX.exec(candidate); + if (!match) { + res.writeHead(400, { "Content-Type": "text/plain" }); + res.end(`No match for module: ${candidate}`); + return; + } + + const [, moduleType, modulePath] = match; + if (!moduleType || !modulePath) { + res.writeHead(400, { "Content-Type": "text/plain" }); + res.end(`Invalid module type or path: ${match[0]}`); + return; + } + + let content: Buffer; + try { + content = await NodeFs.readFile(modulePath); + } catch { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end(`Module not found: ${modulePath}`); + return; + } + + switch (moduleType) { + case "CompiledWasm": { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ wasm: Array.from(content) })); + return; + } + case "Data": { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ data: Array.from(content) })); + return; + } + case "Text": { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ text: content.toString() })); + return; + } + default: { + res.writeHead(400, { "Content-Type": "text/plain" }); + res.end(`Invalid module type: ${moduleType}`); + } + } + } catch (error) { + res.writeHead(500, { "Content-Type": "text/plain" }); + res.end(error instanceof Error ? (error.stack ?? error.message) : String(error)); + } + }); + + yield* Effect.callback((resume) => { + server.listen(0, "127.0.0.1", () => resume(Effect.void)); + }); + yield* Effect.addFinalizer(() => + Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + }), + ); + + const address = server.address(); + if (address === null || typeof address === "string") { + return yield* Effect.die(new Error("Module fallback server address unavailable")); + } + return `127.0.0.1:${address.port}`; +}); + const serve = Effect.fn(function* ( options: CloudflareVitePluginOptions, entryEnvironment: EntryEnvironment, server: vite.ViteDevServer, ) { const runtime = yield* Runtime.Runtime; - const fs = yield* FileSystem.FileSystem; + const moduleFallback = yield* makeModuleFallbackService; const name = options.worker?.name ?? `vite-dev-${crypto.randomUUID()}`; return yield* runtime.start({ @@ -118,46 +197,6 @@ const serve = Effect.fn(function* ( return HttpServerResponse.jsonUnsafe(result); }), }), - Loopback.local({ - binding: "__DISTILLED_MODULE_FALLBACK__", - name: `vite:module-fallback:${name}`, - handler: Effect.gen(function* () { - const request = yield* ModuleFallbackRequest.pipe(Effect.orElseSucceed(() => undefined)); - if (!request) { - return HttpServerResponse.text("Invalid module fallback request", { status: 400 }); - } - const match = MODULE_REFERENCE_REGEX.exec(request.specifier); - if (!match) { - return HttpServerResponse.text(`No match for module: ${request.specifier}`, { - status: 400, - }); - } - const [full, moduleType, modulePath] = match; - if (!moduleType || !modulePath) { - return HttpServerResponse.text(`Invalid module type or path: ${full}`, { status: 400 }); - } - const content = yield* fs - .readFile(modulePath) - .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined)); - if (!content) { - return HttpServerResponse.text(`Module not found: ${modulePath}`, { status: 404 }); - } - switch (moduleType) { - case "CompiledWasm": { - return yield* HttpServerResponse.json({ wasm: Array.from(content) }); - } - case "Data": { - return yield* HttpServerResponse.json({ data: Array.from(content) }); - } - case "Text": { - return yield* HttpServerResponse.json({ text: content.toString() }); - } - default: { - return HttpServerResponse.text(`Invalid module type: ${moduleType}`, { status: 400 }); - } - } - }), - }), ...(options.worker?.bindings ?? []), ], durableObjectNamespaces: [ @@ -171,75 +210,62 @@ const serve = Effect.fn(function* ( hyperdrives: options.worker?.hyperdrives, assets: options.worker?.assets, unsafe: { - moduleFallback: `vite:module-fallback:${name}`, + moduleFallback, ...(options.worker?.unsafe ?? {}), }, }); }); -const ModuleFallbackRequest = Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - if (request.method === "GET") { - const url = new URL(request.url, "http://localhost"); - const specifier = url.searchParams.get("specifier"); +type ModuleFallbackRequest = + | { + protocol: "v1"; + type: "import" | "require"; + specifier: string; + rawSpecifier?: string; + referrer?: string; + } + | { + protocol: "v2"; + type: "import" | "require" | "internal"; + specifier: string; + rawSpecifier?: string; + referrer?: string; + attributes?: Array<{ name: string; value: string }>; + }; +const parseModuleFallbackRequest = async ( + req: NodeHttp.IncomingMessage, +): Promise => { + if (req.method === "GET" && req.headers["x-resolve-method"]) { + const url = new URL(req.url ?? "", "http://localhost"); + const specifier = url.searchParams.get("specifier"); if (!specifier) { return; } - - const resolveMethod = Headers.get(request.headers, "X-Resolve-Method").pipe(Option.getOrThrow); - + const resolveMethod = req.headers["x-resolve-method"]; return { protocol: "v1", type: resolveMethod === "require" ? "require" : "import", specifier, rawSpecifier: url.searchParams.get("rawSpecifier") ?? undefined, referrer: url.searchParams.get("referrer") ?? undefined, - } as ModuleFallbackRequest.V1; - } else if (request.method === "POST") { - const json = yield* request.json; - if (!isV2ModuleFallbackProtocol(json)) { - return; - } - return { protocol: "v2", ...json } as ModuleFallbackRequest.V2; + }; } -}); -const isV2ModuleFallbackProtocol = ( - json: unknown, -): json is Omit => { - return typeof json === "object" && json !== null && "specifier" in json; -}; - -declare namespace ModuleFallbackRequest { - /** V1 protocol request (legacy module registry) */ - export interface V1 { - protocol: "v1"; - /** Import type: "import" for ES modules, "require" for CommonJS */ - type: "import" | "require"; - /** Module specifier as a path (e.g., "/my-module.js") */ - specifier: string; - /** Original specifier as written in source code */ - rawSpecifier?: string; - /** Referrer module path */ - referrer?: string; - } - - /** V2 protocol request (new module registry) */ - export interface V2 { - protocol: "v2"; - /** Import type: includes "internal" for runtime-originated imports */ - type: "import" | "require" | "internal"; - /** Module specifier as a URL (e.g., "file:///bundle/my-module.js") */ - specifier: string; - /** Original specifier as written in source code */ - rawSpecifier?: string; - /** Referrer module URL */ - referrer?: string; - /** Import attributes from the import statement */ - attributes?: Array<{ name: string; value: string }>; + if (req.method === "POST") { + const chunks: Array = []; + for await (const chunk of req) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + } + const json: unknown = JSON.parse(Buffer.concat(chunks).toString()); + if (typeof json === "object" && json !== null && "specifier" in json) { + return { + protocol: "v2", + ...(json as Omit, "protocol">), + }; + } } -} +}; async function makeWorkerModules(options: CloudflareVitePluginOptions): Promise> { const [moduleRunnerWorker, wrapperWorker] = await Promise.all([ diff --git a/packages/cloudflare-vite-plugin/src/module-runner/module-runner.worker.ts b/packages/cloudflare-vite-plugin/src/module-runner/module-runner.worker.ts index 25539e09..0750e89a 100644 --- a/packages/cloudflare-vite-plugin/src/module-runner/module-runner.worker.ts +++ b/packages/cloudflare-vite-plugin/src/module-runner/module-runner.worker.ts @@ -166,7 +166,6 @@ export class ModuleRunnerDO extends DurableObject { env: stripInternalEnv(env as Env), }); } - return await import(filepath); }, }, diff --git a/packages/tools/e2e/src/Vite.ts b/packages/tools/e2e/src/Vite.ts index 24f84efb..69a2782e 100644 --- a/packages/tools/e2e/src/Vite.ts +++ b/packages/tools/e2e/src/Vite.ts @@ -9,6 +9,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import type { PlatformError } from "effect/PlatformError"; +import * as Predicate from "effect/Predicate"; import type * as Scope from "effect/Scope"; import * as NodeCrypto from "node:crypto"; import { createRequire } from "node:module"; @@ -278,7 +279,16 @@ export const ViteLive = Layer.effect( }), readBuildOutput: Effect.fn(function* () { const content = yield* fs.readFileString(path.resolve(cwd, "dist/build.json")); - return JSON.parse(content) as BuildOutput; + return JSON.parse(content, (_, value) => { + if ( + Predicate.hasProperty(value, "type") && + value.type === "Buffer" && + Predicate.hasProperty(value, "data") + ) { + return Buffer.from(value.data as Array); + } + return value; + }) as BuildOutput; }), }); }), From 2ff81d53959f9037e133ef54a121aa3dce003567 Mon Sep 17 00:00:00 2001 From: John Royal <34844819+john-royal@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:55:13 -0400 Subject: [PATCH 7/8] remove | FileSystem from types --- packages/cloudflare-vite-plugin/src/dev-server.ts | 3 +-- packages/cloudflare-vite-plugin/src/plugin.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/cloudflare-vite-plugin/src/dev-server.ts b/packages/cloudflare-vite-plugin/src/dev-server.ts index 348d4b27..65d079f4 100644 --- a/packages/cloudflare-vite-plugin/src/dev-server.ts +++ b/packages/cloudflare-vite-plugin/src/dev-server.ts @@ -10,7 +10,6 @@ import * as Credentials from "@distilled.cloud/cloudflare/Credentials"; import type * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; -import type * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Scope from "effect/Scope"; @@ -34,7 +33,7 @@ export const startServer = async ( options: CloudflareVitePluginOptions, entryEnvironment: EntryEnvironment, server: vite.ViteDevServer, - context: Context.Context, + context: Context.Context, ) => { const scope = Scope.makeUnsafe(); const address = await serve(options, entryEnvironment, server).pipe( diff --git a/packages/cloudflare-vite-plugin/src/plugin.ts b/packages/cloudflare-vite-plugin/src/plugin.ts index 0f3be34b..116482fa 100644 --- a/packages/cloudflare-vite-plugin/src/plugin.ts +++ b/packages/cloudflare-vite-plugin/src/plugin.ts @@ -15,7 +15,6 @@ import type { RuntimeWorker, } from "@distilled.cloud/cloudflare-runtime"; import type * as Context from "effect/Context"; -import type * as FileSystem from "effect/FileSystem"; import type * as vite from "vite"; import { dev } from "./dev-plugin.ts"; @@ -23,7 +22,7 @@ export interface CloudflareVitePluginOptions< B extends BindingHooks = BindingHooks, > extends BasePluginOptions { worker?: Omit, "compatibilityDate" | "compatibilityFlags" | "modules">; - context?: Context.Context; + context?: Context.Context; } export default function cloudflareVitePlugin( From 6e786610eb9f1e0be9cbbb26ab084975da6c4055 Mon Sep 17 00:00:00 2001 From: John Royal <34844819+john-royal@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:09:53 -0400 Subject: [PATCH 8/8] fix(runtime): serialize registry writes against poll refreshes Prevent a stale polling snapshot from clobbering an eager in-memory write, which was flaking Registry tests in CI. Co-authored-by: Cursor --- .../src/registry/Registry.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/cloudflare-runtime/src/registry/Registry.ts b/packages/cloudflare-runtime/src/registry/Registry.ts index 1af34b32..fdc7bad9 100644 --- a/packages/cloudflare-runtime/src/registry/Registry.ts +++ b/packages/cloudflare-runtime/src/registry/Registry.ts @@ -8,6 +8,7 @@ import * as MutableHashMap from "effect/MutableHashMap"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schedule from "effect/Schedule"; +import * as Semaphore from "effect/Semaphore"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -96,6 +97,15 @@ export const RegistryLive = Layer.effect( // immediately (the watcher only reports future changes). const ref = yield* SubscriptionRef.make(yield* readAll); + // Serialize disk snapshots and writes so a poll/watch refresh that started + // before `write` cannot overwrite the eager in-memory update with a stale + // empty snapshot. + const updateLock = yield* Semaphore.make(1); + const refresh = readAll.pipe( + Effect.flatMap((newValue) => SubscriptionRef.set(ref, newValue)), + updateLock.withPermits(1), + ); + // The `fileSystemSupportsWatcher` flag is set to false on Windows and true everywhere else. // The flag can be overridden using a ConfigProvider, e.g. for testing. // If the watcher is not supported, we fall back to polling every 100ms. @@ -108,14 +118,10 @@ export const RegistryLive = Layer.effect( // subscription. `mapEffect` runs the reads sequentially, so a // watcher-triggered read cannot be overwritten by an older one. Stream.merge(Stream.succeed(undefined)), - Stream.mapEffect(() => readAll), + Stream.mapEffect(() => refresh), ) - : Stream.fromEffect(readAll).pipe(Stream.repeat(Schedule.spaced(100))) - ).pipe( - Stream.tap((newValue) => SubscriptionRef.set(ref, newValue)), - Stream.runDrain, - Effect.forkScoped, - ); + : Stream.fromEffect(refresh).pipe(Stream.repeat(Schedule.spaced(100))) + ).pipe(Stream.runDrain, Effect.forkScoped); return Registry.of({ read: (subscribers) => @@ -133,6 +139,7 @@ export const RegistryLive = Layer.effect( // Immediately update the in-memory registry so it's available without waiting on IO. SubscriptionRef.update(ref, (map) => MutableHashMap.set(map, entry.scriptName, entry)), ), + updateLock.withPermits(1), Effect.tap(() => { // Remove the entry from the filesystem when the scope closes — but // only while the file still holds THIS write's content. A