| 1 | /** |
| 2 | * Bundle report generation for the Effect bundle-size tooling. |
| 3 | * |
| 4 | * The reporter coordinates fixture discovery with the Rollup service to turn |
| 5 | * measured fixture bundles into Markdown tables or visualization output. It is |
| 6 | * used by the bundle CLI to compare the current workspace against a checked-out |
| 7 | * base directory, to print a one-off report for selected entry files, and to |
| 8 | * generate visualizations when a size change needs inspection. |
| 9 | * |
| 10 | * Reports compare files by basename and display gzipped Rollup output sizes in |
| 11 | * decimal kilobytes. Base fixtures are bundled only when the matching file |
| 12 | * exists; if a current fixture has no matching basename in the base directory it |
| 13 | * is reported as unchanged. Visualization artifacts are named from entry file |
| 14 | * stems in the requested output directory, so duplicate names can make the |
| 15 | * output misleading. |
| 16 | * |
| 17 | * @since 4.0.0 |
| 18 | */ |
| 19 | import * as Context from "effect/Context" |
| 20 | import * as Data from "effect/Data" |
| 21 | import * as Effect from "effect/Effect" |
| 22 | import * as FileSystem from "effect/FileSystem" |
| 23 | import { constFalse } from "effect/Function" |
| 24 | import * as Layer from "effect/Layer" |
| 25 | import * as Path from "effect/Path" |
| 26 | import { fileURLToPath } from "node:url" |
| 27 | import { Fixtures } from "./Fixtures.ts" |
| 28 | import type { BundleStats } from "./Rollup.ts" |
| 29 | import { Rollup } from "./Rollup.ts" |
| 30 | |
| 31 | /** |
| 32 | * Error raised when generating a bundle size report or visualization fails. |
| 33 | * |
| 34 | * @category errors |
| 35 | * @since 4.0.0 |
| 36 | */ |
| 37 | export class ReporterError extends Data.TaggedError("ReporterError")<{ |
| 38 | readonly cause: unknown |
| 39 | }> {} |
| 40 | |
| 41 | /** |
| 42 | * Options for generating a bundle size comparison report against fixture files from a base directory. |
| 43 | * |
| 44 | * @category options |
| 45 | * @since 4.0.0 |
| 46 | */ |
| 47 | export interface ReportOptions { |
| 48 | readonly baseDirectory: string |
| 49 | } |
| 50 | |
| 51 | /** |
| 52 | * Options for generating bundle visualizations for selected entry files into an output directory. |
| 53 | * |
| 54 | * @category options |
| 55 | * @since 4.0.0 |
| 56 | */ |
| 57 | export interface VisualizeOptions { |
| 58 | readonly paths: ReadonlyArray<string> |
| 59 | readonly outputDirectory: string |
| 60 | } |
| 61 | |
| 62 | /** |
| 63 | * Options for generating a bundle size report for an explicit list of entry files. |
| 64 | * |
| 65 | * @category options |
| 66 | * @since 4.0.0 |
| 67 | */ |
| 68 | export interface ReportSelectedOptions { |
| 69 | readonly paths: ReadonlyArray<string> |
| 70 | } |
| 71 | |
| 72 | /** |
| 73 | * Options for generating a bundle size comparison report for explicit entry files against a base checkout. |
| 74 | * |
| 75 | * @category options |
| 76 | * @since 4.0.0 |
| 77 | */ |
| 78 | export interface ReportSelectedComparisonOptions { |
| 79 | readonly baseDirectory: string |
| 80 | readonly paths: ReadonlyArray<string> |
| 81 | } |
| 82 | |
| 83 | /** |
| 84 | * Context service for producing bundle size reports and visualizations from Rollup-generated fixture stats. |
| 85 | * |
| 86 | * @category services |
| 87 | * @since 4.0.0 |
| 88 | */ |
| 89 | export class Reporter extends Context.Service<Reporter>()( |
| 90 | "@effect/bundle/Reporter", |
| 91 | { |
| 92 | make: Effect.gen(function*() { |
| 93 | const fs = yield* FileSystem.FileSystem |
| 94 | const path = yield* Path.Path |
| 95 | const { fixtures, fixturesDir } = yield* Fixtures |
| 96 | const rollup = yield* Rollup |
| 97 | const currentDirectory = path.resolve(fileURLToPath(new URL("../../../../", import.meta.url))) |
| 98 | |
| 99 | const calculateDifference = (current: BundleStats, previous: BundleStats) => { |
| 100 | const currSize = current.sizeInBytes |
| 101 | const prevSize = previous.sizeInBytes |
| 102 | const diff = currSize - prevSize |
| 103 | const diffPct = prevSize === 0 ? 0 : (Math.abs(diff) / prevSize) * 100 |
| 104 | const currKb = (currSize / 1000).toFixed(2) |
| 105 | const prevKb = (prevSize / 1000).toFixed(2) |
| 106 | const diffKb = (Math.abs(diff) / 1000).toFixed(2) |
| 107 | const filename = path.basename(current.path) |
| 108 | return { |
| 109 | diff, |
| 110 | diffPct, |
| 111 | currKb, |
| 112 | prevKb, |
| 113 | diffKb, |
| 114 | filename |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | const createComparisonReport = ( |
| 119 | entries: ReadonlyArray<{ |
| 120 | readonly current: BundleStats |
| 121 | readonly previous: BundleStats |
| 122 | readonly filename: string |
| 123 | }> |
| 124 | ): string => { |
| 125 | const lines: Array<string> = [ |
| 126 | "| File Name | Current Size | Previous Size | Difference |", |
| 127 | "|:----------|:------------:|:-------------:|:----------:|" |
| 128 | ] |
| 129 | for (const { current, previous, filename } of entries) { |
| 130 | const comparison = calculateDifference(current, previous) |
| 131 | const currKb = `${comparison.currKb} KB` |
| 132 | const prevKb = `${comparison.prevKb} KB` |
| 133 | const diffKb = `${comparison.diffKb} KB` |
| 134 | const diffPct = `${comparison.diffPct.toFixed(2)}%` |
| 135 | const sign = comparison.diff === 0 ? "" : comparison.diff > 0 ? "+" : "-" |
| 136 | const line = `| \`${filename}\` | ${currKb} | ${prevKb} | ${sign}${diffKb} (${sign}${diffPct}) |` |
| 137 | lines.push(line) |
| 138 | } |
| 139 | return lines.join("\n") + "\n" |
| 140 | } |
| 141 | |
| 142 | const createReport = (curr: ReadonlyArray<BundleStats>, prev: ReadonlyArray<BundleStats>): string => { |
| 143 | const entries: Array<{ |
| 144 | readonly current: BundleStats |
| 145 | readonly previous: BundleStats |
| 146 | readonly filename: string |
| 147 | }> = [] |
| 148 | for (const current of curr) { |
| 149 | const previous = prev.find((previous) => { |
| 150 | return path.basename(previous.path) === path.basename(current.path) |
| 151 | }) ?? current |
| 152 | entries.push({ |
| 153 | current, |
| 154 | previous, |
| 155 | filename: path.basename(current.path) |
| 156 | }) |
| 157 | } |
| 158 | return createComparisonReport(entries) |
| 159 | } |
| 160 | |
| 161 | const createSelectedReport = (stats: ReadonlyArray<BundleStats>): string => { |
| 162 | const lines: Array<string> = [ |
| 163 | "| File Name | Current Size |", |
| 164 | "|:----------|:------------:|" |
| 165 | ] |
| 166 | |
| 167 | for (const current of stats) { |
| 168 | const filename = `\`${path.basename(current.path)}\`` |
| 169 | const currKb = `${(current.sizeInBytes / 1000).toFixed(2)} KB` |
| 170 | const line = `| ${filename} | ${currKb} |` |
| 171 | lines.push(line) |
| 172 | } |
| 173 | |
| 174 | return lines.join("\n") + "\n" |
| 175 | } |
| 176 | |
| 177 | const createVisualizationReport = (paths: ReadonlyArray<string>, outputDirectory: string): string => { |
| 178 | const lines: Array<string> = [ |
| 179 | "| File Name | Generated Bundle | Treemap | Raw Data |", |
| 180 | "|:----------|:----------------|:--------|:---------|" |
| 181 | ] |
| 182 | for (const entryPath of paths) { |
| 183 | const name = path.parse(entryPath).name |
| 184 | const filename = path.relative(currentDirectory, path.resolve(entryPath)) |
| 185 | const minified = path.join(outputDirectory, `${name}.min.js`) |
| 186 | const treemap = path.join(outputDirectory, `${name}.treemap.html`) |
| 187 | const rawData = path.join(outputDirectory, `${name}.raw-data.json`) |
| 188 | lines.push(`| \`${filename}\` | \`${minified}\` | \`${treemap}\` | \`${rawData}\` |`) |
| 189 | } |
| 190 | return lines.join("\n") + "\n" |
| 191 | } |
| 192 | |
| 193 | const report = Effect.fn("Reporter.report")( |
| 194 | function*(options: ReportOptions) { |
| 195 | yield* Effect.logInfo(`Found ${fixtures.length} files to bundle`) |
| 196 | |
| 197 | const currentPaths = fixtures.map((fixture) => path.join(fixturesDir, fixture)) |
| 198 | const previousPaths = yield* Effect.filter( |
| 199 | fixtures.map((fixture) => path.join(options.baseDirectory, fixture)), |
| 200 | (previousPath) => fs.exists(previousPath).pipe(Effect.orElseSucceed(constFalse)), |
| 201 | { concurrency: fixtures.length } |
| 202 | ) |
| 203 | |
| 204 | const currentStats = yield* rollup.bundleAll({ paths: currentPaths }) |
| 205 | const previousStats = yield* rollup.bundleAll({ paths: previousPaths }) |
| 206 | |
| 207 | yield* Effect.logInfo("Bundling complete! Generating bundle size report...") |
| 208 | |
| 209 | return createReport(currentStats, previousStats) |
| 210 | } |
| 211 | ) |
| 212 | |
| 213 | const visualize = Effect.fn("Reporter.visualize")( |
| 214 | function*(options: VisualizeOptions) { |
| 215 | yield* fs.makeDirectory(options.outputDirectory, { recursive: true }) |
| 216 | yield* rollup.bundleAll({ |
| 217 | paths: options.paths, |
| 218 | outputDirectory: options.outputDirectory, |
| 219 | visualize: true |
| 220 | }) |
| 221 | return createVisualizationReport(options.paths, options.outputDirectory) |
| 222 | } |
| 223 | ) |
| 224 | |
| 225 | const reportSelected = Effect.fn("Reporter.reportSelected")( |
| 226 | function*(options: ReportSelectedOptions) { |
| 227 | yield* Effect.logInfo(`Found ${options.paths.length} files to bundle`) |
| 228 | const stats = yield* rollup.bundleAll({ paths: options.paths }) |
| 229 | yield* Effect.logInfo("Bundling complete! Generating bundle size report...") |
| 230 | return createSelectedReport(stats) |
| 231 | } |
| 232 | ) |
| 233 | |
| 234 | const reportSelectedComparison = Effect.fn("Reporter.reportSelectedComparison")( |
| 235 | function*(options: ReportSelectedComparisonOptions) { |
| 236 | yield* Effect.logInfo(`Found ${options.paths.length} files to compare`) |
| 237 | const baseDirectory = path.resolve(options.baseDirectory) |
| 238 | const currentPaths = options.paths.map((currentPath) => path.resolve(currentPath)) |
| 239 | const previousPaths = yield* Effect.forEach( |
| 240 | currentPaths, |
| 241 | Effect.fnUntraced(function*(currentPath) { |
| 242 | const relativePath = path.relative(currentDirectory, currentPath) |
| 243 | if (relativePath === "" || relativePath.startsWith("..") || path.isAbsolute(relativePath)) { |
| 244 | return yield* Effect.fail( |
| 245 | new ReporterError({ |
| 246 | cause: `Selected bundle entry must be inside ${currentDirectory}: ${currentPath}` |
| 247 | }) |
| 248 | ) |
| 249 | } |
| 250 | const previousPath = path.join(baseDirectory, relativePath) |
| 251 | yield* fs.makeDirectory(path.dirname(previousPath), { recursive: true }) |
| 252 | yield* fs.copy(currentPath, previousPath, { overwrite: true }) |
| 253 | return previousPath |
| 254 | }), |
| 255 | { concurrency: currentPaths.length } |
| 256 | ) |
| 257 | |
| 258 | const currentStats = yield* rollup.bundleAll({ paths: currentPaths }) |
| 259 | const previousStats = yield* rollup.bundleAll({ paths: previousPaths }) |
| 260 | |
| 261 | const entries: Array<{ |
| 262 | readonly current: BundleStats |
| 263 | readonly previous: BundleStats |
| 264 | readonly filename: string |
| 265 | }> = [] |
| 266 | for (let i = 0; i < currentStats.length; i++) { |
| 267 | entries.push({ |
| 268 | current: currentStats[i]!, |
| 269 | previous: previousStats[i]!, |
| 270 | filename: path.relative(currentDirectory, currentStats[i]!.path) |
| 271 | }) |
| 272 | } |
| 273 | yield* Effect.logInfo("Bundling complete! Generating bundle size report...") |
| 274 | return createComparisonReport(entries) |
| 275 | } |
| 276 | ) |
| 277 | |
| 278 | return { |
| 279 | report, |
| 280 | reportSelectedComparison, |
| 281 | reportSelected, |
| 282 | visualize |
| 283 | } as const |
| 284 | }) |
| 285 | } |
| 286 | ) { |
| 287 | static readonly layer = Layer.effect(this, this.make).pipe( |
| 288 | Layer.provide(Fixtures.layer), |
| 289 | Layer.provide(Rollup.layer) |
| 290 | ) |
| 291 | } |