Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 119 additions & 5 deletions cli/commands/build/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ensureCliBundlerContracts } from "#cli/shared/default-contracts";
import { showHeader } from "#cli/utils";
import type { ParsedArgs } from "#cli/shared/types";
import { ensureBuiltinContentProcessor } from "../../shared/ensure-content-processor.ts";
import { isJsonMode, streamJsonLine } from "../../shared/json-output.ts";

/**
* Schema factory for build command arguments
Expand Down Expand Up @@ -148,10 +149,80 @@ export async function handleBuildCommand(args: ParsedArgs): Promise<void> {
});
}

/** The synthetic shell route `buildEmbeddedPreset` prepends to every manifest. */
const EMBEDDED_APP_SHELL_FILE = "embedded/app.js";

/**
* Total bytes of the artifacts the embedded manifest declares.
*
* The production build reports the size of what it emitted, so the embedded
* preset reports the same thing rather than leaving the field at zero. The
* manifest is the artifact list, so it cannot drift from what was written.
* A file the preset failed to emit is skipped: `buildEmbeddedPreset` only warns
* when an RSC bundle or a route fails, and a size roll-up must not turn that
* warning into a hard error.
*/
async function sumEmbeddedOutputSize(
outDir: string,
manifest: { routes: ReadonlyArray<{ file: string }>; assets: ReadonlyArray<{ file: string }> },
): Promise<number> {
const { join } = await import("veryfront/platform/path");
const { createFileSystem } = await import("veryfront/platform");
const fs = createFileSystem();

const files = new Set<string>(["embedded/manifest.json"]);
for (const route of manifest.routes) files.add(route.file);
for (const asset of manifest.assets) files.add(asset.file);

let total = 0;
for (const file of files) {
try {
total += (await fs.stat(join(outDir, file))).size;
} catch {
// Not emitted — already reported by the preset as a warning.
}
}
return total;
}

/**
* Run the embedded build, terminating the NDJSON stream ourselves in JSON mode.
*
* Once a `step` line has reached stdout, the router's error envelope must not
* also be written: it is a different, multi-line shape, so a consumer gets a
* partial NDJSON stream followed by something that is not NDJSON at all. The
* default path solves this by streaming its own `result` and calling `exit(1)`
* rather than rethrowing, and this matches it — including for failures in the
* config phase, which happen after the first `step` line is already out.
*/
async function handleEmbeddedBuild(projectDir: string, outputDir?: string): Promise<void> {
if (!isJsonMode()) {
await runEmbeddedBuild(projectDir, outputDir);
return;
}
try {
await runEmbeddedBuild(projectDir, outputDir);
} catch (error) {
streamJsonLine({
type: "result",
success: false,
error: error instanceof Error ? error.message : String(error),
});
const { exit } = await import("veryfront/platform");
exit(1);
}
}

async function runEmbeddedBuild(projectDir: string, outputDir?: string): Promise<void> {
const { buildEmbeddedPreset } = await import("veryfront/build");
const { getConfig } = await import("veryfront/config");
const { resolveBuildOutputDir } = await import("./command.ts");
const startTime = Date.now();
// Failures are terminated by the caller, which streams the error result and
// exits rather than letting the router print a second envelope.
const json = isJsonMode();

if (json) streamJsonLine({ type: "step", name: "config", status: "started" });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep config failures inside the NDJSON error path

When runtime initialization, configuration loading, or output resolution fails, this config: started event has already reached stdout, but those operations at lines 203-212 are outside the new catch. The router then appends its multi-line error envelope, leaving consumers with a partial NDJSON stream followed by a different JSON format. Include the config phase in the streaming error handling, or defer the first event until configuration succeeds.

AGENTS.md reference: AGENTS.md:L139-L143

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixed in 5e2b7fc — and both were right. My first pass made the error path better without making it correct.

P1. Streaming the result then rethrowing left the router free to append its own envelope, so stdout still carried two formats and the second was not NDJSON. Now matches the default path: stream the result, then exit(1), no rethrow.

P2. The config phase ran outside that catch but after step: config started had already reached stdout, which reproduces the same hybrid. The whole build is now inside the JSON-mode wrapper, so any failure past the first step line terminates the same way.

Split into handleEmbeddedBuild (terminator) and runEmbeddedBuild (work), leaving the non-JSON path on the router's error handling untouched.

The new test drives a real failure — an .mdx with an unclosed JSX expression, which fails in the bundler after config is reported, exactly where the second envelope used to appear. It asserts stdout is entirely NDJSON, that there is exactly one result line, and that it carries success: false. Verified red against the previous commit.


// The config was never loaded on this path, so `build.outDir` was ignored
// and the preset always wrote `dist`. Resolving through the same helper the
Expand All @@ -168,19 +239,62 @@ async function handleEmbeddedBuild(projectDir: string, outputDir?: string): Prom
clearsOutputDir: false,
});

