alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
310 lines (282 loc) • 10.6 kB
text/typescript
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;
}
}
}