UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

310 lines (282 loc) 10.6 kB
import { $inject, $state, Alepha, AlephaError, z } from "alepha"; import { $command } from "alepha/command"; import { $logger } from "alepha/logger"; import { type BuildRuntime, type BuildTarget, buildOptions, } from "../atoms/buildOptions.ts"; import { AppEntryProvider } from "../providers/AppEntryProvider.ts"; import { ViteBuildProvider } from "../providers/ViteBuildProvider.ts"; import { AlephaCliUtils } from "../services/AlephaCliUtils.ts"; import { PackageManagerUtils } from "../services/PackageManagerUtils.ts"; import { ProjectScaffolder } from "../services/ProjectScaffolder.ts"; import { BuildAssetsTask } from "../tasks/BuildAssetsTask.ts"; import { BuildClientTask } from "../tasks/BuildClientTask.ts"; import { BuildCloudflareTask } from "../tasks/BuildCloudflareTask.ts"; import { BuildCompressTask } from "../tasks/BuildCompressTask.ts"; import { BuildDockerTask } from "../tasks/BuildDockerTask.ts"; import { BuildPrerenderTask } from "../tasks/BuildPrerenderTask.ts"; import { BuildPwaTask } from "../tasks/BuildPwaTask.ts"; import { BuildServerTask } from "../tasks/BuildServerTask.ts"; import { BuildStaticTask } from "../tasks/BuildStaticTask.ts"; import type { BuildTaskContext } from "../tasks/BuildTask.ts"; import { BuildVercelTask } from "../tasks/BuildVercelTask.ts"; export class BuildCommand { protected readonly alepha = $inject(Alepha); protected readonly log = $logger(); protected readonly utils = $inject(AlephaCliUtils); protected readonly pm = $inject(PackageManagerUtils); protected readonly scaffolder = $inject(ProjectScaffolder); protected readonly boot = $inject(AppEntryProvider); protected readonly viteBuildProvider = $inject(ViteBuildProvider); protected readonly options = $state(buildOptions); /** * Build pipeline: tasks run sequentially in this order. * Each task self-guards (checks target, hasClient, etc.). * Order matters — compress must be last. */ protected readonly pipeline = [ $inject(BuildClientTask), $inject(BuildServerTask), $inject(BuildAssetsTask), $inject(BuildPwaTask), $inject(BuildPrerenderTask), $inject(BuildVercelTask), $inject(BuildCloudflareTask), $inject(BuildDockerTask), $inject(BuildStaticTask), $inject(BuildCompressTask), ]; /** * Value aliases accepted for `--target`. * * These let the CLI accept short forms (e.g. `--target cf`) that are * canonicalized to a real {@link BuildTarget} before they flow into the * pipeline. The enum in `flags.target` must also list the alias so it * passes schema validation. */ protected readonly targetAliases: Record<string, BuildTarget> = { cf: "cloudflare", }; /** * Canonicalize a raw `--target` value, mapping any known alias * (e.g. `cf` → `cloudflare`) to its real {@link BuildTarget}. */ protected resolveTarget(target: string | undefined): BuildTarget | undefined { if (!target) { return undefined; } return this.targetAliases[target] ?? (target as BuildTarget); } /** * Resolve the effective runtime based on target and explicit runtime flag. * * Some targets force a specific runtime: * - `cloudflare` always uses `workerd` * - `vercel` always uses `node` * - `docker` and bare deployments respect the runtime flag * * @throws {AlephaError} If an incompatible runtime is specified for a target */ protected resolveRuntime( target: BuildTarget | undefined, runtime: BuildRuntime | undefined, ): BuildRuntime { if (target === "cloudflare") { if (runtime && runtime !== "workerd") { throw new AlephaError( `Target 'cloudflare' requires 'workerd' runtime, got '${runtime}'`, ); } return "workerd"; } if (target === "vercel") { if (runtime && runtime !== "node") { throw new AlephaError( `Target 'vercel' requires 'node' runtime, got '${runtime}'`, ); } return "node"; } return runtime ?? "node"; } public readonly build = $command({ name: "build", mode: "production", description: "Build the project for production", flags: z.object({ stats: z .union([z.boolean(), z.enum(["json"])]) .describe("Generate build stats report") .optional(), target: z .enum(["bare", "docker", "vercel", "cloudflare", "cf", "static"]) .meta({ aliases: ["t"] }) .describe("Deployment target (cf = cloudflare)") .optional(), runtime: z .enum(["node", "bun", "workerd"]) .meta({ aliases: ["r"] }) .describe("JavaScript runtime") .optional(), image: z .union([z.boolean(), z.text()]) .meta({ aliases: ["i"] }) .describe( "Build Docker image. Use -i for latest, -i=<version> for specific version", ) .optional(), compile: z .boolean() .meta({ aliases: ["c"] }) .describe( "Compile server to a single static binary (requires --target=docker --runtime=bun)", ) .optional(), prebuilt: z .boolean() .describe( "Skip the bundle steps (Vite client/server + asset compression). Only regenerates target-specific deploy config (e.g. wrangler.jsonc). Use when `dist/` is already built and you just need the config refreshed.", ) .optional(), }), handler: async ({ flags, run, root }) => { process.env.NODE_ENV = "production"; if (await this.pm.hasExpo(root)) { // will come soon return; } await this.scaffolder.ensureConfig(root, { tsconfigJson: true, }); const entry = await this.boot.getAppEntry(root); this.log.trace("Entry file found", { entry }); // Resolve flags → mutate the atom (single source of truth) this.alepha.store.mut(buildOptions, (current) => { const target = this.resolveTarget(flags.target) ?? current.target; return { ...current, stats: flags.stats ?? current.stats ?? false, target, runtime: this.resolveRuntime( target, flags.runtime ?? current.runtime, ), ...(flags.compile !== undefined && { docker: { ...current.docker, compile: flags.compile ? (current.docker?.compile ?? true) : false, }, }), }; }); const options = this.options; const distDir = options.output?.dist ?? "dist"; // Prebuilt mode: skip clean + Vite builds + asset compression; only // regenerate target-specific deploy config (e.g. wrangler.jsonc). // Used by external orchestrators (Rocket) that ship a pre-built // dist/ and just need the config refreshed for per-tenant overrides. if (!flags.prebuilt) { await run.rm(distDir, { alias: "clean dist" }); } const { target } = options; // Validate --image requires --target=docker if (flags.image && target !== "docker") { throw new AlephaError( `Flag '--image' requires '--target=docker', got '${target ?? "bare"}'`, ); } // Validate --compile requires --target=docker --runtime=bun if (options.docker?.compile) { if (target !== "docker") { throw new AlephaError( `Compile mode requires '--target=docker', got '${target ?? "bare"}'`, ); } if (options.runtime !== "bun") { throw new AlephaError( `Compile mode requires '--runtime=bun', got '${options.runtime}'`, ); } } this.log.trace("Build configuration", { target, runtime: options.runtime, }); // Prebuilt + manifest fast-path: skip `analyze app` (which boots // the workspace via Vite and requires its full node_modules tree). // BuildCloudflareTask reads from `ctx.manifest` instead of from // `ctx.alepha` in this mode. Falls back to the introspection path // when no manifest is present (older artifacts). let manifest: Awaited<ReturnType<typeof this.loadManifest>> = null; if (flags.prebuilt) { manifest = await this.loadManifest(root); } let appAlepha: Alepha | undefined; let hasClient = false; if (!manifest) { await run({ name: "analyze app", handler: async () => { appAlepha = await this.viteBuildProvider.init({ entry }); hasClient = this.viteBuildProvider.hasClient(); }, }); if (!appAlepha) { throw new AlephaError("Alepha instance not found"); } } // Read platformOptions from the CLI's Alepha instance — this is // where alepha.config.ts wrote them during the configure hook. // The workspace's appAlepha (from Vite) is a separate instance // and doesn't have these. Captured here so BuildCloudflareTask // can serialize them into dist/manifest.json without needing to // re-load alepha.config.ts at deploy time. const platformOptions = (this.alepha.store.get("alepha.cli.platform.options") as | BuildTaskContext["platformOptions"] | undefined) ?? null; const ctx: BuildTaskContext = { // Cast: when manifest mode is active, BuildCloudflareTask reads // from ctx.manifest and never dereferences ctx.alepha. Bundle // tasks (BuildClient/Server/etc.) self-guard on ctx.flags.prebuilt // and return early, so they don't touch alepha either. alepha: (appAlepha ?? null) as unknown as Alepha, options, root, run, entry, hasClient, manifest, platformOptions, flags: { image: flags.image, prebuilt: flags.prebuilt }, }; for (const task of this.pipeline) { await task.run(ctx); } }, }); /** * Read `dist/manifest.json` produced by a previous `alepha build`. * Returns null when absent or unparseable — caller falls back to the * Vite-introspection path. */ protected async loadManifest(root: string) { try { const fs = await import("node:fs/promises"); const path = await import("node:path"); const raw = await fs.readFile( path.join(root, "dist", "manifest.json"), "utf-8", ); return JSON.parse( raw, ) as import("../tasks/BuildCloudflareTask.ts").BuildManifest; } catch { return null; } } }