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 */
15import cloudflareVitePlugin, {
16 type CloudflareVitePluginOptions,
17} from "@alchemy.run/cloudflare-runtime/vite";
18import { DeployTargetError, makeDeployTarget } from "../core/index.ts";
19import * as Effect from "effect/Effect";
20import * as NodePath from "node:path";
21import { fileURLToPath } from "node:url";
22import { WAKU_SERVER_ENTRY_PATH, type WakuTarget } from "./Waku.ts";
23
24export type { CloudflareVitePluginOptions } from "@alchemy.run/cloudflare-runtime/vite";
25export type { WakuTarget, WakuTargetContext } from "./Waku.ts";
26
27/** Inputs for {@link makeWakuPluginOptions} (exported for testing). */
28export 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 */
45const 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 */
68export 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 */
104export 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
142export default makeWakuCloudflareTarget;