1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import type * as FileSystem from "effect/FileSystem";
4import type * as Path from "effect/Path";
5import * as Predicate from "effect/Predicate";
6import type * as Scope from "effect/Scope";
7import * as NodePath from "node:path";
8import type { BuildOutput } from "./BuildOutput.ts";
9import { loadProjectModule } from "./Loader.ts";
10
11export class DeployTargetError extends Data.TaggedError<"DeployTargetError">(
12 "DeployTargetError",
13)<{
14 /** The target platform involved (e.g. "cloudflare"), when known. */
15 readonly platform?: string | undefined;
16 readonly message: string;
17 readonly cause?: unknown;
18}> {}
19
20/**
21 * Services a target hook may require. The same set the e2e harness (and the
22 * alchemy-side adapters) provide to `Framework` layers while building them.
23 */
24export type DeployTargetServices = FileSystem.FileSystem | Path.Path;
25
26/**
27 * Bundler-facing settings a target contributes to any pass that resolves or
28 * bundles *server* code for it — the framework's own server build and/or the
29 * target's finishing pass. Platform-neutral: just conditions and externals,
30 * no bundler-specific plugin types.
31 */
32export interface DeployTargetBundleOptions {
33 /**
34 * Module-resolution conditions for server code, most specific first
35 * (e.g. `["workerd", "worker", "module", "browser"]` for Cloudflare).
36 */
37 readonly conditions?: ReadonlyArray<string> | undefined;
38 /**
39 * Import-specifier prefixes that must stay external to the bundle because
40 * the target runtime provides them (e.g. `["cloudflare:"]`; AWS might use
41 * `["@aws-sdk/"]` when the runtime ships the SDK).
42 */
43 readonly external?: ReadonlyArray<string> | undefined;
44 /** `package.json` main fields, in resolution order (e.g. `["module", "main"]`). */
45 readonly mainFields?: ReadonlyArray<string> | undefined;
46}
47
48/**
49 * The user's own server entry — the generic carriage for "a user worker/
50 * function entry that wraps the framework's emitted entry".
51 *
52 * Some apps need their own module to be the deployed entry point instead of
53 * the framework's: on Cloudflare Workers the canonical case is a worker entry
54 * that wraps the framework's fetch handler and additionally exports Durable
55 * Object / Workflow classes, which must live on the same worker for their
56 * bindings to resolve. The pattern is platform-generic (an AWS target could
57 * carry a handler module that wraps the framework handler with middleware),
58 * so the carriage lives on the {@link DeployTarget} contract:
59 *
60 * - The **target config** carries the raw user value in its own shape
61 * (Cloudflare: the vite/rolldown plugin's `main` option). The target
62 * surfaces it here so framework packages can consult it platform-neutrally
63 * (config stays opaque to framework-core).
64 * - The **framework package** honors it in two places: the user entry becomes
65 * the bundler entry the framework build/dev pipeline serves (instead of the
66 * framework's own emitted entry), and the built chunk produced from it
67 * becomes `serverModules[0]` (see `selectEntryByFacade` in the collector).
68 * - The **framework package** must also expose a stable importable specifier
69 * for its emitted server handler so the user entry has something to wrap.
70 * Convention: `virtual:{framework}/server-entry` (precedent: React Router's
71 * `virtual:react-router/server-build`; waku ships
72 * `virtual:waku/server-entry`).
73 */
74export interface DeployTargetEntry {
75 /**
76 * The user's entry module: an absolute path, or a path relative to the
77 * project root (resolve with {@link resolveDeployTargetEntry}).
78 */
79 readonly main: string;
80}
81
82/**
83 * Resolve a target's user-entry module ({@link DeployTargetEntry}) to an
84 * absolute path against the project root. Returns `undefined` when the target
85 * (or its entry) is absent — the framework's own entry should be used.
86 */
87export const resolveDeployTargetEntry = (
88 target: Pick<DeployTarget, "entry"> | undefined,
89 context: { readonly root: string },
90): string | undefined =>
91 target?.entry === undefined
92 ? undefined
93 : NodePath.isAbsolute(target.entry.main)
94 ? target.entry.main
95 : NodePath.resolve(context.root, target.entry.main);
96
97/** Common inputs every target hook receives. */
98export interface DeployTargetContext {
99 /** Absolute project root. */
100 readonly root: string;
101 /** The framework driving the target (e.g. "waku", "sveltekit"), when known. */
102 readonly framework?: string | undefined;
103}
104
105export interface DeployTargetBuildContext extends DeployTargetContext {
106 /**
107 * Process environment for a wholesale `build` child. Applied only in
108 * that process — never on the engine.
109 */
110 readonly env?: Record<string, string> | undefined;
111}
112
113export interface DeployTargetFinishContext extends DeployTargetContext {
114 /**
115 * Absolute path of the on-disk server entry the framework's adapt step
116 * produced, for finishing passes that re-bundle from disk (e.g. SvelteKit's
117 * `_worker.js`). Omitted when the framework build is already in-memory.
118 */
119 readonly entry?: string | undefined;
120}
121
122export interface DeployTargetServeContext extends DeployTargetContext {
123 /** The build to serve. */
124 readonly output: BuildOutput;
125 /** Requested port; the target may fall back to an ephemeral port. */
126 readonly port?: number | undefined;
127}
128
129/** A locally-serving instance of built framework server code. */
130export interface DeployTargetServer {
131 /** The local URL the server is listening on. */
132 readonly url: string;
133 /**
134 * Optional direct dispatch into the server (e.g. miniflare's
135 * `dispatchFetch`). Callers fall back to plain HTTP `fetch` against `url`.
136 */
137 readonly fetch?:
138 | ((path: string, init?: RequestInit) => Promise<Response>)
139 | undefined;
140}
141
142/**
143 * A deploy target — the platform a framework build is produced *for*, passed
144 * to framework integrations as a plain value (a prop). Cloudflare Workers is
145 * the first implementation; future targets (AWS Lambda, ...) implement the
146 * same seams without any change to the framework packages.
147 *
148 * Only the platform-generic seams live here:
149 *
150 * - `bundle` — resolve/bundle conditions for server code.
151 * - `build` — optional *wholesale* build takeover: when defined, the
152 * framework package delegates its entire production build to the target
153 * (the OpenNext case for Next.js). Most targets leave this undefined and
154 * let the framework drive the build.
155 * - `finish` — optional finishing pass over the framework-produced
156 * {@link BuildOutput} (the SvelteKit-style post-adapt re-bundle for the
157 * target runtime). Use {@link applyDeployTargetFinish} to run it.
158 * - `serve` — the target's local serving story for *built* server code
159 * (preview-parity: miniflare / cloudflare-runtime today, a Lambda emulator
160 * later). Scoped: closing the Scope stops the server. The framework's own
161 * HMR dev server is NOT this seam — dev integration that reaches inside the
162 * framework's toolchain lives in the per-framework target module.
163 * - `config` — opaque target configuration carriage (Cloudflare: worker
164 * name/bindings/compatibility; AWS later: its own shape). Framework-core
165 * never inspects it.
166 * - `entry` — the user's own server entry, when the target's config carries
167 * one: a module that wraps the framework's emitted entry (and, on
168 * Cloudflare, re-exports Durable Object classes). See
169 * {@link DeployTargetEntry}.
170 *
171 * The framework-*specific* halves of an integration (waku's adapter fork,
172 * astro's integration fork, kit's in-memory adapter) are intentionally NOT
173 * part of this interface. Each framework package declares its own target
174 * interface extending `DeployTarget` with those hooks, and ships its
175 * Cloudflare implementation as a subpath module
176 * (e.g. `@alchemy.run/frontend-frameworks/waku/cloudflare`) selected via the framework's
177 * `target` option.
178 */
179export interface DeployTarget<Config = unknown> {
180 /** Stable platform identifier (e.g. "cloudflare", "aws"). */
181 readonly platform: string;
182 /** Opaque target configuration. Never inspected by framework-core. */
183 readonly config: Config;
184 /** The user's own server entry wrapping the framework's emitted entry, when configured. */
185 readonly entry?: DeployTargetEntry | undefined;
186 readonly bundle?: DeployTargetBundleOptions | undefined;
187 readonly build?:
188 | ((
189 context: DeployTargetBuildContext,
190 ) => Effect.Effect<BuildOutput, DeployTargetError, DeployTargetServices>)
191 | undefined;
192 readonly finish?:
193 | ((
194 output: BuildOutput,
195 context: DeployTargetFinishContext,
196 ) => Effect.Effect<BuildOutput, DeployTargetError, DeployTargetServices>)
197 | undefined;
198 readonly serve?:
199 | ((
200 context: DeployTargetServeContext,
201 ) => Effect.Effect<
202 DeployTargetServer,
203 DeployTargetError,
204 Scope.Scope | DeployTargetServices
205 >)
206 | undefined;
207}
208
209/**
210 * Identity helper with inference: preserves the concrete target type
211 * (including framework-specific extensions) while checking it against the
212 * {@link DeployTarget} contract.
213 */
214export const makeDeployTarget = <T extends DeployTarget>(target: T): T =>
215 target;
216
217/** Structural guard for {@link DeployTarget} values. */
218export const isDeployTarget = (value: unknown): value is DeployTarget =>
219 Predicate.hasProperty(value, "platform") &&
220 typeof (value as { platform: unknown }).platform === "string" &&
221 Predicate.hasProperty(value, "config");
222
223/**
224 * How a target is passed to a framework integration:
225 *
226 * - a {@link DeployTarget} value — used as-is (full type safety over the
227 * target's config; build it in your config file by importing the target
228 * module directly)
229 * - a factory `(config) => DeployTarget` — applied to the framework-provided
230 * config (the shape a target module's default export takes)
231 * - a module specifier string (e.g. `"@alchemy.run/frontend-frameworks/waku/cloudflare"`) —
232 * loaded from the *project's* `node_modules`; the module must
233 * default-export (or named-export `target`) a target value or factory
234 */
235export type DeployTargetInput<
236 T extends DeployTarget = DeployTarget,
237 Config = unknown,
238> = T | ((config: Config) => T) | string;
239
240/** The exports a target module may provide (see {@link resolveDeployTarget}). */
241interface DeployTargetModule {
242 readonly default?: unknown;
243 readonly target?: unknown;
244}
245
246const invalidTarget = (specifier: string) =>
247 new DeployTargetError({
248 message:
249 `"${specifier}" does not provide a usable deploy target: expected a default export ` +
250 "(or named export `target`) that is a DeployTarget value or a `(config) => DeployTarget` " +
251 "factory",
252 });
253
254/**
255 * Resolve a {@link DeployTargetInput} to a concrete target:
256 *
257 * - a string is imported from the project's own dependency tree (via
258 * `loadProjectModule`, so the project's installed target module is the one
259 * used); its default export (or named export `target`) is resolved next
260 * - a factory is applied to `config`
261 * - a target value is validated and returned as-is
262 */
263export const resolveDeployTarget: <T extends DeployTarget, Config>(
264 root: string,
265 input: DeployTargetInput<T, Config>,
266 config: Config,
267) => Effect.Effect<T, DeployTargetError> = Effect.fn(function* <
268 T extends DeployTarget,
269 Config,
270>(root: string, input: DeployTargetInput<T, Config>, config: Config) {
271 let candidate: unknown = input;
272 let specifier = "the provided deploy target";
273 if (typeof input === "string") {
274 specifier = input;
275 const module_ = yield* loadProjectModule<DeployTargetModule>(
276 root,
277 input,
278 ).pipe(
279 Effect.mapError(
280 (error) =>
281 new DeployTargetError({ message: error.message, cause: error.cause }),
282 ),
283 );
284 candidate = module_.default ?? module_.target;
285 }
286 if (typeof candidate === "function") {
287 candidate = (candidate as (config: Config) => T)(config);
288 }
289 if (!isDeployTarget(candidate)) {
290 return yield* Effect.fail(invalidTarget(specifier));
291 }
292 return candidate as T;
293});
294
295/**
296 * Run the target's finishing pass over a framework-produced build, if the
297 * target defines one; otherwise pass the build through unchanged.
298 */
299export const applyDeployTargetFinish = (
300 target: DeployTarget | undefined,
301 output: BuildOutput,
302 context: DeployTargetFinishContext,
303): Effect.Effect<BuildOutput, DeployTargetError, DeployTargetServices> =>
304 target?.finish !== undefined
305 ? target.finish(output, context)
306 : Effect.succeed(output);