diff --git a/docs/docs/build.md b/docs/docs/build.md index c971d010..5c9a8856 100644 --- a/docs/docs/build.md +++ b/docs/docs/build.md @@ -15,9 +15,32 @@ ev build | `ev inspect --json` | Feed the same application summary to tooling | No | | `ev prepare` | Generate framework entries and declarations without bundling | `.ev` and generated declarations | | `ev build` | Create production browser/server output | `.ev`, declarations, and `dist` | +| `ev analyze` | Inspect bundle sizes and import chains with Utoopack | `.ev`, declarations, and an analysis report | +| `ev analyze export` | Export an existing report as JSONL | No; writes to standard output | -Only one `dev`, `prepare`, or `build` operation can change output for the same -project at a time. +Only one `dev`, `prepare`, `build`, or `analyze` operation can change output for +the same project at a time. + +## Analyze bundle size + +With a Utoopack version that supports the native Analyzer, run: + +```bash +ev analyze +ev analyze --output --output-dir reports/bundle-before +ev analyze export --report reports/bundle-before --environment client > bundle-before.jsonl +``` + +The first command serves the report at `http://127.0.0.1:4000`. Use `--port` +to choose another port or `--output` to save a static report without starting a +server. The default report directory is `.utoo/analyze`; keep it outside +`dist`. + +Analysis uses the application's production settings and generated client and +server entries. It leaves existing production output and stats intact. Export +reads a saved report without loading application config or compiling again. Use +`--entry` to select a particular report entry. When comparing two reports, keep +the entry, environment, and size metric consistent. ## Inspect the application first diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/build.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/build.md index 6aff0988..2dc5b8e4 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/build.md +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/build.md @@ -15,8 +15,28 @@ ev build | `ev inspect --json` | 把同一份应用摘要提供给工具 | 否 | | `ev prepare` | 不打包,只生成框架入口与声明 | `.ev` 与生成声明 | | `ev build` | 生成生产浏览器/服务端产物 | `.ev`、声明与 `dist` | +| `ev analyze` | 使用 Utoopack 查看产物体积与引入链 | `.ev`、声明与分析报告 | +| `ev analyze export` | 将已有报告导出为 JSONL | 否;写入标准输出 | -同一项目一次只能有一个 `dev`、`prepare` 或 `build` 操作修改输出。 +同一项目一次只能有一个 `dev`、`prepare`、`build` 或 `analyze` 操作修改输出。 + +## 分析产物体积 + +使用支持原生 Analyzer 的 Utoopack 版本时,可以运行: + +```bash +ev analyze +ev analyze --output --output-dir reports/bundle-before +ev analyze export --report reports/bundle-before --environment client > bundle-before.jsonl +``` + +第一条命令在 `http://127.0.0.1:4000` 提供报告界面。`--port` 可以指定端口, +`--output` 只保存静态报告,不启动服务。报告默认写到 `.utoo/analyze`,请将其放在 +`dist` 之外。 + +分析使用应用的生产配置及框架生成的客户端和服务端入口,保留已有生产产物与 stats。 +导出只读取已有报告,不加载应用配置,也不重新编译。可用 `--entry` 筛选报告入口。 +比较两份报告时,请使用相同的入口、环境和体积口径。 ## 先检查应用 diff --git a/packages/bundler-utoopack/package.json b/packages/bundler-utoopack/package.json index a47cbebb..890f1c60 100644 --- a/packages/bundler-utoopack/package.json +++ b/packages/bundler-utoopack/package.json @@ -13,6 +13,11 @@ "types": "./esm/index.d.ts", "import": "./esm/index.js", "default": "./esm/index.js" + }, + "./_internal/analyze": { + "types": "./esm/analysis/index.d.ts", + "import": "./esm/analysis/index.js", + "default": "./esm/analysis/index.js" } }, "scripts": { diff --git a/packages/bundler-utoopack/src/adapter/adapter.ts b/packages/bundler-utoopack/src/adapter/adapter.ts index b70a2ac6..cd33f6e4 100644 --- a/packages/bundler-utoopack/src/adapter/adapter.ts +++ b/packages/bundler-utoopack/src/adapter/adapter.ts @@ -11,6 +11,7 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import type { BundlerAdapter, + BundlerAnalyzeContext, BundlerBuildContext, BundlerBuildFacts, BundlerBuildFactsDisposition, @@ -145,6 +146,11 @@ export const utoopackAdapter: BundlerAdapter = { return generator.collectBuildFacts(); }, + async analyze(ctx: BundlerAnalyzeContext) { + const { captureUtoopackAnalysis } = await import("./execution/analyze.js"); + return captureUtoopackAnalysis(ctx); + }, + async dev( ctx: BundlerDevContext, ): Promise { diff --git a/packages/bundler-utoopack/src/adapter/execution/analyze.ts b/packages/bundler-utoopack/src/adapter/execution/analyze.ts new file mode 100644 index 00000000..c21e86a8 --- /dev/null +++ b/packages/bundler-utoopack/src/adapter/execution/analyze.ts @@ -0,0 +1,96 @@ +import { createRequire } from "node:module"; +import type { + BundlerAnalyzeContext, + BundlerAnalyzeResult, +} from "@evjs/ev/_internal/build"; +import type { ConfigComplete } from "@utoo/pack"; +import { markUtoopackProcessForBuild } from "../development/dev-process-mode.js"; + +interface AnalyzeReportServer { + url: string; + close(): Promise; +} + +interface AnalyzeExportOptions { + entry?: string; + environment?: "client" | "server"; +} + +interface PackAnalyzer { + analyze( + options: { config: ConfigComplete }, + projectPath: string, + rootPath: undefined, + analyzeOptions: { outputPath?: string }, + ): Promise; + serveAnalyzeReport( + reportPath: string, + port?: number, + ): Promise; +} + +interface PackReportExporter { + exportAnalyzeReport( + reportPath: string, + options?: AnalyzeExportOptions, + ): AsyncIterable>; +} + +const require = createRequire(import.meta.url); + +function unsupportedAnalyzer(): Error { + const { version } = require("@utoo/pack/package.json") as { version: string }; + return new Error( + `[evjs] @utoo/pack@${version} does not support the native Bundle Analyzer. Use an Analyzer-capable Pack version (for example, 1.5.40-alpha.1 for this preview).`, + ); +} + +export async function captureUtoopackAnalysis( + ctx: BundlerAnalyzeContext, +): Promise { + markUtoopackProcessForBuild(); + const runtime = require("@utoo/pack") as Partial; + if (typeof runtime.analyze !== "function") throw unsupportedAnalyzer(); + + const { createUtoopackConfig } = await import("../config/create-config.js"); + const config = await createUtoopackConfig( + ctx.config, + ctx.plan, + ctx.cwd, + ctx.hooks, + ctx.addWatchFile, + ); + return runtime.analyze({ config }, ctx.cwd, undefined, { + outputPath: ctx.outputPath, + }); +} + +export async function serveBundleAnalysisReport( + reportPath: string, + port?: number, +): Promise { + const runtime = require("@utoo/pack") as Partial; + if (typeof runtime.serveAnalyzeReport !== "function") + throw unsupportedAnalyzer(); + return runtime.serveAnalyzeReport(reportPath, port); +} + +export function exportBundleAnalysisReport( + reportPath: string, + options?: AnalyzeExportOptions, +): AsyncIterable> { + // The report-only subpath does not load the native compiler or project config. + let exporter: string; + try { + exporter = require.resolve("@utoo/pack/analyze"); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ERR_PACKAGE_PATH_NOT_EXPORTED" || code === "MODULE_NOT_FOUND") + throw unsupportedAnalyzer(); + throw error; + } + const runtime = require(exporter) as Partial; + if (typeof runtime.exportAnalyzeReport !== "function") + throw unsupportedAnalyzer(); + return runtime.exportAnalyzeReport(reportPath, options); +} diff --git a/packages/bundler-utoopack/src/analysis/index.ts b/packages/bundler-utoopack/src/analysis/index.ts new file mode 100644 index 00000000..4c89d908 --- /dev/null +++ b/packages/bundler-utoopack/src/analysis/index.ts @@ -0,0 +1,4 @@ +export { + exportBundleAnalysisReport, + serveBundleAnalysisReport, +} from "../adapter/execution/analyze.js"; diff --git a/packages/bundler-utoopack/tests/adapter.test.ts b/packages/bundler-utoopack/tests/adapter.test.ts index 0f582ef5..e36159a9 100644 --- a/packages/bundler-utoopack/tests/adapter.test.ts +++ b/packages/bundler-utoopack/tests/adapter.test.ts @@ -32,6 +32,16 @@ const utoopackMock = vi.hoisted(() => ({ omitClientStats: false, deferInitialServerBuild: false, deferInitialClientBuild: false, + analyzerAvailable: false, + analyze: + vi.fn< + ( + options: { config: ConfigComplete }, + projectPath: string, + rootPath: undefined, + analyzeOptions: { outputPath?: string }, + ) => Promise<{ outputPath: string }> + >(), markUtoopackProcessForBuild: vi.fn(), workerClose: vi.fn(async () => {}), startUtoopackDevWorker: vi.fn( @@ -113,6 +123,9 @@ const utoopackMock = vi.hoisted(() => ({ }, ), requireUtoopack: vi.fn(() => ({ + ...(utoopackMock.analyzerAvailable + ? { analyze: utoopackMock.analyze } + : {}), serve: vi.fn(async ({ config }, _projectPath, _rootPath, serverOptions) => { const fs = await import("node:fs"); const path = await import("node:path"); @@ -378,6 +391,8 @@ afterEach(async () => { utoopackMock.omitClientStats = false; utoopackMock.deferInitialServerBuild = false; utoopackMock.deferInitialClientBuild = false; + utoopackMock.analyzerAvailable = false; + utoopackMock.analyze.mockReset(); utoopackMock.workerClose.mockClear(); utoopackMock.startUtoopackDevWorker.mockClear(); utoopackMock.markUtoopackProcessForBuild.mockClear(); @@ -531,6 +546,85 @@ describe("utoopackAdapter build", () => { }); }); +describe("utoopackAdapter analyze", () => { + it("uses the final production configuration without touching existing output", async () => { + const cwd = await makeProject(); + const config = await resolveProjectConfig(cwd, { + routing: { mode: "spa" }, + }); + const { graph } = await createBuildContext(config, cwd); + const plan = createBuildPlan(config, graph, { mode: "production" }); + const stats = path.resolve(cwd, plan.output.clientDir, "stats.json"); + const serverSentinel = path.resolve(cwd, plan.output.serverDir, "keep.txt"); + for (const filename of [stats, serverSentinel]) { + await fs.promises.mkdir(path.dirname(filename), { recursive: true }); + await fs.promises.writeFile(filename, "existing output"); + } + const outputPath = "reports/before"; + const reportPath = path.resolve(cwd, outputPath); + utoopackMock.analyzerAvailable = true; + utoopackMock.analyze.mockResolvedValue({ outputPath: reportPath }); + + await expect( + utoopackAdapter.analyze?.({ + config, + cwd, + plan, + hooks: [ + { + configureBundler(packConfig) { + packConfig.resolve = { + ...packConfig.resolve, + alias: { + ...packConfig.resolve?.alias, + "report-test": "./src/main.tsx", + }, + }; + }, + }, + ], + outputPath, + }), + ).resolves.toEqual({ outputPath: reportPath }); + expect(utoopackMock.analyze).toHaveBeenCalledWith( + { + config: expect.objectContaining({ + mode: "production", + entry: expect.arrayContaining([ + expect.objectContaining({ name: "main" }), + ]), + resolve: expect.objectContaining({ + alias: expect.objectContaining({ "report-test": "./src/main.tsx" }), + }), + }), + }, + cwd, + undefined, + { outputPath }, + ); + expect(await fs.promises.readFile(stats, "utf-8")).toBe("existing output"); + expect(await fs.promises.readFile(serverSentinel, "utf-8")).toBe( + "existing output", + ); + }); + + it("explains when the installed Pack version does not support analysis", async () => { + const cwd = await makeProject(); + const config = await resolveProjectConfig(cwd, { + routing: { mode: "spa" }, + }); + const { graph } = await createBuildContext(config, cwd); + await expect( + utoopackAdapter.analyze?.({ + config, + cwd, + plan: createBuildPlan(config, graph, { mode: "production" }), + hooks: [], + }), + ).rejects.toThrow("does not support the native Bundle Analyzer"); + }); +}); + describe("utoopackAdapter output safety", () => { it("preserves external files when the server output traverses a symlink", async () => { const cwd = await makeProject(); diff --git a/packages/bundler-utoopack/tests/analyze-export.test.ts b/packages/bundler-utoopack/tests/analyze-export.test.ts new file mode 100644 index 00000000..b976e762 --- /dev/null +++ b/packages/bundler-utoopack/tests/analyze-export.test.ts @@ -0,0 +1,45 @@ +import { expect, it, vi } from "vitest"; +import { exportBundleAnalysisReport } from "../src/analysis/index.js"; + +const packMock = vi.hoisted(() => { + const exporterPath = "/mock/pack/commands/exportAnalyzeReport.js"; + const record = { type: "meta", schema_version: 1, selected_entries: 1 }; + const exportAnalyzeReport = vi.fn(async function* () { + yield record; + }); + const load = vi.fn((specifier: string) => { + if (specifier !== exporterPath) { + throw new Error(`Unexpected runtime import: ${specifier}`); + } + return { exportAnalyzeReport }; + }); + const resolve = vi.fn((specifier: string) => { + if (specifier !== "@utoo/pack/analyze") { + throw new Error(`Unexpected runtime resolution: ${specifier}`); + } + return exporterPath; + }); + return { load, resolve, exportAnalyzeReport, record }; +}); + +vi.mock("node:module", async (importOriginal) => ({ + ...(await importOriginal()), + createRequire: () => + Object.assign(packMock.load, { resolve: packMock.resolve }), +})); + +it("streams a saved report through Pack's native-free export entry", async () => { + const records = []; + const filters = { entry: "main", environment: "client" } as const; + for await (const record of exportBundleAnalysisReport( + "reports/before", + filters, + )) { + records.push(record); + } + expect(records).toEqual([packMock.record]); + expect(packMock.exportAnalyzeReport).toHaveBeenCalledWith( + "reports/before", + filters, + ); +}); diff --git a/packages/cli/src/commands/analyze/command.ts b/packages/cli/src/commands/analyze/command.ts new file mode 100644 index 00000000..0b00681d --- /dev/null +++ b/packages/cli/src/commands/analyze/command.ts @@ -0,0 +1,145 @@ +import path from "node:path"; +import type { Config } from "@evjs/ev/config"; +import type { CliFlags } from "@evjs/ev/plugin"; +import { type Command, InvalidArgumentError, Option } from "commander"; +import type { DefaultBundlerConfig } from "../../config/load.js"; +import { parseCliFlags } from "../../program/options.js"; + +interface ExportFilters { + entry?: string; + environment?: "client" | "server"; +} + +interface CaptureOptions { + output?: boolean; + outputDir?: string; + port?: number; +} + +export interface BundleAnalysisActions { + capture( + config: Config | undefined, + options: { cwd: string; flags: CliFlags; outputPath?: string }, + ): Promise<{ outputPath: string }>; + serveReport(reportPath: string, port?: number): Promise<{ url: string }>; + exportReport( + reportPath: string, + filters: ExportFilters, + ): AsyncIterable>; + writeJsonLine(line: string): void | Promise; +} + +interface AnalyzeCommandDependencies { + analysis: BundleAnalysisActions; + cwd(): string; + loadConfig(cwd: string): Promise | undefined>; + writeStdout(output: string): void; +} + +function parsePort(value: string): number { + const port = Number(value); + if (!/^\d+$/.test(value) || !Number.isInteger(port) || port > 65535) { + throw new InvalidArgumentError( + "Analyzer port must be an integer between 0 and 65535.", + ); + } + return port; +} + +export function registerAnalyzeCommand( + program: Command, + dependencies: AnalyzeCommandDependencies, + runCommand: (summary: string, command: () => Promise) => Promise, +): void { + const analyze = program + .command("analyze") + .description("Analyze production bundles with Utoopack") + .enablePositionalOptions() + .allowUnknownOption(true) + .option("-o, --output", "Write the report without starting a server") + .option( + "--output-dir ", + "Report directory relative to the project (default: .utoo/analyze)", + ) + .option( + "--port ", + "Local report server port (default: 4000)", + parsePort, + ) + .action(async (options: CaptureOptions, command: Command) => { + await runCommand("Analysis failed", async () => { + const cwd = dependencies.cwd(); + const flags = parseCliFlags(command.args); + const config = await dependencies.loadConfig(cwd); + const report = await dependencies.analysis.capture(config, { + cwd, + flags, + outputPath: options.outputDir, + }); + dependencies.writeStdout( + `Analyzer report written to ${report.outputPath}\n`, + ); + if (!options.output) { + const server = await dependencies.analysis.serveReport( + report.outputPath, + options.port, + ); + dependencies.writeStdout( + `Bundle Analyzer available at ${server.url}\n`, + ); + } + }); + }); + + analyze + .command("export") + .description("Export a saved report as JSONL without compiling") + .allowExcessArguments(false) + .allowUnknownOption(false) + .option( + "--report ", + "Saved report directory, relative to the current directory", + ".utoo/analyze", + ) + .option("--entry ", "Filter by entry name or /entry-N ID") + .addOption( + new Option( + "--environment ", + "Filter by entry environment", + ).choices(["client", "server"]), + ) + .action( + async (options: ExportFilters & { report: string }, command: Command) => { + // Commander can parse options owned by the parent before dispatching + // the subcommand. Do not silently ignore capture options on export. + const capture = analyze.opts(); + if ( + capture.output || + capture.outputDir !== undefined || + capture.port !== undefined + ) { + command.error( + "error: --output, --output-dir and --port are only supported by ev analyze (capture).", + ); + } + await runCommand("Analysis export failed", async () => { + const reportPath = path.resolve(dependencies.cwd(), options.report); + try { + for await (const record of dependencies.analysis.exportReport( + reportPath, + { entry: options.entry, environment: options.environment }, + )) { + await dependencies.analysis.writeJsonLine( + `${JSON.stringify(record)}\n`, + ); + } + } catch (error) { + // A pipe consumer such as `head` can stop reading early. + if ((error as NodeJS.ErrnoException | null)?.code !== "EPIPE") { + throw error; + } + } + }); + }, + ); +} diff --git a/packages/cli/src/commands/analyze/runtime.ts b/packages/cli/src/commands/analyze/runtime.ts new file mode 100644 index 00000000..6647b493 --- /dev/null +++ b/packages/cli/src/commands/analyze/runtime.ts @@ -0,0 +1,46 @@ +import type { BundleAnalysisActions } from "./command.js"; + +export const bundleAnalysisActions: BundleAnalysisActions = { + async capture(config, options) { + const [{ analyzeFrameworkBuild }, { utoopackAdapter }] = await Promise.all([ + import("@evjs/ev/_internal/build/analyze"), + import("@evjs/bundler-utoopack"), + ]); + return analyzeFrameworkBuild(config, { + ...options, + bundler: config?.bundler ?? utoopackAdapter, + }); + }, + + async serveReport(reportPath, port) { + const { serveBundleAnalysisReport } = await import( + "@evjs/bundler-utoopack/_internal/analyze" + ); + return serveBundleAnalysisReport(reportPath, port); + }, + + async *exportReport(reportPath, filters) { + const { exportBundleAnalysisReport } = await import( + "@evjs/bundler-utoopack/_internal/analyze" + ); + yield* exportBundleAnalysisReport(reportPath, filters); + }, + + writeJsonLine(line) { + const { stdout } = process; + return new Promise((resolve, reject) => { + // Wait for the write callback so a large report does not accumulate in + // stdout's buffer. A failed write also emits an error on the stream. + const onError = (error: Error) => reject(error); + stdout.once("error", onError); + stdout.write(line, (error) => { + if (error) { + reject(error); + } else { + stdout.off("error", onError); + resolve(); + } + }); + }); + }, +}; diff --git a/packages/cli/src/program/command-program.ts b/packages/cli/src/program/command-program.ts index 6b6a3d55..83d2843b 100644 --- a/packages/cli/src/program/command-program.ts +++ b/packages/cli/src/program/command-program.ts @@ -1,6 +1,10 @@ import type { Config } from "@evjs/ev/config"; import type { CliFlags } from "@evjs/ev/plugin"; import { Command, CommanderError } from "commander"; +import { + type BundleAnalysisActions, + registerAnalyzeCommand, +} from "../commands/analyze/command.js"; import { formatInspectCommandErrorJson, type InspectCommandOptions, @@ -39,6 +43,7 @@ export interface CliProgramDependencies { loadConfig: ConfigLoader; dev: DevFrameworkCommand; build: FrameworkCommand; + analysis: BundleAnalysisActions; prepare: FrameworkCommand; inspect(options: InspectCommandOptions): Promise; writeStdout(output: string): void; @@ -115,6 +120,8 @@ export async function runCliProgram( }); }); + registerAnalyzeCommand(program, dependencies, runCommand); + program .command("prepare") .description("Generate .ev framework IR without running a bundler") diff --git a/packages/cli/src/program/run.ts b/packages/cli/src/program/run.ts index 2877f001..6523e03f 100644 --- a/packages/cli/src/program/run.ts +++ b/packages/cli/src/program/run.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { configure, getConsoleSink, getLogger } from "@logtape/logtape"; import { build, dev, prepare } from "../api/framework-commands.js"; +import { bundleAnalysisActions } from "../commands/analyze/runtime.js"; import { runInspectCommand } from "../commands/inspect/command.js"; import { type DefaultBundlerConfig, loadConfig } from "../config/load.js"; import { runCliProgram } from "./command-program.js"; @@ -62,6 +63,7 @@ export async function runCli( loadConfig(cwd, options), dev, build, + analysis: bundleAnalysisActions, prepare, inspect: runInspectCommand, writeStdout: (output) => process.stdout.write(output), diff --git a/packages/cli/tests/cli.test.ts b/packages/cli/tests/cli.test.ts index e01660a2..4b83d7ab 100644 --- a/packages/cli/tests/cli.test.ts +++ b/packages/cli/tests/cli.test.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import type { Config } from "@evjs/ev/config"; import type { CliFlags } from "@evjs/ev/plugin"; import { describe, expect, it, vi } from "vitest"; @@ -21,6 +22,14 @@ function createDependencies(overrides: Partial = {}) { loadConfig: async () => undefined, dev: command, build: command, + analysis: { + capture: async () => ({ outputPath: "/project/.utoo/analyze" }), + serveReport: async () => ({ url: "http://127.0.0.1:4000" }), + async *exportReport() {}, + writeJsonLine(line) { + stdout.push(line); + }, + }, prepare: command, inspect: async () => ({ exitCode: 0, output: "inspect output\n" }), writeStdout: (output) => stdout.push(output), @@ -173,3 +182,118 @@ describe("CLI execution", () => { expect(errors).toEqual([]); }); }); + +describe("CLI bundle analysis", () => { + it("writes a static report with production plugin flags and no server", async () => { + const { dependencies, stdout } = createDependencies(); + const capture = vi.spyOn(dependencies.analysis, "capture"); + const serveReport = vi.spyOn(dependencies.analysis, "serveReport"); + + await expect( + runCliProgram( + [ + "node", + "ev", + "analyze", + "--output", + "--output-dir", + "reports/before", + "--profile", + "export", + "--include-mock=false", + ], + dependencies, + ), + ).resolves.toBe(0); + + expect(capture).toHaveBeenCalledWith(undefined, { + cwd: "/project", + flags: { profile: "export", includeMock: false }, + outputPath: "reports/before", + }); + expect(serveReport).not.toHaveBeenCalled(); + expect(stdout).toEqual([ + "Analyzer report written to /project/.utoo/analyze\n", + ]); + }); + + it("serves the captured report on the requested port", async () => { + const { dependencies, stdout } = createDependencies(); + const serveReport = vi.spyOn(dependencies.analysis, "serveReport"); + + await expect( + runCliProgram(["node", "ev", "analyze", "--port", "0"], dependencies), + ).resolves.toBe(0); + + expect(serveReport).toHaveBeenCalledWith("/project/.utoo/analyze", 0); + expect(stdout.join("")).toContain( + "Bundle Analyzer available at http://127.0.0.1:4000", + ); + }); + + it("streams JSONL from a saved report without loading project config", async () => { + const loadConfig = vi.fn(async () => { + throw new Error("Project config should not load for export"); + }); + const { dependencies, stdout } = createDependencies({ loadConfig }); + const capture = vi.spyOn(dependencies.analysis, "capture"); + const exportReport = vi + .spyOn(dependencies.analysis, "exportReport") + .mockImplementation(async function* () { + yield { type: "meta", version: 1 }; + expect(stdout).toHaveLength(1); + yield { type: "entry", name: "api" }; + }); + + await expect( + runCliProgram( + [ + "node", + "ev", + "analyze", + "export", + "--report", + "reports/before", + "--entry", + "api", + "--environment", + "server", + ], + dependencies, + ), + ).resolves.toBe(0); + + expect(loadConfig).not.toHaveBeenCalled(); + expect(capture).not.toHaveBeenCalled(); + expect(exportReport).toHaveBeenCalledWith( + path.resolve("/project", "reports/before"), + { entry: "api", environment: "server" }, + ); + expect(stdout).toEqual([ + '{"type":"meta","version":1}\n', + '{"type":"entry","name":"api"}\n', + ]); + }); + + it.each<[string[], string]>([ + [["--port", "65536"], "Analyzer port must be an integer"], + [ + ["export", "--environment", "browser"], + "Allowed choices are client, server", + ], + [["export", "--output"], "only supported by ev analyze (capture)"], + ])("rejects invalid analysis flags %j before starting work", async (args, diagnostic) => { + const loadConfig = vi.fn(async () => undefined); + const { dependencies, stderr, stdout } = createDependencies({ loadConfig }); + const exportReport = vi.spyOn(dependencies.analysis, "exportReport"); + + await expect( + runCliProgram(["node", "ev", "analyze", ...args], dependencies), + ).resolves.toBe(1); + + expect(loadConfig).not.toHaveBeenCalled(); + expect(exportReport).not.toHaveBeenCalled(); + expect(stdout).toEqual([]); + expect(stderr.join("")).toContain(diagnostic); + }); +}); diff --git a/packages/ev/package.json b/packages/ev/package.json index 5a5d055e..d7ae9a8a 100644 --- a/packages/ev/package.json +++ b/packages/ev/package.json @@ -74,6 +74,11 @@ "import": "./esm/_internal/build/index.js", "default": "./esm/_internal/build/index.js" }, + "./_internal/build/analyze": { + "types": "./esm/_internal/build/operations/analyze.d.ts", + "import": "./esm/_internal/build/operations/analyze.js", + "default": "./esm/_internal/build/operations/analyze.js" + }, "./_internal/client/api": { "types": "./esm/_internal/generated/client/api.d.ts", "import": "./esm/_internal/generated/client/api.js", diff --git a/packages/ev/src/_internal/build/bundler/contracts.ts b/packages/ev/src/_internal/build/bundler/contracts.ts index 05d534a6..a36aa73c 100644 --- a/packages/ev/src/_internal/build/bundler/contracts.ts +++ b/packages/ev/src/_internal/build/bundler/contracts.ts @@ -193,6 +193,15 @@ export interface BundlerBuildContext { addWatchFile?(file: string): void; } +export interface BundlerAnalyzeContext + extends BundlerBuildContext { + outputPath?: string; +} + +export interface BundlerAnalyzeResult { + outputPath: string; +} + export interface BundlerDevContext extends BundlerBuildContext { /** Aborted when the immutable framework development session is closing. */ @@ -265,6 +274,11 @@ export interface BundlerAdapter { */ build(ctx: BundlerBuildContext): Promise; + /** Analyze the production plan without emitting application build output. */ + analyze?( + ctx: BundlerAnalyzeContext, + ): Promise; + /** * Start a development server. * diff --git a/packages/ev/src/_internal/build/dev/runtime.ts b/packages/ev/src/_internal/build/dev/runtime.ts index 75945965..17c34f63 100644 --- a/packages/ev/src/_internal/build/dev/runtime.ts +++ b/packages/ev/src/_internal/build/dev/runtime.ts @@ -44,7 +44,7 @@ interface DevPortLock extends DevRuntimeLock { role: "client" | "server"; } -export type ProjectOperation = "build" | "dev" | "prepare"; +export type ProjectOperation = "analyze" | "build" | "dev" | "prepare"; interface ProjectOperationLock extends DevRuntimeLock { operation: ProjectOperation; @@ -669,7 +669,10 @@ export async function acquireProjectOperationLock( }); } -export async function assertNoActiveDevSessionLock(cwd: string): Promise { +export async function assertNoActiveDevSessionLock( + cwd: string, + operation: "build" | "analyze" = "build", +): Promise { const normalizedCwd = await normalizeProjectPath(cwd); const lockPath = getDevSessionLockPath(normalizedCwd); for (let attempt = 0; attempt < 3; attempt++) { @@ -678,20 +681,20 @@ export async function assertNoActiveDevSessionLock(cwd: string): Promise { if (!lock) { if (await removeAbandonedRuntimeLock(lockPath)) continue; throw new Error( - `[evjs] Cannot build "${normalizedCwd}" while an ev dev session is initializing. Stop ev dev first or build in a separate workspace.`, + `[evjs] Cannot ${operation} "${normalizedCwd}" while an ev dev session is initializing. Stop ev dev first or ${operation} in a separate workspace.`, ); } if (isProcessAlive(lock.pid)) { throw new Error( - `[evjs] Cannot build "${normalizedCwd}" because ev dev is running in process ${lock.pid}. Stop ev dev first or build in a separate workspace.`, + `[evjs] Cannot ${operation} "${normalizedCwd}" because ev dev is running in process ${lock.pid}. Stop ev dev first or ${operation} in a separate workspace.`, ); } if (await removeRuntimeLock(lockPath, lock.token)) return; } throw new Error( - `[evjs] Cannot build "${normalizedCwd}" because its dev session lock changed while being checked. Stop ev dev first or build in a separate workspace.`, + `[evjs] Cannot ${operation} "${normalizedCwd}" because its dev session lock changed while being checked. Stop ev dev first or ${operation} in a separate workspace.`, ); } diff --git a/packages/ev/src/_internal/build/index.ts b/packages/ev/src/_internal/build/index.ts index fb4a31aa..8afa0495 100644 --- a/packages/ev/src/_internal/build/index.ts +++ b/packages/ev/src/_internal/build/index.ts @@ -18,6 +18,8 @@ export { } from "./bundler/client-compatibility.js"; export { type BundlerAdapter, + type BundlerAnalyzeContext, + type BundlerAnalyzeResult, type BundlerBuildCapability, type BundlerBuildContext, type BundlerBuildFacts, diff --git a/packages/ev/src/_internal/build/operations/analyze.ts b/packages/ev/src/_internal/build/operations/analyze.ts new file mode 100644 index 00000000..5e4170be --- /dev/null +++ b/packages/ev/src/_internal/build/operations/analyze.ts @@ -0,0 +1,4 @@ +export { + type AnalyzeFrameworkBuildOptions, + analyzeFrameworkBuild, +} from "./commands.js"; diff --git a/packages/ev/src/_internal/build/operations/commands.ts b/packages/ev/src/_internal/build/operations/commands.ts index 4feb79f6..773d2e82 100644 --- a/packages/ev/src/_internal/build/operations/commands.ts +++ b/packages/ev/src/_internal/build/operations/commands.ts @@ -15,6 +15,7 @@ import type { import { withActiveBundler } from "../bundler/config.js"; import type { BundlerAdapter, + BundlerAnalyzeResult, BundlerBuildFacts, } from "../bundler/contracts.js"; import { preflightBundlerBuild } from "../bundler/contracts.js"; @@ -38,6 +39,7 @@ import { linkAndEmitBuildOutput, validateHtmlTemplates, } from "../output/framework-output.js"; +import { assertAnalysisOutputOutsideDist } from "../output/output-path-safety.js"; import { createProductionOutputTransaction } from "../output/production-output-transaction.js"; import type { CreateBuildPlanOptions } from "../plan/types.js"; import { @@ -166,6 +168,11 @@ export interface BuildOptions { flags?: CliFlags; } +export interface AnalyzeFrameworkBuildOptions + extends BuildOptions { + outputPath?: string; +} + export interface PrepareFrameworkBuildOptions { cwd?: string; flags?: CliFlags; @@ -408,6 +415,56 @@ export async function build( ); } +export async function analyzeFrameworkBuild( + userConfig?: Config, + options?: AnalyzeFrameworkBuildOptions, +): Promise { + const cwd = options?.cwd ?? process.cwd(); + await assertNoActiveDevSessionLock(cwd, "analyze"); + return withProjectOperationLock(cwd, "analyze", async () => { + process.env.NODE_ENV ??= "production"; + const prepared = await prepareInternalFrameworkBuild(userConfig, { + cwd, + mode: "production", + bundler: options?.bundler, + flags: options?.flags, + requireBundler: true, + }); + let result: BundlerAnalyzeResult; + try { + const bundler = prepared.config.bundler; + if (!bundler?.analyze) { + throw new Error( + `[evjs] The "${bundler?.name ?? "configured"}" bundler does not support ev analyze.`, + ); + } + preflightBundlerBuild(bundler, prepared.plan); + const outputPath = options?.outputPath ?? ".utoo/analyze"; + await assertAnalysisOutputOutsideDist( + cwd, + prepared.plan.distDir, + outputPath, + ); + result = await bundler.analyze({ + cwd, + config: prepared.config, + plan: prepared.plan, + hooks: prepared.hooks, + addWatchFile: prepared.pluginContext.addWatchFile, + outputPath, + }); + } catch (error) { + return rethrowAfterCleanup( + error, + prepared.dispose, + "[evjs] Analysis failed and plugin cleanup also failed.", + ); + } + await prepared.dispose(); + return result; + }); +} + async function runBuild( userConfig: Config | undefined, options: BuildOptions | undefined, diff --git a/packages/ev/src/_internal/build/output/output-path-safety.ts b/packages/ev/src/_internal/build/output/output-path-safety.ts index 6ae04729..7ab1cb81 100644 --- a/packages/ev/src/_internal/build/output/output-path-safety.ts +++ b/packages/ev/src/_internal/build/output/output-path-safety.ts @@ -8,6 +8,38 @@ export interface ResolvedBuildOutputPaths { serverDir: string; } +/** Keep Analyzer reports separate from canonical framework build output. */ +export async function assertAnalysisOutputOutsideDist( + cwd: string, + distDir: string, + outputPath: string, +): Promise { + const dist = path.resolve(cwd, distDir); + const report = path.resolve(cwd, outputPath); + const overlaps = (a: string, b: string) => + isSameOrDescendantPath(path.relative(a, b)) || + isSameOrDescendantPath(path.relative(b, a)); + const [realDist, realReport] = await Promise.all([ + resolveOutputPath(dist), + resolveOutputPath(report), + ]); + if (overlaps(dist, report) || overlaps(realDist, realReport)) { + throw new Error( + `[evjs] Analyzer output directory "${outputPath}" must not overlap build output "${distDir}".`, + ); + } +} + +async function resolveOutputPath(absolute: string): Promise { + try { + return await fs.realpath(absolute); + } catch (error) { + const parent = path.dirname(absolute); + if (!isMissingPathError(error) || parent === absolute) throw error; + return path.join(await resolveOutputPath(parent), path.basename(absolute)); + } +} + /** * Fail closed before a bundler recursively cleans framework-owned output. * diff --git a/packages/ev/src/config/resolution.ts b/packages/ev/src/config/resolution.ts index 27b71703..6b483b45 100644 --- a/packages/ev/src/config/resolution.ts +++ b/packages/ev/src/config/resolution.ts @@ -681,6 +681,7 @@ const PUBLIC_BUNDLER_CONFIG_KEYS = new Set([ "capabilities", "build", "dev", + "analyze", ]); const PUBLIC_BUNDLER_CAPABILITY_KEYS = new Set(["build", "dev"]); const PUBLIC_BUNDLER_BUILD_CAPABILITY_KEYS = new Set(["server", "rsc", "ppr"]); @@ -1041,6 +1042,12 @@ function assertBundlerAdapter( bundlerConfig.dev, `${path}.dev`, ); + if (bundlerConfig.analyze !== undefined) { + assertFunction["analyze"]>>( + bundlerConfig.analyze, + `${path}.analyze`, + ); + } } function validateBundlerConfigKeys( @@ -1051,7 +1058,7 @@ function validateBundlerConfigKeys( bundler, PUBLIC_BUNDLER_CONFIG_KEYS, path, - "name, capabilities, build, or dev", + "name, capabilities, build, dev, or analyze", ); } diff --git a/packages/ev/tests/commands.test.ts b/packages/ev/tests/commands.test.ts index e67fc731..353001e1 100644 --- a/packages/ev/tests/commands.test.ts +++ b/packages/ev/tests/commands.test.ts @@ -28,10 +28,12 @@ import { createRscPageBuildEntryName, } from "../src/_internal/build/conventions/build-entry-conventions.js"; import { PAGE_ANCHOR_ROUTE_CONVENTION_SUMMARY } from "../src/_internal/build/conventions/page-route-conventions.js"; +import { acquireDevSessionLock } from "../src/_internal/build/dev/runtime.js"; import { prepareFrameworkIR, publishFrameworkIR, } from "../src/_internal/build/generated-ir/generated-contributions.js"; +import { analyzeFrameworkBuild } from "../src/_internal/build/operations/analyze.js"; import { PAGE_ROUTE_TYPES_MARKER, PAGE_ROUTE_TYPES_USAGE_HINT, @@ -3634,6 +3636,142 @@ describe("prepareFrameworkBuild", () => { }); }); +describe("analyzeFrameworkBuild", () => { + it("analyzes generated production entries without changing build output or running build hooks", async () => { + const cwd = await createServerOutputProject(); + const events: string[] = []; + const dist = path.join(cwd, "dist"); + const outputPath = "reports/bundle-before"; + await writeFile(path.join(dist, "client", "stats.json"), '{"prior":true}'); + await writeFile(path.join(dist, "server", "main.js"), "existing bundle"); + const before = await readDirectorySnapshot(dist); + + const bundler: BundlerAdapter> = { + ...createMockBundler(events), + async analyze({ cwd, plan, hooks, outputPath }) { + events.push("bundler.analyze"); + expect(plan.mode).toBe("production"); + expect(plan.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ environment: "client" }), + expect.objectContaining({ environment: "server" }), + ]), + ); + const { plan: generatedPlan } = JSON.parse( + await fs.promises.readFile( + path.join(cwd, ".ev/framework/build-plan.json"), + "utf-8", + ), + ) as { plan: BuildPlan }; + expect(generatedPlan.entries).toEqual(plan.entries); + for (const entry of plan.entries) { + await fs.promises.access(path.resolve(cwd, entry.import)); + } + expect(hooks.some((hook) => hook.configureBundler)).toBe(true); + const reportDir = path.resolve(cwd, outputPath ?? ".utoo/analyze"); + await writeFile(path.join(reportDir, "index.html"), "Analysis report"); + return { outputPath: reportDir }; + }, + }; + + const report = await analyzeFrameworkBuild( + { + bundler, + routing: { mode: "spa" }, + plugins: [ + { + id: "analysis-lifecycle", + setup(ctx) { + events.push(`setup:${ctx.mode}:${ctx.flags?.profile}`); + return { + configureBundler() {}, + beforeBuild() { + events.push("beforeBuild"); + }, + afterBuild() { + events.push("afterBuild"); + }, + dispose() { + events.push("dispose"); + }, + }; + }, + }, + ], + }, + { cwd, flags: { profile: "preview" }, outputPath }, + ); + + expect(report.outputPath).toBe(path.join(cwd, outputPath)); + await expect( + fs.promises.readFile(path.join(report.outputPath, "index.html"), "utf-8"), + ).resolves.toBe("Analysis report"); + expect(events).toEqual([ + "setup:production:preview", + "bundler.analyze", + "dispose", + ]); + expect(await readDirectorySnapshot(dist)).toEqual(before); + }); + + it("reports unsupported adapters and disposes prepared hooks", async () => { + const cwd = await createSpaProject(); + const events: string[] = []; + await expect( + analyzeFrameworkBuild( + { + routing: { mode: "spa" }, + plugins: [ + { + id: "analysis-cleanup", + setup() { + return { + dispose() { + events.push("dispose"); + }, + }; + }, + }, + ], + }, + { cwd, bundler: createMockBundler(events) }, + ), + ).rejects.toThrow('The "mock" bundler does not support ev analyze'); + expect(events).toEqual(["dispose"]); + }); + + it.each([ + "dist", + "dist/report", + ".", + ])("rejects a report at %s that overlaps canonical build output", async (outputPath) => { + const cwd = await createSpaProject(); + const analyze = vi.fn(); + await expect( + analyzeFrameworkBuild( + { routing: { mode: "spa" } }, + { cwd, bundler: { ...createMockBundler([]), analyze }, outputPath }, + ), + ).rejects.toThrow(/Analyzer output directory .* must not overlap/); + expect(analyze).not.toHaveBeenCalled(); + }); + + it("rejects analysis while dev owns the project's generated entries", async () => { + const cwd = await createSpaProject(); + const release = await acquireDevSessionLock(cwd); + try { + await expect(analyzeFrameworkBuild(undefined, { cwd })).rejects.toThrow( + /Cannot analyze .* because ev dev is running/, + ); + await expect(fs.promises.access(path.join(cwd, ".ev"))).rejects.toThrow( + "ENOENT", + ); + } finally { + await release(); + } + }); +}); + describe("build", () => { it("serializes concurrent project builds before a second bundler starts", async () => { const cwd = await createSpaProject(); diff --git a/packages/ev/tests/package-surface.test.ts b/packages/ev/tests/package-surface.test.ts index e7e4ca0f..75622e5a 100644 --- a/packages/ev/tests/package-surface.test.ts +++ b/packages/ev/tests/package-surface.test.ts @@ -7,6 +7,7 @@ import { promisify } from "node:util"; import * as serverRuntime from "@evjs/server"; import { describe, expect, expectTypeOf, it } from "vitest"; import * as buildTools from "../src/_internal/build/index.js"; +import * as analyzeTools from "../src/_internal/build/operations/analyze.js"; import * as apiAuthoring from "../src/api/index.js"; import * as publicBuildTools from "../src/build-tools/index.js"; import * as evRoot from "../src/index.js"; @@ -341,6 +342,7 @@ const expectedPackageExportSubpaths = { "@evjs/ev": [ ".", "./_internal/build", + "./_internal/build/analyze", "./_internal/client", "./_internal/client/api", "./_internal/client/api-transport", @@ -384,7 +386,7 @@ const expectedPackageExportSubpaths = { "@evjs/cli": ["."], "@evjs/create-app": ["."], "@evjs/plugin-qiankun": [".", "./runtime"], - "@evjs/bundler-utoopack": ["."], + "@evjs/bundler-utoopack": [".", "./_internal/analyze"], "@evjs/bundler-webpack": ["."], "@evjs/shared": [".", "./_internal/static-json", "./manifest"], } as const satisfies Record; @@ -764,6 +766,16 @@ describe("workspace package surface", () => { ); }); + it("exposes bundle analysis through its own narrow CLI subpath", async () => { + const evPackageJson = await readPackageJson("ev"); + expect(evPackageJson.exports?.["./_internal/build/analyze"]).toEqual({ + types: "./esm/_internal/build/operations/analyze.d.ts", + import: "./esm/_internal/build/operations/analyze.js", + default: "./esm/_internal/build/operations/analyze.js", + }); + expect(Object.keys(analyzeTools)).toEqual(["analyzeFrameworkBuild"]); + }); + it("keeps internal build implementations grouped by capability", async () => { const buildRoot = path.join(repoRoot, "packages/ev/src/_internal/build"); const entries = await fs.readdir(buildRoot, { withFileTypes: true });