| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as FileSystem from "effect/FileSystem"; |
| 3 | import * as Layer from "effect/Layer"; |
| 4 | import * as Path from "effect/Path"; |
| 5 | import * as Schedule from "effect/Schedule"; |
| 6 | import * as FrameworkCore from "../core/index.ts"; |
| 7 | import { |
| 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. */ |
| 15 | export 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. */ |
| 33 | export 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. */ |
| 39 | export 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 | */ |
| 60 | export 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 | */ |
| 81 | export 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 | */ |
| 102 | export interface ViteTarget extends DeployTarget<ViteTargetConfig> {} |
| 103 | |
| 104 | /** How a deploy target is passed to this package (value, factory, or specifier). */ |
| 105 | export 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 | */ |
| 113 | export const DEFAULT_TARGET_SPECIFIER = |
| 114 | "@alchemy.run/frontend-frameworks/vite/aws"; |
| 115 | |
| 116 | export 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 | |
| 144 | const 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 | */ |
| 165 | export 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 | */ |
| 354 | export 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 | */ |
| 388 | export const layer = ( |
| 389 | options?: ViteOptions, |
| 390 | ): Layer.Layer<Framework, never, FileSystem.FileSystem | Path.Path> => |
| 391 | Layer.effect(Framework, make(options)); |