| 1 | import { isDeployTarget } from "../../core/index.ts"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as NodePath from "node:path"; |
| 4 | import type * as ViteModule from "vite"; |
| 5 | import { describe, expect, it } from "vitest"; |
| 6 | import cloudflareTargetFactory, { |
| 7 | makeWakuCloudflareTarget, |
| 8 | makeWakuPluginOptions, |
| 9 | } from "../cloudflare.ts"; |
| 10 | import { WAKU_SERVER_ENTRY_PATH } from "../index.ts"; |
| 11 | |
| 12 | const WAKU_DIR = "/project/node_modules/waku"; |
| 13 | |
| 14 | const context = { |
| 15 | root: "/project", |
| 16 | wakuDirectory: WAKU_DIR, |
| 17 | phase: "build", |
| 18 | } as const; |
| 19 | |
| 20 | const flatten = ( |
| 21 | plugins: ReadonlyArray<ViteModule.PluginOption>, |
| 22 | ): Array<ViteModule.Plugin> => |
| 23 | (plugins as Array<unknown>) |
| 24 | .flat(8) |
| 25 | .filter( |
| 26 | (plugin): plugin is ViteModule.Plugin => |
| 27 | typeof plugin === "object" && plugin !== null && "name" in plugin, |
| 28 | ); |
| 29 | |
| 30 | describe("makeWakuPluginOptions", () => { |
| 31 | it("defaults main to waku's rsc worker entry and pins the rsc/ssr topology", () => { |
| 32 | const options = makeWakuPluginOptions({ |
| 33 | root: "/project", |
| 34 | wakuDirectory: WAKU_DIR, |
| 35 | }); |
| 36 | expect(options.main).toBe(NodePath.join(WAKU_DIR, WAKU_SERVER_ENTRY_PATH)); |
| 37 | expect(options.viteEnvironments).toEqual({ |
| 38 | entry: "rsc", |
| 39 | children: ["ssr"], |
| 40 | }); |
| 41 | }); |
| 42 | |
| 43 | it("preserves user cloudflare options but overrides viteEnvironments", () => { |
| 44 | const options = makeWakuPluginOptions({ |
| 45 | root: "/project", |
| 46 | wakuDirectory: WAKU_DIR, |
| 47 | pluginOptions: { |
| 48 | compatibilityDate: "2026-03-10", |
| 49 | compatibilityFlags: ["nodejs_als"], |
| 50 | worker: { name: "fixtures-waku", bindings: [] }, |
| 51 | viteEnvironments: { entry: "ssr" }, |
| 52 | }, |
| 53 | }); |
| 54 | expect(options.compatibilityDate).toBe("2026-03-10"); |
| 55 | expect(options.compatibilityFlags).toEqual(["nodejs_als"]); |
| 56 | expect(options.worker?.name).toBe("fixtures-waku"); |
| 57 | // The environment topology always wins: the worker must run in the rsc |
| 58 | // environment. |
| 59 | expect(options.main).toBe(NodePath.join(WAKU_DIR, WAKU_SERVER_ENTRY_PATH)); |
| 60 | expect(options.viteEnvironments).toEqual({ |
| 61 | entry: "rsc", |
| 62 | children: ["ssr"], |
| 63 | }); |
| 64 | }); |
| 65 | |
| 66 | it("lets a user main (the user-entry seam) take precedence over waku's entry", () => { |
| 67 | const absolute = makeWakuPluginOptions({ |
| 68 | root: "/project", |
| 69 | wakuDirectory: WAKU_DIR, |
| 70 | pluginOptions: { main: "/project/src/worker-entry.ts" }, |
| 71 | }); |
| 72 | expect(absolute.main).toBe("/project/src/worker-entry.ts"); |
| 73 | |
| 74 | // A relative main resolves against the project root, not process.cwd(). |
| 75 | const relative = makeWakuPluginOptions({ |
| 76 | root: "/project", |
| 77 | wakuDirectory: WAKU_DIR, |
| 78 | pluginOptions: { main: "./src/worker-entry.ts" }, |
| 79 | }); |
| 80 | expect(relative.main).toBe( |
| 81 | NodePath.resolve("/project", "src/worker-entry.ts"), |
| 82 | ); |
| 83 | // The topology stays pinned even with a user main. |
| 84 | expect(relative.viteEnvironments).toEqual({ |
| 85 | entry: "rsc", |
| 86 | children: ["ssr"], |
| 87 | }); |
| 88 | }); |
| 89 | |
| 90 | it("defaults compatibilityFlags to nodejs_als (waku needs AsyncLocalStorage)", () => { |
| 91 | expect( |
| 92 | makeWakuPluginOptions({ root: "/project", wakuDirectory: WAKU_DIR }) |
| 93 | .compatibilityFlags, |
| 94 | ).toEqual(["nodejs_als"]); |
| 95 | expect( |
| 96 | makeWakuPluginOptions({ |
| 97 | root: "/project", |
| 98 | wakuDirectory: WAKU_DIR, |
| 99 | pluginOptions: { compatibilityFlags: [] }, |
| 100 | }).compatibilityFlags, |
| 101 | ).toEqual(["nodejs_als"]); |
| 102 | expect( |
| 103 | makeWakuPluginOptions({ |
| 104 | root: "/project", |
| 105 | wakuDirectory: WAKU_DIR, |
| 106 | pluginOptions: { compatibilityFlags: ["global_fetch_strictly_public"] }, |
| 107 | }).compatibilityFlags, |
| 108 | ).toEqual(["global_fetch_strictly_public", "nodejs_als"]); |
| 109 | }); |
| 110 | |
| 111 | it("keeps user compatibilityFlags that already provide AsyncLocalStorage", () => { |
| 112 | expect( |
| 113 | makeWakuPluginOptions({ |
| 114 | root: "/project", |
| 115 | wakuDirectory: WAKU_DIR, |
| 116 | pluginOptions: { compatibilityFlags: ["nodejs_als"] }, |
| 117 | }).compatibilityFlags, |
| 118 | ).toEqual(["nodejs_als"]); |
| 119 | expect( |
| 120 | makeWakuPluginOptions({ |
| 121 | root: "/project", |
| 122 | wakuDirectory: WAKU_DIR, |
| 123 | pluginOptions: { compatibilityFlags: ["nodejs_compat"] }, |
| 124 | }).compatibilityFlags, |
| 125 | ).toEqual(["nodejs_compat"]); |
| 126 | expect( |
| 127 | makeWakuPluginOptions({ |
| 128 | root: "/project", |
| 129 | wakuDirectory: WAKU_DIR, |
| 130 | pluginOptions: { compatibilityFlags: ["nodejs_compat_v2"] }, |
| 131 | }).compatibilityFlags, |
| 132 | ).toEqual(["nodejs_compat_v2"]); |
| 133 | }); |
| 134 | }); |
| 135 | |
| 136 | describe("makeWakuCloudflareTarget", () => { |
| 137 | it("default-exports the target factory", () => { |
| 138 | expect(cloudflareTargetFactory).toBe(makeWakuCloudflareTarget); |
| 139 | expect(isDeployTarget(cloudflareTargetFactory({}))).toBe(true); |
| 140 | }); |
| 141 | |
| 142 | it("is a cloudflare DeployTarget carrying the config opaquely", () => { |
| 143 | const config = { compatibilityDate: "2026-03-10" }; |
| 144 | const target = makeWakuCloudflareTarget(config); |
| 145 | expect(target.platform).toBe("cloudflare"); |
| 146 | expect(target.config).toBe(config); |
| 147 | expect(isDeployTarget(target)).toBe(true); |
| 148 | // `config` must be an own property even when undefined — `isDeployTarget` |
| 149 | // checks for its presence. |
| 150 | expect(isDeployTarget(makeWakuCloudflareTarget())).toBe(true); |
| 151 | }); |
| 152 | |
| 153 | it("declares the workerd bundle conditions and cloudflare: externals", () => { |
| 154 | const target = makeWakuCloudflareTarget(); |
| 155 | expect(target.bundle?.conditions).toEqual([ |
| 156 | "workerd", |
| 157 | "worker", |
| 158 | "module", |
| 159 | "browser", |
| 160 | ]); |
| 161 | expect(target.bundle?.external).toEqual(["cloudflare:"]); |
| 162 | }); |
| 163 | |
| 164 | it("does not take over the framework build (no wholesale build hook)", () => { |
| 165 | const target = makeWakuCloudflareTarget(); |
| 166 | expect(target.build).toBeUndefined(); |
| 167 | expect(target.finish).toBeUndefined(); |
| 168 | }); |
| 169 | |
| 170 | it("resolves the adapter hook to this package's wrangler-free fork", () => { |
| 171 | const target = makeWakuCloudflareTarget(); |
| 172 | const adapterPath = Effect.runSync(target.adapter(context)); |
| 173 | expect(NodePath.isAbsolute(adapterPath)).toBe(true); |
| 174 | expect(adapterPath).toMatch(/adapter\.(ts|js)$/); |
| 175 | }); |
| 176 | |
| 177 | it("produces the cloudflare vite plugin with the pinned waku options", () => { |
| 178 | const target = makeWakuCloudflareTarget({ |
| 179 | compatibilityDate: "2026-03-10", |
| 180 | }); |
| 181 | const plugins = flatten(Effect.runSync(target.vitePlugins(context))); |
| 182 | expect(plugins.length).toBeGreaterThan(0); |
| 183 | expect( |
| 184 | plugins.some((plugin) => plugin.name.startsWith("distilled-cloudflare")), |
| 185 | ).toBe(true); |
| 186 | }); |
| 187 | |
| 188 | it("surfaces the config's main as the generic user-entry carriage", () => { |
| 189 | expect(makeWakuCloudflareTarget().entry).toBeUndefined(); |
| 190 | expect( |
| 191 | makeWakuCloudflareTarget({ compatibilityDate: "2026-03-10" }).entry, |
| 192 | ).toBeUndefined(); |
| 193 | expect( |
| 194 | makeWakuCloudflareTarget({ main: "./src/worker-entry.ts" }).entry, |
| 195 | ).toEqual({ |
| 196 | main: "./src/worker-entry.ts", |
| 197 | }); |
| 198 | }); |
| 199 | }); |