Skip to content
Draft
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
27 changes: 25 additions & 2 deletions docs/docs/build.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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` 筛选报告入口。
比较两份报告时,请使用相同的入口、环境和体积口径。

## 先检查应用

Expand Down
5 changes: 5 additions & 0 deletions packages/bundler-utoopack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
6 changes: 6 additions & 0 deletions packages/bundler-utoopack/src/adapter/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import fs from "node:fs";
import { createRequire } from "node:module";
import type {
BundlerAdapter,
BundlerAnalyzeContext,
BundlerBuildContext,
BundlerBuildFacts,
BundlerBuildFactsDisposition,
Expand Down Expand Up @@ -145,6 +146,11 @@ export const utoopackAdapter: BundlerAdapter<ConfigComplete> = {
return generator.collectBuildFacts();
},

async analyze(ctx: BundlerAnalyzeContext<ConfigComplete>) {
const { captureUtoopackAnalysis } = await import("./execution/analyze.js");
return captureUtoopackAnalysis(ctx);
},

async dev(
ctx: BundlerDevContext<ConfigComplete>,
): Promise<BundlerDevController> {
Expand Down
96 changes: 96 additions & 0 deletions packages/bundler-utoopack/src/adapter/execution/analyze.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}

interface AnalyzeExportOptions {
entry?: string;
environment?: "client" | "server";
}

interface PackAnalyzer {
analyze(
options: { config: ConfigComplete },
projectPath: string,
rootPath: undefined,
analyzeOptions: { outputPath?: string },
): Promise<BundlerAnalyzeResult>;
serveAnalyzeReport(
reportPath: string,
port?: number,
): Promise<AnalyzeReportServer>;
}

interface PackReportExporter {
exportAnalyzeReport(
reportPath: string,
options?: AnalyzeExportOptions,
): AsyncIterable<Record<string, unknown>>;
}

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<ConfigComplete>,
): Promise<BundlerAnalyzeResult> {
markUtoopackProcessForBuild();
const runtime = require("@utoo/pack") as Partial<PackAnalyzer>;
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<AnalyzeReportServer> {
const runtime = require("@utoo/pack") as Partial<PackAnalyzer>;
if (typeof runtime.serveAnalyzeReport !== "function")
throw unsupportedAnalyzer();
return runtime.serveAnalyzeReport(reportPath, port);
}

export function exportBundleAnalysisReport(
reportPath: string,
options?: AnalyzeExportOptions,
): AsyncIterable<Record<string, unknown>> {
// 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<PackReportExporter>;
if (typeof runtime.exportAnalyzeReport !== "function")
throw unsupportedAnalyzer();
return runtime.exportAnalyzeReport(reportPath, options);
}
4 changes: 4 additions & 0 deletions packages/bundler-utoopack/src/analysis/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export {
exportBundleAnalysisReport,
serveBundleAnalysisReport,
} from "../adapter/execution/analyze.js";
94 changes: 94 additions & 0 deletions packages/bundler-utoopack/tests/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
45 changes: 45 additions & 0 deletions packages/bundler-utoopack/tests/analyze-export.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("node:module")>()),
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,
);
});
Loading
Loading