300 lines
11 KiB
TypeScript
300 lines
11 KiB
TypeScript
/**
|
|
* 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<string>
|
|
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<string>
|
|
}
|
|
|
|
/**
|
|
* 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<string>
|
|
}
|
|
|
|
/**
|
|
* 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<Reporter>()(
|
|
"@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<string> = [
|
|
"| 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<BundleStats>, prev: ReadonlyArray<BundleStats>): 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<BundleStats>): string => {
|
|
const lines: Array<string> = [
|
|
"| 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<string>, outputDirectory: string): string => {
|
|
const lines: Array<string> = [
|
|
"| 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)
|
|
)
|
|
}
|