1/**
2 * Rollup-backed bundling and size measurement for the Effect bundle-size tools.
3 *
4 * This module provides the service used by the bundle CLI and reporter to turn
5 * fixture or selected TypeScript entrypoints into ESM Rollup output, optionally
6 * write a minified artifact, and return the gzipped byte count used in
7 * bundle-size comparisons.
8 *
9 * Bundles are generated in memory so the emitted code can be streamed to both
10 * gzip measurement and optional file output. Only Rollup `chunk` outputs are
11 * included; assets are ignored, and when Rollup creates multiple chunks (for
12 * example because of dynamic imports or shared chunks) their code is streamed
13 * together for measurement. The optional output file is named from the
14 * entrypoint stem, so it is best treated as an inspection artifact rather than
15 * a complete Rollup output directory.
16 *
17 * @since 4.0.0
18 */
19import * as NodeStream from "@effect/platform-node/NodeStream"
20import * as Context from "effect/Context"
21import * as Data from "effect/Data"
22import * as Effect from "effect/Effect"
23import * as FileSystem from "effect/FileSystem"
24import * as Layer from "effect/Layer"
25import * as Path from "effect/Path"
26import * as Stream from "effect/Stream"
27import { createGzip } from "node:zlib"
28import type { RollupOptions } from "rollup"
29import { rollup } from "rollup"
30import { createPlugins, type VisualizationOutput } from "./Plugins.ts"
31
32/**
33 * Error raised when Rollup bundling, output generation, or bundle size measurement fails.
34 *
35 * @category errors
36 * @since 4.0.0
37 */
38export class RollupError extends Data.TaggedError("RollupError")<{
39 readonly cause: unknown
40}> {}
41
42/**
43 * Bundle size statistics for an entry file, including its path and gzipped size in bytes.
44 *
45 * @category models
46 * @since 4.0.0
47 */
48export class BundleStats extends Data.TaggedClass("BundleStats")<{
49 readonly path: string
50 readonly sizeInBytes: number
51}> {}
52
53/**
54 * Options for bundling one entry file, optionally writing a minified output and generating a visualization.
55 *
56 * @category options
57 * @since 4.0.0
58 */
59export interface BundleOptions {
60 readonly path: string
61 readonly visualize?: boolean | undefined
62 readonly outputDirectory?: string | undefined
63}
64
65/**
66 * Options for bundling multiple entry files with shared visualization and output-directory settings.
67 *
68 * @category options
69 * @since 4.0.0
70 */
71export interface BundleAllOptions {
72 readonly paths: ReadonlyArray<string>
73 readonly visualize?: boolean | undefined
74 readonly outputDirectory?: string | undefined
75}
76
77/**
78 * Context service for bundling entry files with Rollup and measuring their gzipped output size.
79 *
80 * @category services
81 * @since 4.0.0
82 */
83export class Rollup extends Context.Service<Rollup>()(
84 "@effect/bundle/Rollup",
85 {
86 make: Effect.gen(function*() {
87 const pathService = yield* Path.Path
88 const fs = yield* FileSystem.FileSystem
89
90 const createVisualizationOutputs = (options: BundleOptions): ReadonlyArray<VisualizationOutput> => {
91 if (!options.visualize || !options.outputDirectory) {
92 return []
93 }
94 const name = pathService.parse(options.path).name
95 return [
96 {
97 filename: pathService.join(options.outputDirectory, `${name}.treemap.html`),
98 template: "treemap",
99 title: `${name} bundle treemap`
100 },
101 {
102 filename: pathService.join(options.outputDirectory, `${name}.raw-data.json`),
103 template: "raw-data",
104 title: `${name} bundle raw data`
105 }
106 ]
107 }
108
109 const getRollupOptions = (options: BundleOptions): RollupOptions => ({
110 input: options.path,
111 output: {
112 format: "esm"
113 },
114 plugins: createPlugins(pathService, {
115 visualize: options.visualize,
116 visualizations: createVisualizationOutputs(options)
117 }),
118 onwarn: (warning, next) => {
119 if (warning.code === "THIS_IS_UNDEFINED") return
120 next(warning)
121 }
122 })
123
124 const bundle = Effect.fn("Rollup.bundle")(
125 function*(options: BundleOptions) {
126 const bundle = yield* Effect.acquireRelease(
127 Effect.tryPromise({
128 try: () => rollup(getRollupOptions(options)),
129 catch: (cause) => new RollupError({ cause })
130 }),
131 (bundle) => Effect.promise(() => bundle.close())
132 )
133 const { output } = yield* Effect.tryPromise({
134 try: () => bundle.generate({ format: "esm" }),
135 catch: (cause) => new RollupError({ cause })
136 })
137
138 const stream = yield* Stream.fromIterable(output).pipe(
139 Stream.filter((output) => output.type === "chunk"),
140 Stream.map((chunk) => chunk.code),
141 Stream.encodeText,
142 Stream.broadcast({ capacity: 8, replay: 8 })
143 )
144
145 const writeOutput = options.outputDirectory
146 ? stream.pipe(
147 Stream.run(fs.sink(pathService.join(
148 options.outputDirectory,
149 `${pathService.parse(options.path).name}.min.js`
150 ))),
151 Effect.mapError((cause) => new RollupError({ cause }))
152 )
153 : Effect.void
154
155 const [, sizeInBytes] = yield* Effect.all([
156 writeOutput,
157 stream.pipe(
158 NodeStream.pipeThroughDuplex({
159 evaluate: () => createGzip({ level: 9 }),
160 onError: (cause) => new RollupError({ cause })
161 }),
162 Stream.runFold(
163 () => 0,
164 (totalBytes, chunkBytes) => chunkBytes.length + totalBytes
165 )
166 )
167 ], { concurrency: 2 })
168
169 yield* Effect.log(`Bundled ${options.path}`).pipe(
170 Effect.annotateLogs({ size: `${(sizeInBytes / 1000).toFixed(2)} kB` })
171 )
172
173 return new BundleStats({ path: options.path, sizeInBytes })
174 },
175 Effect.scoped
176 )
177
178 const bundleAll = Effect.fn("Rollup.bundleAll")(
179 function*(options: BundleAllOptions) {
180 return yield* Effect.forEach(
181 options.paths,
182 (path) => bundle({ path, visualize: options.visualize, outputDirectory: options.outputDirectory }),
183 // Rollup retains a module graph for each active bundle, so unbounded
184 // concurrency can exhaust the Node.js heap on CI runners.
185 { concurrency: 4 }
186 )
187 }
188 )
189
190 return {
191 bundle,
192 bundleAll
193 } as const
194 })
195 }
196) {
197 static readonly layer = Layer.effect(this, this.make)
198}