/** * Bundle report generation for the Effect bundle-size tooling. * * The reporter coordinates fixture discovery with the Rollup service to turn * measured fixture bundles into Markdown tables or visualization output. It is * used by the bundle CLI to compare the current workspace against a checked-out * base directory, to print a one-off report for selected entry files, and to * generate visualizations when a size change needs inspection. * * Reports compare files by basename and display gzipped Rollup output sizes in * decimal kilobytes. Base fixtures are bundled only when the matching file * exists; if a current fixture has no matching basename in the base directory it * is reported as unchanged. Visualization artifacts are named from entry file * stems in the requested output directory, so duplicate names can make the * output misleading. * * @since 4.0.0 */ import * as Context from "effect/Context" import * as Data from "effect/Data" import * as Effect from "effect/Effect" import * as FileSystem from "effect/FileSystem" import { constFalse } from "effect/Function" import * as Layer from "effect/Layer" import * as Path from "effect/Path" import { fileURLToPath } from "node:url" import { Fixtures } from "./Fixtures.ts" import type { BundleStats } from "./Rollup.ts" import { Rollup } from "./Rollup.ts" /** * Error raised when generating a bundle size report or visualization fails. * * @category errors * @since 4.0.0 */ export class ReporterError extends Data.TaggedError("ReporterError")<{ readonly cause: unknown }> {} /** * Options for generating a bundle size comparison report against fixture files from a base directory. * * @category options * @since 4.0.0 */ export interface ReportOptions { readonly baseDirectory: string } /** * Options for generating bundle visualizations for selected entry files into an output directory. * * @category options * @since 4.0.0 */ export interface VisualizeOptions { readonly paths: ReadonlyArray readonly outputDirectory: string } /** * Options for generating a bundle size report for an explicit list of entry files. * * @category options * @since 4.0.0 */ export interface ReportSelectedOptions { readonly paths: ReadonlyArray } /** * Options for generating a bundle size comparison report for explicit entry files against a base checkout. * * @category options * @since 4.0.0 */ export interface ReportSelectedComparisonOptions { readonly baseDirectory: string readonly paths: ReadonlyArray } /** * Context service for producing bundle size reports and visualizations from Rollup-generated fixture stats. * * @category services * @since 4.0.0 */ export class Reporter extends Context.Service()( "@effect/bundle/Reporter", { make: Effect.gen(function*() { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path const { fixtures, fixturesDir } = yield* Fixtures const rollup = yield* Rollup const currentDirectory = path.resolve(fileURLToPath(new URL("../../../../", import.meta.url))) const calculateDifference = (current: BundleStats, previous: BundleStats) => { const currSize = current.sizeInBytes const prevSize = previous.sizeInBytes const diff = currSize - prevSize const diffPct = prevSize === 0 ? 0 : (Math.abs(diff) / prevSize) * 100 const currKb = (currSize / 1000).toFixed(2) const prevKb = (prevSize / 1000).toFixed(2) const diffKb = (Math.abs(diff) / 1000).toFixed(2) const filename = path.basename(current.path) return { diff, diffPct, currKb, prevKb, diffKb, filename } } const createComparisonReport = ( entries: ReadonlyArray<{ readonly current: BundleStats readonly previous: BundleStats readonly filename: string }> ): string => { const lines: Array = [ "| File Name | Current Size | Previous Size | Difference |", "|:----------|:------------:|:-------------:|:----------:|" ] for (const { current, previous, filename } of entries) { const comparison = calculateDifference(current, previous) const currKb = `${comparison.currKb} KB` const prevKb = `${comparison.prevKb} KB` const diffKb = `${comparison.diffKb} KB` const diffPct = `${comparison.diffPct.toFixed(2)}%` const sign = comparison.diff === 0 ? "" : comparison.diff > 0 ? "+" : "-" const line = `| \`${filename}\` | ${currKb} | ${prevKb} | ${sign}${diffKb} (${sign}${diffPct}) |` lines.push(line) } return lines.join("\n") + "\n" } const createReport = (curr: ReadonlyArray, prev: ReadonlyArray): string => { const entries: Array<{ readonly current: BundleStats readonly previous: BundleStats readonly filename: string }> = [] for (const current of curr) { const previous = prev.find((previous) => { return path.basename(previous.path) === path.basename(current.path) }) ?? current entries.push({ current, previous, filename: path.basename(current.path) }) } return createComparisonReport(entries) } const createSelectedReport = (stats: ReadonlyArray): string => { const lines: Array = [ "| File Name | Current Size |", "|:----------|:------------:|" ] for (const current of stats) { const filename = `\`${path.basename(current.path)}\`` const currKb = `${(current.sizeInBytes / 1000).toFixed(2)} KB` const line = `| ${filename} | ${currKb} |` lines.push(line) } return lines.join("\n") + "\n" } const createVisualizationReport = (paths: ReadonlyArray, outputDirectory: string): string => { const lines: Array = [ "| File Name | Generated Bundle | Treemap | Raw Data |", "|:----------|:----------------|:--------|:---------|" ] for (const entryPath of paths) { const name = path.parse(entryPath).name const filename = path.relative(currentDirectory, path.resolve(entryPath)) const minified = path.join(outputDirectory, `${name}.min.js`) const treemap = path.join(outputDirectory, `${name}.treemap.html`) const rawData = path.join(outputDirectory, `${name}.raw-data.json`) lines.push(`| \`${filename}\` | \`${minified}\` | \`${treemap}\` | \`${rawData}\` |`) } return lines.join("\n") + "\n" } const report = Effect.fn("Reporter.report")( function*(options: ReportOptions) { yield* Effect.logInfo(`Found ${fixtures.length} files to bundle`) const currentPaths = fixtures.map((fixture) => path.join(fixturesDir, fixture)) const previousPaths = yield* Effect.filter( fixtures.map((fixture) => path.join(options.baseDirectory, fixture)), (previousPath) => fs.exists(previousPath).pipe(Effect.orElseSucceed(constFalse)), { concurrency: fixtures.length } ) const [currentStats, previousStats] = yield* Effect.all([ rollup.bundleAll({ paths: currentPaths }), rollup.bundleAll({ paths: previousPaths }) ], { concurrency: 2 }) yield* Effect.logInfo("Bundling complete! Generating bundle size report...") return createReport(currentStats, previousStats) } ) const visualize = Effect.fn("Reporter.visualize")( function*(options: VisualizeOptions) { yield* fs.makeDirectory(options.outputDirectory, { recursive: true }) yield* rollup.bundleAll({ paths: options.paths, outputDirectory: options.outputDirectory, visualize: true }) return createVisualizationReport(options.paths, options.outputDirectory) } ) const reportSelected = Effect.fn("Reporter.reportSelected")( function*(options: ReportSelectedOptions) { yield* Effect.logInfo(`Found ${options.paths.length} files to bundle`) const stats = yield* rollup.bundleAll({ paths: options.paths }) yield* Effect.logInfo("Bundling complete! Generating bundle size report...") return createSelectedReport(stats) } ) const reportSelectedComparison = Effect.fn("Reporter.reportSelectedComparison")( function*(options: ReportSelectedComparisonOptions) { yield* Effect.logInfo(`Found ${options.paths.length} files to compare`) const baseDirectory = path.resolve(options.baseDirectory) const currentPaths = options.paths.map((currentPath) => path.resolve(currentPath)) const previousPaths = yield* Effect.forEach( currentPaths, Effect.fnUntraced(function*(currentPath) { const relativePath = path.relative(currentDirectory, currentPath) if (relativePath === "" || relativePath.startsWith("..") || path.isAbsolute(relativePath)) { return yield* Effect.fail( new ReporterError({ cause: `Selected bundle entry must be inside ${currentDirectory}: ${currentPath}` }) ) } const previousPath = path.join(baseDirectory, relativePath) yield* fs.makeDirectory(path.dirname(previousPath), { recursive: true }) yield* fs.copy(currentPath, previousPath, { overwrite: true }) return previousPath }), { concurrency: currentPaths.length } ) const [currentStats, previousStats] = yield* Effect.all([ rollup.bundleAll({ paths: currentPaths }), rollup.bundleAll({ paths: previousPaths }) ], { concurrency: 2 }) const entries: Array<{ readonly current: BundleStats readonly previous: BundleStats readonly filename: string }> = [] for (let i = 0; i < currentStats.length; i++) { entries.push({ current: currentStats[i]!, previous: previousStats[i]!, filename: path.relative(currentDirectory, currentStats[i]!.path) }) } yield* Effect.logInfo("Bundling complete! Generating bundle size report...") return createComparisonReport(entries) } ) return { report, reportSelectedComparison, reportSelected, visualize } as const }) } ) { static readonly layer = Layer.effect(this, this.make).pipe( Layer.provide(Fixtures.layer), Layer.provide(Rollup.layer) ) }