1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Layer from "effect/Layer";
4import * as Path from "effect/Path";
5import * as Schedule from "effect/Schedule";
6import * as FrameworkCore from "../core/index.ts";
7import {
8 Framework,
9 FrameworkError,
10 type DeployTarget,
11 type DeployTargetInput,
12} from "../core/index.ts";
13
14/** The structural slice of the project's `vite` module this package drives. */
15export interface ViteModule {
16 readonly version?: string;
17 readonly build: (config: Record<string, unknown>) => Promise<unknown>;
18 readonly createServer: (
19 config: Record<string, unknown>,
20 ) => Promise<ViteDevServer>;
21 /**
22 * `vite.resolveConfig` — used to read the project's resolved `build.outDir`
23 * (and root) without duplicating vite's config-file discovery. Optional in
24 * the slice: ancient vite versions without it fall back to `dist`.
25 */
26 readonly resolveConfig?: (
27 config: Record<string, unknown>,
28 command: "build" | "serve",
29 ) => Promise<ResolvedViteConfigSlice>;
30}
31
32/** The structural slice of a resolved vite config this package reads. */
33export interface ResolvedViteConfigSlice {
34 readonly root: string;
35 readonly build: { readonly outDir: string };
36}
37
38/** The structural slice of a Vite dev server this package reads. */
39export interface ViteDevServer {
40 readonly listen: () => Promise<unknown>;
41 readonly close: () => Promise<void>;
42 /**
43 * Native Node HTTP server. `null` in middleware mode; optional so older
44 * vite slices without the field still typecheck.
45 */
46 readonly httpServer?:
47 | { readonly closeAllConnections?: () => void }
48 | null
49 | undefined;
50 readonly resolvedUrls?:
51 | { readonly local: ReadonlyArray<string> }
52 | null
53 | undefined;
54}
55
56/**
57 * Serializable build knobs merged OVER the project's own `vite.config.*`
58 * (which loads natively, plugins included).
59 */
60export interface ViteBuildConfig {
61 /**
62 * Build output directory, relative to the project root. Overrides the
63 * project config's `build.outDir`.
64 * @default the project config's `build.outDir` (vite's default: "dist")
65 */
66 readonly outDir?: string | undefined;
67 /** Public base path the site deploys under (vite's `base`). */
68 readonly base?: string | undefined;
69 /**
70 * Alternate config file to load instead of the auto-discovered
71 * `vite.config.*`, resolved relative to the project root.
72 */
73 readonly configFile?: string | undefined;
74}
75
76/**
77 * The configuration this package assembles from {@link ViteOptions} and
78 * hands to a deploy-target factory (the target's `config`). Framework-core
79 * never inspects it.
80 */
81export interface ViteTargetConfig {
82 readonly vite?: ViteBuildConfig | undefined;
83 /**
84 * Assets-only Node serve entry (`vite/node` finish). `"spa"` serves
85 * `index.html` for unmatched GET paths.
86 * @default "spa" on the Node target
87 */
88 readonly notFoundHandling?: "none" | "spa" | "404-page" | undefined;
89 /**
90 * Serve `about/index.html` at `/about`.
91 * @default "none"
92 */
93 readonly htmlHandling?: "none" | "drop-trailing-slash" | undefined;
94}
95
96/**
97 * A deploy target for plain Vite: just the generic `DeployTarget` seams.
98 * A client-only Vite build has no server output, so targets have nothing
99 * framework-specific to hook — the AWS target only uses the wholesale
100 * `build` seam to move the build into a child process.
101 */
102export interface ViteTarget extends DeployTarget<ViteTargetConfig> {}
103
104/** How a deploy target is passed to this package (value, factory, or specifier). */
105export type ViteTargetInput = DeployTargetInput<ViteTarget, ViteTargetConfig>;
106
107/**
108 * The default deploy target: this package's AWS static-site target module,
109 * loaded from the project's dependency tree. (Cloudflare deploys plain Vite
110 * projects through its native Vite plugin — `Cloudflare.Website.Vite` —
111 * so no Cloudflare target exists here.)
112 */
113export const DEFAULT_TARGET_SPECIFIER =
114 "@alchemy.run/frontend-frameworks/vite/aws";
115
116export interface ViteOptions {
117 /**
118 * Project root (the directory containing `vite.config.*` / `index.html`).
119 * @default process.cwd()
120 */
121 readonly root?: string | undefined;
122 /**
123 * The deploy target the build is produced for. Accepts a target value, a
124 * `(config) => target` factory, or a module specifier string.
125 * @default "@alchemy.run/frontend-frameworks/vite/aws"
126 */
127 readonly target?: ViteTargetInput | undefined;
128 /** Serializable overrides merged over the project's `vite.config.*`. */
129 readonly vite?: ViteBuildConfig | undefined;
130 /**
131 * Forwarded to the Node target's static serve entry. Ignored by AWS
132 * (S3) / Cloudflare (Workers assets).
133 */
134 readonly notFoundHandling?: "none" | "spa" | "404-page" | undefined;
135 readonly htmlHandling?: "none" | "drop-trailing-slash" | undefined;
136 readonly dev?:
137 | {
138 /** Default dev-server port (overridden by `FrameworkDevOptions.port`). */
139 readonly port?: number | undefined;
140 }
141 | undefined;
142}
143
144const fail = (message: string, cause?: unknown) =>
145 new FrameworkError({ framework: "vite", message, cause });
146
147/**
148 * Build the `Framework` service implementation for a plain Vite project —
149 * a client-only site (React/Vue/Solid SPA, an `index.html` multi-page app,
150 * a Foldkit app, ...) whose entire deployable output is static assets.
151 *
152 * - `build` resolves the deploy target, then drives the PROJECT's own Vite
153 * install programmatically: one `vite build` with the project's
154 * `vite.config.*` (plugins included) producing the assets directory. The
155 * `BuildOutput` is assets-only: `clientDirectory` = the resolved
156 * `build.outDir`, `serverModules: undefined`.
157 * - `dev` runs the project's own Vite dev server programmatically (native
158 * HMR), scoped — closing the Scope closes the server.
159 *
160 * Frameworks that wrap Vite with a server half (Octane, SvelteKit, Astro,
161 * ...) have their own integrations; Octane's fails actionably when pointed
162 * at a fullstack config, and this one deliberately never looks for server
163 * output.
164 */
165export const make: (
166 options?: ViteOptions,
167) => Effect.Effect<
168 Framework["Service"],
169 never,
170 FileSystem.FileSystem | Path.Path
171> = Effect.fnUntraced(function* (options?: ViteOptions) {
172 const fs = yield* FileSystem.FileSystem;
173 const path = yield* Path.Path;
174 const baseRoot = options?.root ?? (yield* Effect.sync(() => process.cwd()));
175
176 const targetConfig: ViteTargetConfig = {
177 vite: options?.vite,
178 notFoundHandling: options?.notFoundHandling,
179 htmlHandling: options?.htmlHandling,
180 };
181
182 const resolveTarget = (root: string) =>
183 FrameworkCore.resolveDeployTarget<ViteTarget, ViteTargetConfig>(
184 root,
185 options?.target ?? DEFAULT_TARGET_SPECIFIER,
186 targetConfig,
187 ).pipe(Effect.mapError((error) => fail(error.message, error.cause)));
188
189 const loadVite = (root: string) =>
190 FrameworkCore.loadProjectModule<ViteModule>(root, "vite").pipe(
191 Effect.mapError((error) =>
192 fail("Failed to load the project's Vite install", error.cause),
193 ),
194 );
195
196 /**
197 * The inline config merged over the project's `vite.config.*` on both
198 * `build` and `resolveConfig`, so the resolved `outDir` and the actual
199 * build always agree.
200 */
201 const inlineConfig = (root: string): Record<string, unknown> => ({
202 root,
203 logLevel: "warn",
204 // Resolve against the project root, not the cwd vite would use, so
205 // "relative to rootDir" semantics hold regardless of where the engine
206 // (or the build child) happens to run.
207 ...(options?.vite?.configFile !== undefined
208 ? { configFile: path.resolve(root, options.vite.configFile) }
209 : undefined),
210 ...(options?.vite?.base !== undefined
211 ? { base: options.vite.base }
212 : undefined),
213 ...(options?.vite?.outDir !== undefined
214 ? { build: { outDir: options.vite.outDir } }
215 : undefined),
216 });
217
218 /** Resolve the absolute assets output directory for a build at `root`. */
219 const resolveOutDir = (vite: ViteModule, root: string) =>
220 Effect.gen(function* () {
221 if (options?.vite?.outDir !== undefined) {
222 return path.resolve(root, options.vite.outDir);
223 }
224 if (vite.resolveConfig === undefined) {
225 return path.resolve(root, "dist");
226 }
227 const resolved = yield* Effect.tryPromise({
228 try: async () =>
229 await vite.resolveConfig!(
230 { ...inlineConfig(root), logLevel: "error" },
231 "build",
232 ),
233 catch: (error) =>
234 fail("Failed to resolve the project's vite config", error),
235 });
236 return path.resolve(resolved.root ?? root, resolved.build.outDir);
237 });
238
239 const build: Framework["Service"]["build"] = Effect.fn(
240 function* (buildOptions) {
241 const root = buildOptions?.root ?? baseRoot;
242 const target = yield* resolveTarget(root);
243 const targetContext = { root, framework: "vite", env: buildOptions?.env };
244
245 // Wholesale build takeover (the AWS target uses this seam to run the
246 // build in a disposable child process — vite.config.* executes user
247 // plugins that must not touch the engine process).
248 if (target.build !== undefined) {
249 return yield* target.build(targetContext).pipe(
250 Effect.mapError((error) => fail(error.message, error.cause)),
251 Effect.provideService(FileSystem.FileSystem, fs),
252 Effect.provideService(Path.Path, path),
253 );
254 }
255
256 const vite = yield* loadVite(root);
257 yield* Effect.tryPromise({
258 try: async () => await vite.build(inlineConfig(root)),
259 catch: (error) => fail("Failed to build", error),
260 });
261
262 const outDir = yield* resolveOutDir(vite, root);
263 const output = yield* readViteOutput({ outDir }).pipe(
264 Effect.provideService(FileSystem.FileSystem, fs),
265 );
266
267 return yield* FrameworkCore.applyDeployTargetFinish(
268 target,
269 output,
270 targetContext,
271 ).pipe(
272 Effect.mapError((error) => fail(error.message, error.cause)),
273 Effect.provideService(FileSystem.FileSystem, fs),
274 Effect.provideService(Path.Path, path),
275 );
276 },
277 );
278
279 const dev: Framework["Service"]["dev"] = Effect.fn(function* (devOptions) {
280 const root = devOptions?.root ?? baseRoot;
281 const vite = yield* loadVite(root);
282 // `port: 0` (true OS-assigned) on Vite >= 8.2.1, probed ephemeral port
283 // on older Vite — see `resolveViteDevPort`.
284 const port = yield* FrameworkCore.resolveViteDevPort(
285 vite.version,
286 devOptions?.port ?? options?.dev?.port,
287 );
288 const host = devOptions?.host;
289
290 const server = yield* Effect.acquireRelease(
291 Effect.tryPromise({
292 try: async () => {
293 const server = await vite.createServer({
294 root,
295 ...(options?.vite?.configFile !== undefined
296 ? { configFile: path.resolve(root, options.vite.configFile) }
297 : undefined),
298 ...(options?.vite?.base !== undefined
299 ? { base: options.vite.base }
300 : undefined),
301 server: {
302 port,
303 ...(host !== undefined ? { host } : undefined),
304 },
305 });
306 await server.listen();
307 return server;
308 },
309 catch: (error) => fail("Failed to start the Vite dev server", error),
310 }),
311 (server) =>
312 Effect.promise(async () => {
313 try {
314 server.httpServer?.closeAllConnections?.();
315 await server.close();
316 } catch {
317 // teardown is best-effort
318 }
319 }).pipe(
320 Effect.timeout("3 seconds"),
321 Effect.orElseSucceed(() => undefined),
322 ),
323 );
324
325 const url = server.resolvedUrls?.local[0];
326 if (url === undefined) {
327 return yield* Effect.fail(fail("Could not determine the dev server URL"));
328 }
329
330 // Bounded readiness probe: any HTTP response counts (vite serves
331 // lazily; we only need the listener to answer).
332 yield* Effect.tryPromise({
333 try: async () => {
334 const response = await fetch(url, { redirect: "manual" });
335 await response.arrayBuffer().catch(() => {});
336 },
337 catch: (error) => fail("The dev server did not become reachable", error),
338 }).pipe(
339 Effect.retry({ schedule: Schedule.spaced("250 millis"), times: 40 }),
340 );
341
342 return { url };
343 });
344
345 return Framework.of({ build, dev });
346});
347
348/**
349 * Map a plain Vite build's on-disk output onto the `BuildOutput` contract:
350 * assets-only — the whole `outDir` is the client directory, there are no
351 * server modules, and no external workspace roots (vite bundles workspace
352 * imports into the client assets).
353 */
354export const readViteOutput = (options: {
355 /** Absolute assets output directory (the resolved `build.outDir`). */
356 readonly outDir: string;
357}): Effect.Effect<
358 FrameworkCore.BuildOutput,
359 FrameworkError,
360 FileSystem.FileSystem
361> =>
362 Effect.gen(function* () {
363 const fs = yield* FileSystem.FileSystem;
364 const exists = yield* Effect.orElseSucceed(
365 fs.exists(options.outDir),
366 () => false,
367 );
368 if (!exists) {
369 return yield* Effect.fail(
370 fail(
371 `The Vite build produced no output directory at ${options.outDir}`,
372 ),
373 );
374 }
375 return {
376 distDirectory: options.outDir,
377 clientDirectory: options.outDir,
378 serverModules: undefined,
379 externalWorkspaces: new Set<string>(),
380 } satisfies FrameworkCore.BuildOutput;
381 });
382
383/**
384 * A `Layer` providing framework-core's `Framework` service for a plain Vite
385 * project — the fully-typed entrypoint for `e2e.config.ts` and alchemy's
386 * `AWS.Website.Vite` composite.
387 */
388export const layer = (
389 options?: ViteOptions,
390): Layer.Layer<Framework, never, FileSystem.FileSystem | Path.Path> =>
391 Layer.effect(Framework, make(options));