cliLogger.info("Building embedded preset...");
if (isVerbose()) {
cliLogger.info(` ${dim("Project:")} ${projectDir}`);
cliLogger.info(` ${dim("Output:")} ${finalOutput}`);
if (json) {
streamJsonLine({ type: "step", name: "config", status: "completed" });
streamJsonLine({ type: "step", name: "build", status: "started" });
} else {
cliLogger.info("Building embedded preset...");
if (isVerbose()) {
cliLogger.info(` ${dim("Project:")} ${projectDir}`);
cliLogger.info(` ${dim("Output:")} ${finalOutput}`);
}
}

await buildEmbeddedPreset({
const { manifest } = await buildEmbeddedPreset({
projectDir,
outDir: finalOutput,
runtime: "deno",
config,
});

if (json) {
const elapsed = Date.now() - startTime;
streamJsonLine({
type: "step",
name: "build",
status: "completed",
duration_ms: elapsed,
});
// Same event and payload shape as the default build path in command.ts:
// one command must not answer `--json` with two different result lines.
streamJsonLine({
type: "result",
success: true,
data: {
// `buildEmbeddedPreset` unshifts a synthetic `/` -> `embedded/app.js`
// shell route on top of the discovered ones, so counting every
// `type: "page"` reports one more page than the project has. Measured
// on a two-page fixture: routes are the shell, `/about`, and `/`, so
// the naive count says 3.
pages: manifest.routes.filter((route) =>
route.type === "page" && route.file !== EMBEDDED_APP_SHELL_FILE
).length,
// The default path reports 0 for a build with no splitting stage, and
// the embedded preset has none — which is why `--split` is rejected for
// it above. Reporting 1 here would answer the same field differently
// from the command this is supposed to match.
chunks: 0,
assets: manifest.assets.length,
totalSize: await sumEmbeddedOutputSize(finalOutput, manifest),
duration_ms: elapsed,
outputDir: finalOutput,
// `--dry-run` is rejected for this preset, so a build that got here ran.
dryRun: false,
},
});
return;
}

logSuccess("Built embedded preset");
cliLogger.info(` ${finalOutput}\n`);
}
188 changes: 188 additions & 0 deletions tests/integration/build/embedded-json-output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import "#veryfront/schemas/_test-setup.ts";
import { assertEquals } from "#veryfront/testing/assert.ts";
import { describe, it } from "#veryfront/testing/bdd.ts";
import { join } from "#veryfront/compat/path/index.ts";

/**
* Keys the default build path puts on its `type: "result"` line.
*
* Pinned here so the embedded preset cannot answer the same command with a
* second, differently shaped payload. Kept in sync with `buildCommand` in
* command.ts.
*/
const RESULT_DATA_KEYS = [
"assets",
"chunks",
"dryRun",
"duration_ms",
"outputDir",
"pages",
"totalSize",
];

const REPO_ROOT = new URL("../../../", import.meta.url).pathname;

interface CliResult {
code: number;
stdout: string;
stderr: string;
}

/**
* Run the real CLI in a child process.
*
* In-process is not an option here: `buildEmbeddedPreset` calls `esbuild.stop()`
* when it finishes, and the bundler cannot be brought back up in the same
* process, so only one embedded build per process can succeed — and
* embedded-preset-flags.test.ts already spends it. A child process is also the
* only way to see everything that reaches stdout, which is what `--json`
* promises.
*/
async function runCli(projectDir: string, args: string[]): Promise<CliResult> {
const command = new Deno.Command(Deno.execPath(), {
args: [
"run",
"-A",
"--config",
join(REPO_ROOT, "deno.json"),
"--unstable-worker-options",
"--unstable-net",
join(REPO_ROOT, "cli/main.ts"),
...args,
],
cwd: projectDir,
env: {
NO_COLOR: "1",
DENO_TESTING: "1",
VERYFRONT_NO_UPDATE_CHECK: "1",
},
stdin: "null",
stdout: "piped",
stderr: "piped",
});

const output = await command.output();
const decoder = new TextDecoder();
return {
code: output.code,
stdout: decoder.decode(output.stdout),
stderr: decoder.decode(output.stderr),
};
}

async function makeProject(prefix: string): Promise<string> {
const projectDir = await Deno.makeTempDir({ prefix });
await Deno.mkdir(join(projectDir, "app"), { recursive: true });
await Deno.writeTextFile(join(projectDir, "app/page.mdx"), "# Home\n");
return projectDir;
}

function parseJsonLine(line: string): Record<string, unknown> | undefined {
try {
const parsed = JSON.parse(line) as unknown;
if (typeof parsed !== "object" || parsed === null) return undefined;
return parsed as Record<string, unknown>;
} catch {
return undefined;
}
}

describe("commands/build embedded preset --json", () => {
it("emits only NDJSON, ending in the default path's result line", async () => {
const projectDir = await makeProject("vf-embedded-json-");
// realPath because macOS temp dirs sit behind a symlink and the CLI reports
// the directory it resolved from its own cwd.
const expectedOutputDir = join(await Deno.realPath(projectDir), "dist");
let result: CliResult;
try {
result = await runCli(projectDir, ["build", "--preset", "embedded", "--json"]);
} finally {
await Deno.remove(projectDir, { recursive: true });
}

assertEquals(result.code, 0, `build failed:\n${result.stdout}\n${result.stderr}`);

const lines = result.stdout.split("\n").filter((line) => line.trim() !== "");
const prose = lines.filter((line) => parseJsonLine(line) === undefined);
assertEquals(
prose,
[],
`--json must put nothing but NDJSON on stdout: ${JSON.stringify(prose)}`,
);

const events = lines.map((line) => parseJsonLine(line)!);
const results = events.filter((event) => event.type === "result");
assertEquals(
results.length,
1,
`expected exactly one result line, got:\n${result.stdout}`,
);

const resultLine = results[0]!;
assertEquals(resultLine.success, true, JSON.stringify(resultLine));
const data = resultLine.data as Record<string, unknown>;
assertEquals(
Object.keys(data).sort(),
RESULT_DATA_KEYS,
"the embedded result payload must carry the same keys as the default build path",
);
assertEquals(data.dryRun, false);
assertEquals(typeof data.duration_ms, "number");
assertEquals(typeof data.totalSize, "number");
assertEquals(
data.outputDir,
expectedOutputDir,
"outputDir must be the directory the preset wrote",
);
// Exact, not `>= 1`. The fixture ships one page, and the preset unshifts a
// synthetic `/` -> `embedded/app.js` shell route on top of the discovered
// ones — so a `>= 1` assertion passes just as happily on the naive count
// that reports 2 for a one-page project.
assertEquals(
data.pages,
1,
`one page in, one page reported: ${JSON.stringify(data)}`,
);
// The default path reports 0 chunks for a build with no splitting stage.
assertEquals(data.chunks, 0, `embedded has no splitting stage: ${JSON.stringify(data)}`);
assertEquals(
(data.totalSize as number) > 0,
true,
`totalSize must reflect real artifacts: ${JSON.stringify(data)}`,
);
});
it("keeps the failure path NDJSON too, ending in one error result", async () => {
// Raised in review: streaming the error result and then rethrowing left the
// router free to append its own multi-line envelope, so stdout carried two
// result formats and the second was not NDJSON. The build must terminate
// the stream itself, as the default path does.
const projectDir = await Deno.makeTempDir({ prefix: "vf-embedded-json-fail-" });
await Deno.mkdir(join(projectDir, "app"), { recursive: true });
// Unclosed JSX expression: the bundler fails after `config` has already
// been reported, which is precisely when a second envelope used to appear.
await Deno.writeTextFile(join(projectDir, "app/page.mdx"), "# Broken\n\n<Foo\n");
let result: CliResult;
try {
result = await runCli(projectDir, ["build", "--preset", "embedded", "--json"]);
} finally {
await Deno.remove(projectDir, { recursive: true });
}

assertEquals(result.code === 0, false, `expected a nonzero exit:\n${result.stdout}`);

const lines = result.stdout.split("\n").filter((line) => line.trim() !== "");
const prose = lines.filter((line) => parseJsonLine(line) === undefined);
assertEquals(
prose,
[],
`a failed --json build must still put nothing but NDJSON on stdout: ${JSON.stringify(prose)}`,
);

const results = lines
.map(parseJsonLine)
.filter((entry): entry is Record<string, unknown> => entry?.type === "result");
assertEquals(results.length, 1, `exactly one result line: ${JSON.stringify(results)}`);
assertEquals(results[0].success, false);
assertEquals(typeof results[0].error, "string");
});
});