diff --git a/cli/commands/build/command.test.ts b/cli/commands/build/command.test.ts index cb98bc7459..0f7ec2cce6 100644 --- a/cli/commands/build/command.test.ts +++ b/cli/commands/build/command.test.ts @@ -1,7 +1,12 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertExists, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { buildCommand, formatBuildOutputPath, runWithBundlerShutdown } from "./command.ts"; +import { + buildCommand, + formatBuildOutputPath, + releaseBuildExtensions, + runWithBundlerShutdown, +} from "./command.ts"; import type { BuildOptions } from "./types.ts"; describe("commands/build/command", () => { @@ -55,6 +60,33 @@ describe("commands/build/command", () => { }); }); + describe("releaseBuildExtensions", () => { + it("tears down the composed extensions", async () => { + let torndown = 0; + await releaseBuildExtensions({ + teardownAll: () => { + torndown++; + return Promise.resolve(); + }, + }); + assertEquals(torndown, 1); + }); + + it("does nothing when no extensions were composed", async () => { + // The build can fail before composition, so the release path runs with + // nothing to release and must not throw. + await releaseBuildExtensions(undefined); + }); + + it("does not let a teardown failure change the build outcome", async () => { + // The build has already produced its result. runWithBundlerShutdown sets + // the same precedent by preserving the build error over a shutdown one. + await releaseBuildExtensions({ + teardownAll: () => Promise.reject(new Error("teardown exploded")), + }); + }); + }); + describe("formatBuildOutputPath", () => { it("reports the default output relative to the project", () => { assertEquals( diff --git a/cli/commands/build/command.ts b/cli/commands/build/command.ts index bfa9058295..8ffce818a7 100644 --- a/cli/commands/build/command.ts +++ b/cli/commands/build/command.ts @@ -10,6 +10,7 @@ import { displayBuildSuccess } from "./stats-display.ts"; import type { BuildOptions } from "./types.ts"; import { isJsonMode, streamJsonLine } from "../../shared/json-output.ts"; import { ensureBuiltinContentProcessor } from "../../shared/ensure-content-processor.ts"; +import { setupBuildCliExtensions } from "../../shared/build-extensions.ts"; /** @internal */ export async function runWithBundlerShutdown( @@ -37,6 +38,33 @@ export async function runWithBundlerShutdown( return result; } +/** + * Release the extensions composed for this build. + * + * Extensions can hold timers and other resources, and `teardownAll()` also + * clears the process-global contract registry that `orchestrateExtensions` + * populated. `veryfront eval` and `veryfront serve` already do this; the build + * did not, so a command that composes extensions left them running. + * + * A teardown failure never changes the build's outcome. The build has already + * produced its result by this point, and `runWithBundlerShutdown` sets the + * same precedent by preserving the build error over a shutdown one. + * + * @internal + */ +export async function releaseBuildExtensions( + loader: { teardownAll: () => Promise } | undefined, +): Promise { + if (!loader) return; + try { + await loader.teardownAll(); + } catch { + if (!isJsonMode()) { + cliLogger.warn("Extension teardown failed after the build"); + } + } +} + export function formatBuildOutputPath(projectDir: string, outputDir: string): string { return relative(projectDir, resolve(projectDir, outputDir)).replace(/\\/g, "/"); } @@ -48,6 +76,14 @@ export function buildCommand(options: BuildOptions): Promise { const outputDir = options.outputDir ?? join(options.projectDir, "dist"); const startTime = Date.now(); const dryRun = options.dryRun ?? false; + let extensions: Awaited> | undefined; + // exit() does not run `finally`, so the JSON error path below releases + // explicitly before exiting. Clearing the handle keeps that idempotent. + const releaseExtensions = async (): Promise => { + const loader = extensions; + extensions = undefined; + await releaseBuildExtensions(loader); + }; try { if (isJsonMode()) { @@ -58,7 +94,11 @@ export function buildCommand(options: BuildOptions): Promise { const stats = await runWithBundlerShutdown(async () => { const adapter = await runtime.get(); - await getConfig(options.projectDir, adapter); + const config = await getConfig(options.projectDir, adapter); + // Compose the project's extensions before anything that resolves a + // contract. Only server bootstrap used to do this, so the build ran + // with whatever one-off shims had been added and failed on the rest. + extensions = await setupBuildCliExtensions(options.projectDir, config); await ensureBuiltinContentProcessor(); if (isJsonMode()) { @@ -121,11 +161,14 @@ export function buildCommand(options: BuildOptions): Promise { success: false, error: error instanceof Error ? error.message : String(error), }); + await releaseExtensions(); const { exit } = await import("veryfront/platform"); exit(1); return; } handleBuildError(error); + } finally { + await releaseExtensions(); } }, { "cli.projectDir": options.projectDir }, diff --git a/cli/shared/build-extensions.test.ts b/cli/shared/build-extensions.test.ts new file mode 100644 index 0000000000..aa03248aa8 --- /dev/null +++ b/cli/shared/build-extensions.test.ts @@ -0,0 +1,91 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertExists } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { setupBuildCliExtensions } from "./build-extensions.ts"; + +/** Minimal stand-in for the loader; the build path only needs it to resolve. */ +const loaderStub = {} as Awaited>; + +/** + * Deferred builtins do not declare their contracts until they load, so identity + * is what a caller can assert on before orchestration runs. + */ +function extensionNames( + builtins: readonly { extension: { name: string } }[], +): Set { + return new Set(builtins.map((builtin) => builtin.extension.name)); +} + +describe("cli/shared/build-extensions", () => { + it("composes the project's configured extensions", async () => { + let seen: { projectDir?: string; config?: unknown } = {}; + // A full Extension entry: ExtensionConfigEntry only admits an Extension or + // an explicit { name, enabled: false } disable. + const config = { + extensions: [{ name: "ext-css-lightning", version: "1.0.0", capabilities: [] }], + }; + + await setupBuildCliExtensions("/projects/app", config, (options) => { + seen = { projectDir: options.projectDir, config: options.config }; + return Promise.resolve(loaderStub); + }); + + assertEquals(seen.projectDir, "/projects/app"); + // The build must honor what the project declares, not a hardcoded default. + assertEquals(seen.config, config); + }); + + it("offers the CSSProcessor provider among the builtins", async () => { + let builtins: readonly { extension: { name: string } }[] = []; + + await setupBuildCliExtensions("/projects/app", {}, (options) => { + builtins = options.builtinExtensions ?? []; + return Promise.resolve(loaderStub); + }); + + // Without this, `veryfront build` reaches the release-asset CSS compile with + // no CSSProcessor registered and fails with "Missing extension for contract". + assertEquals(extensionNames(builtins).has("ext-css-tailwind"), true); + }); + + it("offers the bundler and content providers the build also needs", async () => { + let builtins: readonly { extension: { name: string } }[] = []; + + await setupBuildCliExtensions("/projects/app", {}, (options) => { + builtins = options.builtinExtensions ?? []; + return Promise.resolve(loaderStub); + }); + + const names = extensionNames(builtins); + assertEquals(names.has("ext-bundler-esbuild"), true); + assertEquals(names.has("ext-content-mdx"), true); + }); + + it("hands orchestration a logger it can actually log through", async () => { + // Not "names the build": cliLogger.component() deliberately returns the + // same logger, because CLI output carries no structured component tag. So + // there is no attribution to assert on, only that orchestration receives + // something usable. `typeof x === "object"` alone would also accept null. + let logger: Record | undefined; + + await setupBuildCliExtensions("/projects/app", {}, (options) => { + logger = options.logger as unknown as Record; + return Promise.resolve(loaderStub); + }); + + assertExists(logger); + for (const method of ["debug", "info", "warn", "error"] as const) { + assertEquals(typeof logger[method], "function", `logger.${method} must be callable`); + } + }); + + it("returns the composed loader to the caller", async () => { + const result = await setupBuildCliExtensions( + "/projects/app", + {}, + () => Promise.resolve(loaderStub), + ); + + assertEquals(result, loaderStub); + }); +}); diff --git a/cli/shared/build-extensions.ts b/cli/shared/build-extensions.ts new file mode 100644 index 0000000000..538a7694a5 --- /dev/null +++ b/cli/shared/build-extensions.ts @@ -0,0 +1,51 @@ +/** + * Extension composition for `veryfront build`. + * + * Extension orchestration used to happen only in server bootstrap, so commands + * that never start a server ran with an almost empty contract registry. The + * build path compensated with one-off shims (`ensureCliBundlerContracts`, + * `ensureBuiltinContentProcessor`) that covered the contracts someone had + * already been bitten by, and nothing else. + * + * CSS was the contract nobody had added a shim for. A scaffolded project whose + * stylesheet is `@import "tailwindcss";` reached the release-asset CSS compile + * with no CSSProcessor registered and failed with: + * + * Missing extension for contract "CSSProcessor". + * Install it with: deno add @veryfront/ext-css-tailwind + * + * The extension was installed the whole time. `veryfront dev` compiled the same + * stylesheet correctly because starting a server orchestrated it. + * + * Composing extensions here fixes that class of failure rather than one + * instance of it, and honors what the project configures: a project that + * declares `ext-css-lightning` gets its own processor instead of whichever one + * a shim happened to hardcode. `veryfront eval` already does this. + * + * @module cli/shared/build-extensions + */ + +import { orchestrateExtensions } from "veryfront/extensions"; +import { cliLogger } from "#cli/utils"; +import { createBuiltinExtensions } from "../../src/extensions/builtin-extensions.ts"; + +type OrchestrateExtensions = typeof orchestrateExtensions; +type OrchestrateOptions = Parameters[0]; + +/** + * Compose the extensions a production build needs. + * + * `orchestrate` is a test seam and defaults to the real implementation. + */ +export async function setupBuildCliExtensions( + projectDir: string, + config: OrchestrateOptions["config"], + orchestrate: OrchestrateExtensions = orchestrateExtensions, +): Promise>> { + return await orchestrate({ + projectDir, + config, + logger: cliLogger.component("build-extensions"), + builtinExtensions: createBuiltinExtensions(), + }); +}