| 1 | // Alchemy modifications are licensed under Apache-2.0. |
| 2 | // This file includes third-party code; see /THIRD_PARTY_LICENSES.md. |
| 3 | /** |
| 4 | * The Cloudflare Workers deploy target for `@alchemy.run/frontend-frameworks/waku` — the ONLY |
| 5 | * module in this package (besides the bundled `./adapter` fork it selects) |
| 6 | * that imports Cloudflare-specific code. The framework half (`./Waku.ts`) |
| 7 | * receives this target as a value and stays platform-neutral. |
| 8 | * |
| 9 | * Default export: a `(config) => WakuTarget` factory, the shape |
| 10 | * `resolveDeployTarget` expects from a target module specifier. The config is |
| 11 | * the cloudflare vite plugin's options (worker name/bindings/assets, |
| 12 | * compatibility date/flags) — exactly what the e2e harness carries at |
| 13 | * `target.cloudflare.worker`. |
| 14 | */ |
| 15 | import cloudflareVitePlugin, { |
| 16 | type CloudflareVitePluginOptions, |
| 17 | } from "@alchemy.run/cloudflare-runtime/vite"; |
| 18 | import { DeployTargetError, makeDeployTarget } from "../core/index.ts"; |
| 19 | import * as Effect from "effect/Effect"; |
| 20 | import * as NodePath from "node:path"; |
| 21 | import { fileURLToPath } from "node:url"; |
| 22 | import { WAKU_SERVER_ENTRY_PATH, type WakuTarget } from "./Waku.ts"; |
| 23 | |
| 24 | export type { CloudflareVitePluginOptions } from "@alchemy.run/cloudflare-runtime/vite"; |
| 25 | export type { WakuTarget, WakuTargetContext } from "./Waku.ts"; |
| 26 | |
| 27 | /** Inputs for {@link makeWakuPluginOptions} (exported for testing). */ |
| 28 | export interface WakuPluginOptionsInputs { |
| 29 | /** Absolute project root (a relative user `main` resolves against it). */ |
| 30 | readonly root: string; |
| 31 | /** Directory of the project's installed `waku` package. */ |
| 32 | readonly wakuDirectory: string; |
| 33 | /** User cloudflare plugin options (the target's config). */ |
| 34 | readonly pluginOptions?: CloudflareVitePluginOptions | undefined; |
| 35 | } |
| 36 | |
| 37 | /** |
| 38 | * Waku requires `AsyncLocalStorage` on the worker runtime (its server entry |
| 39 | * imports `node:async_hooks` at the top level), so a config without any |
| 40 | * Node.js compatibility flag would fail at runtime with a module-resolution |
| 41 | * error. When the user enables neither `nodejs_als` nor |
| 42 | * `nodejs_compat`/`nodejs_compat_v2` (each of which provides ALS), the |
| 43 | * target defaults to the minimal `nodejs_als`. |
| 44 | */ |
| 45 | const withNodejsAls = (flags: Array<string> | undefined): Array<string> => { |
| 46 | const hasAls = flags?.some( |
| 47 | (flag) => |
| 48 | flag === "nodejs_als" || |
| 49 | flag === "nodejs_compat" || |
| 50 | flag === "nodejs_compat_v2", |
| 51 | ); |
| 52 | return hasAls ? [...flags!] : [...(flags ?? []), "nodejs_als"]; |
| 53 | }; |
| 54 | |
| 55 | /** |
| 56 | * The cloudflare plugin options for a waku project: user options with `main` |
| 57 | * defaulted to waku's rsc worker entry, the rsc/ssr environment topology, and |
| 58 | * `compatibilityFlags` defaulted to include `nodejs_als` (required by waku's |
| 59 | * server entry) unless the user already enabled ALS or full nodejs_compat. |
| 60 | * |
| 61 | * An explicit `main` is mandatory either way: waku's rsc environment declares |
| 62 | * two rolldown inputs (`index` + `build`) while the dev plugin asserts exactly |
| 63 | * one entry. A USER `main` (the deploy target's user-entry seam — a worker |
| 64 | * entry that wraps `virtual:waku/server-entry` and re-exports Durable Object |
| 65 | * classes) takes precedence over waku's own entry and is resolved against the |
| 66 | * project root; without one, waku's rsc server entry is pinned as before. |
| 67 | */ |
| 68 | export const makeWakuPluginOptions = ( |
| 69 | inputs: WakuPluginOptionsInputs, |
| 70 | ): CloudflareVitePluginOptions => { |
| 71 | const userMain = inputs.pluginOptions?.main; |
| 72 | return { |
| 73 | ...inputs.pluginOptions, |
| 74 | main: |
| 75 | userMain !== undefined |
| 76 | ? NodePath.isAbsolute(userMain) |
| 77 | ? userMain |
| 78 | : NodePath.resolve(inputs.root, userMain) |
| 79 | : NodePath.join(inputs.wakuDirectory, WAKU_SERVER_ENTRY_PATH), |
| 80 | viteEnvironments: { entry: "rsc", children: ["ssr"] }, |
| 81 | compatibilityFlags: withNodejsAls(inputs.pluginOptions?.compatibilityFlags), |
| 82 | }; |
| 83 | }; |
| 84 | |
| 85 | /** |
| 86 | * Build the Cloudflare deploy target for waku: |
| 87 | * |
| 88 | * - `adapter` — this package's wrangler-free fork of waku's cloudflare |
| 89 | * adapter (`@alchemy.run/frontend-frameworks/waku/adapter`): behavior-identical to upstream |
| 90 | * except `buildEnhancers: []`, dropping the sole wrangler-file writer. |
| 91 | * - `vitePlugins` — `@alchemy.run/cloudflare-runtime/vite` with `main` |
| 92 | * set to the user's own worker entry when the config carries one (the |
| 93 | * user-entry seam), or pinned to waku's rsc worker entry otherwise, plus |
| 94 | * the `{ entry: "rsc", children: ["ssr"] }` environment topology. Injected |
| 95 | * inside waku's `vite.plugins` (before waku's own plugins), so in dev the |
| 96 | * rsc environment runs in workerd and the workerd proxy middleware |
| 97 | * registers ahead of waku's Node request bridge. |
| 98 | * - `entry` — surfaces the config's `main` as the generic user-entry |
| 99 | * carriage, so the platform-neutral framework half can pin the built |
| 100 | * user-entry chunk as `serverModules[0]`. |
| 101 | * - `bundle` — the workerd resolve conditions and `cloudflare:` externals for |
| 102 | * any pass that bundles server code for this target. |
| 103 | */ |
| 104 | export const makeWakuCloudflareTarget = ( |
| 105 | config?: CloudflareVitePluginOptions, |
| 106 | ): WakuTarget<CloudflareVitePluginOptions | undefined> => |
| 107 | makeDeployTarget({ |
| 108 | platform: "cloudflare", |
| 109 | config, |
| 110 | entry: config?.main !== undefined ? { main: config.main } : undefined, |
| 111 | bundle: { |
| 112 | conditions: ["workerd", "worker", "module", "browser"], |
| 113 | external: ["cloudflare:"], |
| 114 | }, |
| 115 | adapter: () => |
| 116 | Effect.try({ |
| 117 | try: () => |
| 118 | fileURLToPath( |
| 119 | import.meta |
| 120 | .resolve("@alchemy.run/frontend-frameworks/waku/adapter"), |
| 121 | ), |
| 122 | catch: (cause) => |
| 123 | new DeployTargetError({ |
| 124 | platform: "cloudflare", |
| 125 | message: |
| 126 | "Failed to resolve the @alchemy.run/frontend-frameworks/waku adapter module", |
| 127 | cause, |
| 128 | }), |
| 129 | }), |
| 130 | vitePlugins: (context) => |
| 131 | Effect.sync(() => [ |
| 132 | cloudflareVitePlugin( |
| 133 | makeWakuPluginOptions({ |
| 134 | root: context.root, |
| 135 | wakuDirectory: context.wakuDirectory, |
| 136 | pluginOptions: config, |
| 137 | }), |
| 138 | ), |
| 139 | ]), |
| 140 | }); |
| 141 | |
| 142 | export default makeWakuCloudflareTarget; |