/** * Rollup-backed bundling and size measurement for the Effect bundle-size tools. * * This module provides the service used by the bundle CLI and reporter to turn * fixture or selected TypeScript entrypoints into ESM Rollup output, optionally * write a minified artifact, and return the gzipped byte count used in * bundle-size comparisons. * * Bundles are generated in memory so the emitted code can be streamed to both * gzip measurement and optional file output. Only Rollup `chunk` outputs are * included; assets are ignored, and when Rollup creates multiple chunks (for * example because of dynamic imports or shared chunks) their code is streamed * together for measurement. The optional output file is named from the * entrypoint stem, so it is best treated as an inspection artifact rather than * a complete Rollup output directory. * * @since 4.0.0 */ import * as NodeStream from "@effect/platform-node/NodeStream" import * as Context from "effect/Context" import * as Data from "effect/Data" import * as Effect from "effect/Effect" import * as FiberSet from "effect/FiberSet" import * as FileSystem from "effect/FileSystem" import * as Layer from "effect/Layer" import * as Path from "effect/Path" import * as Stream from "effect/Stream" import { createGzip } from "node:zlib" import type { RollupOptions } from "rollup" import { rollup } from "rollup" import { createPlugins, type VisualizationOutput } from "./Plugins.ts" /** * Error raised when Rollup bundling, output generation, or bundle size measurement fails. * * @category errors * @since 4.0.0 */ export class RollupError extends Data.TaggedError("RollupError")<{ readonly cause: unknown }> {} /** * Bundle size statistics for an entry file, including its path and gzipped size in bytes. * * @category models * @since 4.0.0 */ export class BundleStats extends Data.TaggedClass("BundleStats")<{ readonly path: string readonly sizeInBytes: number }> {} /** * Options for bundling one entry file, optionally writing a minified output and generating a visualization. * * @category options * @since 4.0.0 */ export interface BundleOptions { readonly path: string readonly visualize?: boolean | undefined readonly outputDirectory?: string | undefined } /** * Options for bundling multiple entry files with shared visualization and output-directory settings. * * @category options * @since 4.0.0 */ export interface BundleAllOptions { readonly paths: ReadonlyArray readonly visualize?: boolean | undefined readonly outputDirectory?: string | undefined } /** * Context service for bundling entry files with Rollup and measuring their gzipped output size. * * @category services * @since 4.0.0 */ export class Rollup extends Context.Service()( "@effect/bundle/Rollup", { make: Effect.gen(function*() { const pathService = yield* Path.Path const fs = yield* FileSystem.FileSystem const createVisualizationOutputs = (options: BundleOptions): ReadonlyArray => { if (!options.visualize || !options.outputDirectory) { return [] } const name = pathService.parse(options.path).name return [ { filename: pathService.join(options.outputDirectory, `${name}.treemap.html`), template: "treemap", title: `${name} bundle treemap` }, { filename: pathService.join(options.outputDirectory, `${name}.raw-data.json`), template: "raw-data", title: `${name} bundle raw data` } ] } const getRollupOptions = (options: BundleOptions): RollupOptions => ({ input: options.path, output: { format: "esm" }, plugins: createPlugins(pathService, { visualize: options.visualize, visualizations: createVisualizationOutputs(options) }), onwarn: (warning, next) => { if (warning.code === "THIS_IS_UNDEFINED") return next(warning) } }) const bundle = Effect.fn("Rollup.bundle")( function*(options: BundleOptions) { const bundle = yield* Effect.acquireRelease( Effect.tryPromise({ try: () => rollup(getRollupOptions(options)), catch: (cause) => new RollupError({ cause }) }), (bundle) => Effect.promise(() => bundle.close()) ) const fibers = yield* FiberSet.make() const { output } = yield* Effect.tryPromise({ try: () => bundle.generate({ format: "esm" }), catch: (cause) => new RollupError({ cause }) }) const stream = yield* Stream.fromIterable(output).pipe( Stream.filter((output) => output.type === "chunk"), Stream.map((chunk) => chunk.code), Stream.encodeText, Stream.broadcast({ capacity: 8, replay: 8 }) ) if (options.outputDirectory) { const outputPath = pathService.join( options.outputDirectory, `${pathService.parse(options.path).name}.min.js` ) yield* FiberSet.run( fibers, stream.pipe( Stream.run(fs.sink(outputPath)) ) ) } const sizeInBytes = yield* stream.pipe( NodeStream.pipeThroughDuplex({ evaluate: () => createGzip({ level: 9 }), onError: (cause) => new RollupError({ cause }) }), Stream.runFold( () => 0, (totalBytes, chunkBytes) => chunkBytes.length + totalBytes ) ) yield* FiberSet.awaitEmpty(fibers) yield* Effect.log(`Bundled ${options.path}`).pipe( Effect.annotateLogs({ size: `${(sizeInBytes / 1000).toFixed(2)} kB` }) ) return new BundleStats({ path: options.path, sizeInBytes }) }, Effect.scoped ) const bundleAll = Effect.fn("Rollup.bundleAll")( function*(options: BundleAllOptions) { return yield* Effect.forEach( options.paths, (path) => bundle({ path, visualize: options.visualize, outputDirectory: options.outputDirectory }), { concurrency: options.paths.length } ) } ) return { bundle, bundleAll } as const }) } ) { static readonly layer = Layer.effect(this, this.make) }