@medicomind/rolldown-compression 2.3.6 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # @medicomind/rolldown-compression
2
2
 
3
+ ## 2.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [`de208d7`](https://github.com/Medico-Mind/rolldown-compression/commit/de208d744e6cf3bb3224cd739b5dabb88230e001) Thanks [@Mnwa](https://github.com/Mnwa)! - Return mimalloc as default
8
+
9
+ ## 2.4.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [`7731f9d`](https://github.com/Medico-Mind/rolldown-compression/commit/7731f9d48a0f98ec4b8f1e75f30729bceb8be5bf) Thanks [@Mnwa](https://github.com/Mnwa)! - Follow the Rolldown plugin conventions and harden asset handling.
14
+
15
+ - The plugin is now named `rolldown-plugin-compression` (was `rolldown-compression`), as the [plugin conventions](https://rolldown.rs/apis/plugin-api#conventions) ask for. It also reports `version` and `meta.packageName`, and exposes an `api` (`algorithms`, `extensions`, `emittedFileNames()`) for inter-plugin communication.
16
+ - `deleteOriginalAssets` no longer removes an asset that ended up without a compressed variant — a file skipped by `skipIfLargerOrEqual` used to be deleted with nothing to replace it. Removing a chunk now logs a warning once, since only the compressed name is left on disk.
17
+ - Artifact names resolved from `filename` are validated: names that escape the output directory, are absolute, collide with another artifact of the same build, or would overwrite a file the build already owns now fail the build instead of silently clobbering.
18
+ - In stream mode `deleteOriginalAssets` now only unlinks files the build actually wrote — those the bundle declares, or those written while the output was being generated. A leftover from an earlier build in an output directory that was not emptied still gets compressed, but is no longer deleted.
19
+ - Per-output state is reset in `renderStart`, so with multiple outputs one output's artifacts no longer shadow another output's sources, and watch-mode rebuilds no longer accumulate it.
20
+ - Per-file detail (`skipped …`) moved from `this.info` to `this.debug`; the build summary stays on `this.info`.
21
+ - Assets no longer cross into the native module through a duplicated `Buffer`, and stream mode reads each batch's files concurrently instead of one at a time.
22
+
23
+ ### Patch Changes
24
+
25
+ - [`0b10197`](https://github.com/Medico-Mind/rolldown-compression/commit/0b10197902c3278d9ba9dfe3dec7891fc8ff110f) Thanks [@Mnwa](https://github.com/Mnwa)! - Update simd-brotli to v10, speeding up brotli compression. On a 36 MB production-shaped bundle corpus (62 files): quality 11 is ~5% faster (7518ms -> 7143ms) and quality 6 ~3% faster (111ms -> 108ms), with byte-identical output.
26
+
27
+ - [`97b67df`](https://github.com/Medico-Mind/rolldown-compression/commit/97b67df90db45e7b93605594616abe9b47773939) Thanks [@Mnwa](https://github.com/Mnwa)! - Remove the `@rollup/pluginutils` runtime dependency and use Node.js `path.matchesGlob` for include and exclude glob matching.
28
+
29
+ - [`97571e9`](https://github.com/Medico-Mind/rolldown-compression/commit/97571e990720f135b3fa8c809bca9af831d18419) Thanks [@Mnwa](https://github.com/Mnwa)! - Route plugin messages through Rolldown's logging context instead of writing directly to the console.
30
+
3
31
  ## 2.3.6
4
32
 
5
33
  ### Patch Changes
package/README.md CHANGED
@@ -82,17 +82,17 @@ export default defineConfig({
82
82
 
83
83
  | option | type | default | description |
84
84
  |------------------------|-------------------------------------------------|-------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
85
- | `include` | `string \| RegExp \| Array<string \| RegExp>` | `/\.(html\|xml\|css\|json\|js\|mjs\|svg\|yaml\|yml\|toml\|txt\|wasm)$/` | Files to compress. Strings are [picomatch](https://github.com/micromatch/picomatch) globs, matched against bundle-relative file names ([`@rollup/pluginutils` `createFilter`](https://github.com/rollup/plugins/tree/master/packages/pluginutils#createfilter) semantics). |
85
+ | `include` | `string \| RegExp \| Array<string \| RegExp>` | `/\.(html\|xml\|css\|json\|js\|mjs\|svg\|yaml\|yml\|toml\|txt\|wasm)$/` | Files to compress. Strings are [`path.matchesGlob`](https://nodejs.org/api/path.html#pathmatchesglobpath-pattern) patterns matched against bundle-relative file names. Source maps (`*.map`) are **not** covered by the default — add `/\.map$/` if you serve them pre-compressed. |
86
86
  | `exclude` | `string \| RegExp \| Array<string \| RegExp>` | — | Files to skip. **Wins over `include`.** |
87
87
  | `threshold` | `number` | `0` | Minimum original size in bytes for a file to be compressed. |
88
88
  | `algorithms` | `Array<AlgorithmName \| DefineAlgorithmResult>` | `['gzip', 'brotli']` | Algorithms to run. Aliases `gz`, `br`, `brotliCompress`, `zstandard` normalize to `gzip` / `brotli` / `zstd`. |
89
- | `filename` | `string \| (fileName, algorithm) => string` | `'[path][base]' + ext` | Name of the emitted artifact. Tokens: `[path]` (directory incl. trailing `/`), `[base]`, `[name]`, `[ext]` (with dot), `[hash]` (8-char sha256 of the content). Default extensions: `.gz`, `.br`, `.zst`. |
90
- | `deleteOriginalAssets` | `boolean` | `false` | Remove the original from the bundle after all algorithms processed it. Errors if `filename` resolves to the source name. |
89
+ | `filename` | `string \| (fileName, algorithm) => string` | `'[path][base]' + ext` | Name of the emitted artifact. Tokens: `[path]` (directory incl. trailing `/`), `[base]`, `[name]`, `[ext]` (with dot), `[hash]` (8-char sha256 of the **original**, uncompressed bytes). Default extensions: `.gz`, `.br`, `.zst`. The result must be a relative path inside the output directory, must differ from the source name, and must be unique across the build — anything else fails the build. |
90
+ | `deleteOriginalAssets` | `boolean` | `false` | Remove the original once **every** algorithm produced a variant of it. Files with no variant (e.g. skipped by `skipIfLargerOrEqual`) are always kept, so this can never leave the build without a servable copy. Removing chunks logs a warning — see [deleting originals](#deleting-originals). |
91
91
  | `skipIfLargerOrEqual` | `boolean` | `true` | Don't emit artifacts whose compressed size is `>=` the original. |
92
92
  | `concurrency` | `number` | `0` | Native worker threads. `0` = number of logical CPUs. |
93
93
  | `chunkSize` | `number` | `0` | Max source bytes buffered per native compression batch. `0` = one batch for the whole build. A positive value (e.g. `64 * 1024 * 1024`) caps the plugin's peak memory overhead at roughly one batch of source copies plus its compressed outputs; a single file larger than `chunkSize` still forms its own batch. The bundler keeps the original bundle in memory regardless. In [stream mode](#stream-mode) `0` falls back to a 4 MB batch instead of one big batch. |
94
94
  | `stream` | `boolean` | `false` | Compress from disk in `writeBundle` (order `post`) instead of in memory in `generateBundle`: files are read on demand in bounded batches (`chunkSize` bytes, default 4 MB), and assets written to disk by other plugins' `writeBundle` hooks are compressed too (see [stream mode](#stream-mode)). |
95
- | `logLevel` | `'silent' \| 'error' \| 'warn' \| 'info'` | `'info'` | Plugin log verbosity; `info` prints a per-algorithm summary at build end. |
95
+ | `logLevel` | `'silent' \| 'error' \| 'warn' \| 'info'` | `'info'` | Plugin log verbosity. `info` prints the per-algorithm summary through `this.info` and per-file detail through `this.debug`, so the bundler's own log level decides whether the detail is shown; `warn` keeps only warnings. |
96
96
  | `enableInWatchMode` | `boolean` | `false` | The plugin is a no-op in watch/dev mode unless enabled (see [watch mode](#watch--dev-mode)). |
97
97
 
98
98
  All options are validated when `compression()` is called — invalid levels (e.g. brotli quality 12), unknown algorithm names or malformed filters throw immediately, not mid-build.
@@ -128,6 +128,8 @@ The size penalty depends entirely on how far apart your content's repetitions ar
128
128
  - Buffers cross the FFI boundary without base64/string round-trips, and the compression working set lives in native memory — a 500 MB asset does not pressure the JS heap.
129
129
  - A failing task never aborts the batch: per-task errors are aggregated and fail the build with one message.
130
130
  - Already-compressed artifacts (`.gz`, `.br`, `.zst` — ours or pre-existing) are never re-compressed, so chaining plugin instances can't produce `app.js.gz.br`.
131
+ - Artifact names are checked before anything is written: a name that escapes the output directory, collides with another artifact of the same build, or overwrites a file the build already owns fails the build instead of silently clobbering it.
132
+ - Rolldown runs the output hooks once per output, so per-output state is reset in `renderStart` — one output's artifacts never shadow another output's sources, and watch-mode rebuilds don't accumulate state.
131
133
 
132
134
  Limitation of the default mode: assets written to disk by other plugins in `writeBundle`/`closeBundle` (i.e. after `generateBundle`) are not seen. This matches how `vite-plugin-compression2` handles the in-bundle pass. Set `stream: true` to remove it.
133
135
 
@@ -144,9 +146,38 @@ compression({
144
146
 
145
147
  - The output directory is scanned recursively and matching files are read **on demand**, never all at once: a batch is flushed to the native module whenever it reaches `chunkSize` source bytes, defaulting to **4 MB** when `chunkSize` is `0`. Peak memory overhead is one batch of sources plus its compressed outputs, regardless of build size.
146
148
  - Compressed artifacts are written straight to the output directory (`emitFile` is not available after write); `deleteOriginalAssets` unlinks the originals from disk.
147
- - The same filter, threshold and re-compression guards apply, and everything already in the output directory that matches them is compressed — including files produced by other plugins after `generateBundle`, the default mode's limitation. Assets written in `closeBundle` (after all `writeBundle` hooks) are still not seen.
149
+ - The same filter, threshold and re-compression guards apply, and everything in the output directory that matches them is compressed — including files produced by other plugins after `generateBundle`, the default mode's limitation. Assets written in `closeBundle` (after all `writeBundle` hooks) are still not seen.
150
+ - `deleteOriginalAssets` is deliberately narrower than that: it only unlinks files the build actually wrote — those the bundle declares, or those written while the output was being generated. A leftover from an earlier build in a directory that was not emptied gets compressed but never deleted, because deleting a file the build did not produce is not something a compression plugin can undo.
148
151
  - Trade-off: files the bundler already had in memory are re-read from disk, and per-batch FFI calls replace the single big batch — for small builds the default in-memory mode is faster.
149
152
 
153
+ ### Deleting originals
154
+
155
+ `deleteOriginalAssets: true` removes an original only once a compressed variant of it exists. Anything the compressor skipped — incompressible binaries, files under a codec's break-even point — stays in place, and the plugin says which ones it kept. In [stream mode](#stream-mode) it additionally only touches files the build itself wrote.
156
+
157
+ Removing **chunks** (JS/CSS) is a deployment decision, not just a bundling one: only the compressed name is left on disk, so requests resolve only if whatever serves the build rewrites the original path to the compressed variant. Dynamic imports and source map links still use the original names. The plugin warns once per process the first time it removes a chunk.
158
+
159
+ ### Plugin identity and `api`
160
+
161
+ The plugin follows the rolldown [plugin conventions](https://rolldown.rs/apis/plugin-api#conventions): it is named `rolldown-plugin-compression` and reports the package it ships in, so tooling that inspects a build can attribute it.
162
+
163
+ ```ts
164
+ const plugin = compression()
165
+ plugin.name // 'rolldown-plugin-compression'
166
+ plugin.version // the package version
167
+ plugin.meta?.packageName // '@medicomind/rolldown-compression'
168
+ ```
169
+
170
+ Other plugins can read its `api` through the documented [inter-plugin communication](https://rolldown.rs/apis/plugin-api/inter-plugin-communication) channel — useful for, say, a middleware that serves the artifacts:
171
+
172
+ ```ts
173
+ interface CompressionPluginApi {
174
+ packageName: string
175
+ algorithms: readonly ('gzip' | 'brotli' | 'zstd')[]
176
+ extensions: readonly string[] // '.gz', '.br', '.zst'
177
+ emittedFileNames(): string[] // artifacts of the latest output
178
+ }
179
+ ```
180
+
150
181
  ### Watch / dev mode
151
182
 
152
183
  The plugin declares `apply: 'build'` (honored by Vite / `rolldown-vite`) **and** checks `this.meta.watchMode` at `generateBundle` time, making it a no-op under `rolldown --watch`. Set `enableInWatchMode: true` to compress in watch builds anyway.
package/dist/index.cjs CHANGED
@@ -24,7 +24,6 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  let node_crypto = require("node:crypto");
25
25
  let node_path = require("node:path");
26
26
  node_path = __toESM(node_path);
27
- let _rollup_pluginutils = require("@rollup/pluginutils");
28
27
  let node_fs_promises = require("node:fs/promises");
29
28
  let node_module = require("node:module");
30
29
  //#region ts/options.ts
@@ -65,6 +64,25 @@ const LOG_LEVELS = [
65
64
  "warn",
66
65
  "info"
67
66
  ];
67
+ function toArray(patterns) {
68
+ if (patterns === void 0) return [];
69
+ return Array.isArray(patterns) ? patterns : [patterns];
70
+ }
71
+ function createFilter(include, exclude) {
72
+ const includes = toArray(include);
73
+ const excludes = toArray(exclude);
74
+ const matches = (fileName, pattern) => {
75
+ if (typeof pattern === "string") return node_path.default.matchesGlob(fileName, pattern);
76
+ pattern.lastIndex = 0;
77
+ return pattern.test(fileName);
78
+ };
79
+ return (fileName) => {
80
+ if (typeof fileName !== "string" || fileName.includes("\0")) return false;
81
+ const normalizedFileName = fileName.replaceAll("\\", "/");
82
+ if (excludes.some((pattern) => matches(normalizedFileName, pattern))) return false;
83
+ return includes.length === 0 || includes.some((pattern) => matches(normalizedFileName, pattern));
84
+ };
85
+ }
68
86
  /** File extensions produced by this plugin; used by the re-compression guard. */
69
87
  const COMPRESSED_EXTENSION_RE = /\.(gz|br|zst)$/i;
70
88
  var OptionValidationError = class extends Error {
@@ -138,7 +156,7 @@ function resolveOptions(options = {}) {
138
156
  if (filename !== void 0 && typeof filename !== "string" && typeof filename !== "function") throw new OptionValidationError("invalid filename: expected a string pattern or a function");
139
157
  if (!Array.isArray(algorithms) || algorithms.length === 0) throw new OptionValidationError("invalid algorithms: expected a non-empty array");
140
158
  return {
141
- filter: (0, _rollup_pluginutils.createFilter)(include, exclude, { resolve: false }),
159
+ filter: createFilter(include, exclude),
142
160
  threshold,
143
161
  algorithms: algorithms.map(resolveAlgorithm),
144
162
  filename,
@@ -152,9 +170,46 @@ function resolveOptions(options = {}) {
152
170
  };
153
171
  }
154
172
  /**
173
+ * Validate a resolved artifact name before anything is emitted or written.
174
+ *
175
+ * A `filename` function is arbitrary user code, so its result is checked the
176
+ * same way a path from outside would be: it has to stay inside the output
177
+ * directory and must not claim the name of the asset it was derived from.
178
+ * Returns the normalized name to use, or the reason it was rejected.
179
+ */
180
+ function checkArtifactName(outputFileName, sourceFileName, algorithm) {
181
+ const context = `the filename option resolved "${sourceFileName}" (${algorithm})`;
182
+ if (typeof outputFileName !== "string" || outputFileName.length === 0) return {
183
+ ok: false,
184
+ message: `${context} to ${JSON.stringify(outputFileName)}; expected a non-empty file name relative to the output directory`
185
+ };
186
+ if (outputFileName.includes("\0")) return {
187
+ ok: false,
188
+ message: `${context} to a name containing a NUL byte`
189
+ };
190
+ const normalized = node_path.default.posix.normalize(outputFileName.replaceAll("\\", "/"));
191
+ if (node_path.default.posix.isAbsolute(normalized) || /^[a-zA-Z]:/.test(normalized)) return {
192
+ ok: false,
193
+ message: `${context} to the absolute path "${outputFileName}"; expected a path relative to the output directory`
194
+ };
195
+ if (normalized === ".." || normalized.startsWith("../") || normalized.endsWith("/")) return {
196
+ ok: false,
197
+ message: `${context} to "${outputFileName}", which does not name a file inside the output directory`
198
+ };
199
+ if (normalized === sourceFileName) return {
200
+ ok: false,
201
+ message: `${context} to the same name as the source asset; refusing to overwrite it`
202
+ };
203
+ return {
204
+ ok: true,
205
+ fileName: normalized
206
+ };
207
+ }
208
+ /**
155
209
  * Resolve the emitted file name for a compressed artifact.
156
210
  *
157
- * Supported tokens: `[path]`, `[base]`, `[name]`, `[ext]`, `[hash]`.
211
+ * Supported tokens: `[path]`, `[base]`, `[name]`, `[ext]`, `[hash]`. The
212
+ * result is not validated here — see {@link checkArtifactName}.
158
213
  */
159
214
  function resolveOutputFileName(filename, fileName, algorithm, source) {
160
215
  if (typeof filename === "function") return filename(fileName, algorithm.algorithm);
@@ -189,49 +244,69 @@ const compressBuffers = (0, node_module.createRequire)(require("url").pathToFile
189
244
  * {@link STREAM_DEFAULT_CHUNK_SIZE} when `chunkSize` is 0 — so the whole
190
245
  * build is never held in memory and assets written to disk by other
191
246
  * plugins' `writeBundle` hooks are compressed as well.
247
+ *
248
+ * Rolldown runs the output hooks once per output, so the per-output state
249
+ * below is reset in `renderStart` rather than shared across a whole build.
192
250
  */
193
- const PLUGIN_NAME = "rolldown-compression";
251
+ /**
252
+ * Rolldown asks plugins to be named with a `rolldown-plugin-` prefix so they
253
+ * are recognizable in logs, errors and hook traces.
254
+ */
255
+ const PLUGIN_NAME = "rolldown-plugin-compression";
256
+ const PACKAGE_NAME = "@medicomind/rolldown-compression";
194
257
  /** Source bytes per batch in stream mode when `chunkSize` is 0. */
195
258
  const STREAM_DEFAULT_CHUNK_SIZE = 4194304;
196
- const LOG_PRIORITY = {
197
- silent: 0,
198
- error: 1,
199
- warn: 2,
200
- info: 3
201
- };
202
- function createLogger(level) {
203
- const priority = LOG_PRIORITY[level];
204
- const prefix = `[${PLUGIN_NAME}]`;
205
- return {
206
- info: (message) => {
207
- if (priority >= LOG_PRIORITY.info) console.info(`${prefix} ${message}`);
208
- },
209
- warn: (message) => {
210
- if (priority >= LOG_PRIORITY.warn) console.warn(`${prefix} ${message}`);
211
- },
212
- error: (message) => {
213
- if (priority >= LOG_PRIORITY.error) console.error(`${prefix} ${message}`);
214
- }
215
- };
259
+ /**
260
+ * Slack applied when deciding whether a file on disk was written by the
261
+ * current output. Some filesystems store mtimes at one-second granularity,
262
+ * and the wall clock can drift against them, so a strict comparison would
263
+ * occasionally misread a file the build had just produced as a leftover.
264
+ */
265
+ const MTIME_SLACK_MS = 2e3;
266
+ /** Concurrent `stat` calls while scanning the output directory. */
267
+ const STAT_CONCURRENCY = 32;
268
+ /** Version of the shipping package, surfaced through `plugin.version`. */
269
+ const PACKAGE_VERSION = readPackageVersion();
270
+ function readPackageVersion() {
271
+ try {
272
+ const { version } = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href)("../package.json");
273
+ return typeof version === "string" ? version : void 0;
274
+ } catch {
275
+ return;
276
+ }
216
277
  }
217
278
  function toBuffer(source) {
218
- return typeof source === "string" ? Buffer.from(source, "utf8") : Buffer.from(source);
279
+ if (typeof source === "string") return Buffer.from(source, "utf8");
280
+ if (Buffer.isBuffer(source)) return source;
281
+ return Buffer.from(source.buffer, source.byteOffset, source.byteLength);
219
282
  }
220
283
  function formatBytes(bytes) {
221
284
  if (bytes < 1024) return `${bytes} B`;
222
285
  if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} kB`;
223
286
  return `${(bytes / 1048576).toFixed(2)} MB`;
224
287
  }
288
+ function createLogger(context, logLevel) {
289
+ const verbose = logLevel === "info";
290
+ const warns = logLevel === "info" || logLevel === "warn";
291
+ const noop = () => {};
292
+ return {
293
+ debug: verbose ? (message) => context.debug?.(message) : noop,
294
+ info: verbose ? (message) => context.info?.(message) : noop,
295
+ warn: warns ? (message) => context.warn?.(message) : noop
296
+ };
297
+ }
225
298
  /**
226
299
  * Shared batching core: queues per-algorithm tasks, flushes them to the
227
300
  * native module and hands successful results to `emit`. Only one batch of
228
301
  * source buffers is referenced at a time once a flush trigger is set.
229
302
  */
230
- function createBatchRunner(options, logger, emittedNames, emit, fail) {
303
+ function createBatchRunner({ options, log, emittedNames, isReserved, emit, fail }) {
231
304
  const processedSources = /* @__PURE__ */ new Set();
232
305
  const emittedBySource = /* @__PURE__ */ new Map();
233
306
  const failures = [];
234
307
  const stats = /* @__PURE__ */ new Map();
308
+ /** Artifact name -> the `source (algorithm)` that claimed it, for collision reporting. */
309
+ const claimed = /* @__PURE__ */ new Map();
235
310
  const chunkSize = options.chunkSize > 0 ? options.chunkSize : options.stream ? STREAM_DEFAULT_CHUNK_SIZE : 0;
236
311
  let pending = [];
237
312
  let pendingSourceBytes = 0;
@@ -252,7 +327,7 @@ function createBatchRunner(options, logger, emittedNames, emit, fail) {
252
327
  continue;
253
328
  }
254
329
  if (result.skipped) {
255
- logger.info(`skipped ${artifact.outputFileName}: ${result.algorithm} output would not be smaller than the original`);
330
+ log.debug(`skipped ${artifact.outputFileName}: ${result.algorithm} output would not be smaller than the original`);
256
331
  continue;
257
332
  }
258
333
  await emit(artifact, result.data);
@@ -271,8 +346,13 @@ function createBatchRunner(options, logger, emittedNames, emit, fail) {
271
346
  };
272
347
  const addFile = async (fileName, buffer) => {
273
348
  for (const algorithm of options.algorithms) {
274
- const outputFileName = resolveOutputFileName(options.filename, fileName, algorithm, buffer);
275
- if (outputFileName === fileName) fail(`the filename option resolved "${fileName}" (${algorithm.algorithm}) to the same name as the source asset; refusing to overwrite it`);
349
+ const check = checkArtifactName(resolveOutputFileName(options.filename, fileName, algorithm, buffer), fileName, algorithm.algorithm);
350
+ if (!check.ok) return fail(check.message);
351
+ const outputFileName = check.fileName;
352
+ const owner = claimed.get(outputFileName);
353
+ if (owner !== void 0) fail(`both ${owner} and ${fileName} (${algorithm.algorithm}) resolve to the artifact "${outputFileName}"; make the filename option depend on the algorithm`);
354
+ if (isReserved(outputFileName)) fail(`the artifact "${outputFileName}" for ${fileName} (${algorithm.algorithm}) would overwrite a file this build already owns`);
355
+ claimed.set(outputFileName, `${fileName} (${algorithm.algorithm})`);
276
356
  pending.push({
277
357
  task: {
278
358
  fileName,
@@ -299,12 +379,12 @@ function createBatchRunner(options, logger, emittedNames, emit, fail) {
299
379
  stats
300
380
  };
301
381
  }
302
- function logSummary(logger, stats, startedAt) {
382
+ function formatSummary(stats, startedAt) {
303
383
  const summary = [...stats.entries()].map(([algorithm, stat]) => `${algorithm}: ${stat.count} file(s), ${formatBytes(stat.originalBytes)} -> ${formatBytes(stat.outputBytes)}`).join("; ");
304
- if (summary.length === 0) return;
384
+ if (summary.length === 0) return void 0;
305
385
  const totalSaved = [...stats.values()].reduce((sum, stat) => sum + (stat.originalBytes - stat.outputBytes), 0);
306
386
  const elapsedMs = performance.now() - startedAt;
307
- logger.info(`${summary}; saved ${formatBytes(totalSaved)} in ${(elapsedMs / 1e3).toFixed(2)}s`);
387
+ return `${summary}; saved ${formatBytes(totalSaved)} in ${(elapsedMs / 1e3).toFixed(2)}s`;
308
388
  }
309
389
  /** Recursively list every file under `root`, in a deterministic order. */
310
390
  async function walkFiles(root) {
@@ -321,26 +401,133 @@ async function walkFiles(root) {
321
401
  await visit(root);
322
402
  return files;
323
403
  }
404
+ /** Run `task` over `items` with at most `limit` in flight, preserving order. */
405
+ async function mapConcurrent(items, limit, task) {
406
+ const results = new Array(items.length);
407
+ let next = 0;
408
+ const worker = async () => {
409
+ for (let index = next++; index < items.length; index = next++) results[index] = await task(items[index]);
410
+ };
411
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
412
+ return results;
413
+ }
414
+ /**
415
+ * Every file under `outDir` eligible for compression, each tagged with whether
416
+ * this build produced it.
417
+ *
418
+ * Compression stays deliberately broad — whatever is in the output directory
419
+ * and matches the filter gets compressed, which is the point of stream mode.
420
+ * Deletion does not: removing a file is not undoable, so it is restricted to
421
+ * files the build owns and never reaches leftovers from an earlier build in a
422
+ * directory that was not emptied.
423
+ */
424
+ async function collectStreamSources(outDir, bundleNames, writtenSinceMs, options, emittedNames) {
425
+ const absolutePaths = await walkFiles(outDir);
426
+ const infos = await mapConcurrent(absolutePaths, STAT_CONCURRENCY, async (absolute) => {
427
+ try {
428
+ return await (0, node_fs_promises.stat)(absolute);
429
+ } catch {
430
+ return;
431
+ }
432
+ });
433
+ const sources = [];
434
+ for (const [index, absolute] of absolutePaths.entries()) {
435
+ const info = infos[index];
436
+ if (info === void 0) continue;
437
+ const fileName = node_path.default.relative(outDir, absolute).split(node_path.default.sep).join("/");
438
+ if (!options.filter(fileName)) continue;
439
+ if (COMPRESSED_EXTENSION_RE.test(fileName) || emittedNames.has(fileName)) continue;
440
+ if (info.size < options.threshold) continue;
441
+ sources.push({
442
+ fileName,
443
+ absolute,
444
+ size: info.size,
445
+ ownedByBuild: bundleNames.has(fileName) || info.mtimeMs >= writtenSinceMs
446
+ });
447
+ }
448
+ return sources;
449
+ }
450
+ /**
451
+ * Split sources into read groups that mirror the batch boundaries
452
+ * {@link createBatchRunner} would pick, so a group can be read concurrently
453
+ * without ever holding more than one batch of file contents in memory.
454
+ */
455
+ function* groupBySize(sources, maxBytes) {
456
+ let group = [];
457
+ let bytes = 0;
458
+ for (const source of sources) {
459
+ group.push(source);
460
+ bytes += source.size;
461
+ if (bytes >= maxBytes) {
462
+ yield group;
463
+ group = [];
464
+ bytes = 0;
465
+ }
466
+ }
467
+ if (group.length > 0) yield group;
468
+ }
324
469
  function createCompressionPlugin(options) {
325
- const logger = createLogger(options.logLevel);
326
470
  const emittedNames = /* @__PURE__ */ new Set();
327
- return Object.assign({
471
+ /**
472
+ * Wall-clock start of the current output. Stream mode compares file mtimes
473
+ * against it to tell what this build wrote from what it merely found in the
474
+ * output directory, which is what `deleteOriginalAssets` is allowed to touch.
475
+ */
476
+ let outputStartedAtMs;
477
+ /** `deleteOriginalAssets` removing a chunk is worth saying once, not every build. */
478
+ let chunkDeletionWarned = false;
479
+ const warnDeletedChunks = (names, log) => {
480
+ if (names.length === 0 || chunkDeletionWarned) return;
481
+ chunkDeletionWarned = true;
482
+ const sample = names.slice(0, 3).join(", ");
483
+ log.warn(`deleteOriginalAssets removed ${names.length} chunk(s) (${sample}${names.length > 3 ? ", …" : ""}); requests for those paths only resolve if the server rewrites them to the compressed variant, and dynamic imports and source map links still reference the original names`);
484
+ };
485
+ const skipsWatchMode = (watchMode, log) => {
486
+ if (!watchMode || options.enableInWatchMode) return false;
487
+ log.info("watch mode detected, skipping compression (set enableInWatchMode to opt in)");
488
+ return true;
489
+ };
490
+ const plugin = {
328
491
  name: PLUGIN_NAME,
492
+ ...PACKAGE_VERSION === void 0 ? {} : { version: PACKAGE_VERSION },
493
+ meta: {
494
+ packageName: PACKAGE_NAME,
495
+ ...PACKAGE_VERSION === void 0 ? {} : { version: PACKAGE_VERSION },
496
+ description: "Compresses emitted assets with gzip, brotli and zstd through a native Rust core"
497
+ },
498
+ api: {
499
+ packageName: PACKAGE_NAME,
500
+ algorithms: options.algorithms.map((algorithm) => algorithm.algorithm),
501
+ extensions: options.algorithms.map((algorithm) => algorithm.extension),
502
+ emittedFileNames: () => [...emittedNames]
503
+ },
504
+ renderStart() {
505
+ emittedNames.clear();
506
+ outputStartedAtMs = Date.now();
507
+ },
329
508
  async generateBundle(_outputOptions, bundle) {
330
- if (options.stream) return;
331
- if (this.meta?.watchMode && !options.enableInWatchMode) {
332
- logger.info("watch mode detected, skipping compression (set enableInWatchMode to opt in)");
509
+ if (options.stream) {
510
+ outputStartedAtMs ??= Date.now();
333
511
  return;
334
512
  }
513
+ const log = createLogger(this, options.logLevel);
514
+ if (skipsWatchMode(this.meta?.watchMode, log)) return;
335
515
  const startedAt = performance.now();
336
516
  const fail = (message) => this.error(/* @__PURE__ */ new Error(`[${PLUGIN_NAME}] ${message}`));
337
- const runner = createBatchRunner(options, logger, emittedNames, (artifact, data) => {
338
- this.emitFile({
339
- type: "asset",
340
- fileName: artifact.outputFileName,
341
- source: data
342
- });
343
- }, fail);
517
+ const runner = createBatchRunner({
518
+ options,
519
+ log,
520
+ emittedNames,
521
+ isReserved: (fileName) => Object.hasOwn(bundle, fileName),
522
+ emit: (artifact, data) => {
523
+ this.emitFile({
524
+ type: "asset",
525
+ fileName: artifact.outputFileName,
526
+ source: data
527
+ });
528
+ },
529
+ fail
530
+ });
344
531
  for (const [fileName, output] of Object.entries(bundle)) {
345
532
  if (!options.filter(fileName)) continue;
346
533
  if (COMPRESSED_EXTENSION_RE.test(fileName) || emittedNames.has(fileName)) continue;
@@ -351,54 +538,90 @@ function createCompressionPlugin(options) {
351
538
  await runner.flush();
352
539
  if (runner.processedSources.size === 0) return;
353
540
  if (runner.failures.length > 0) fail(`${runner.failures.length} compression task(s) failed:\n${runner.failures.join("\n")}`);
354
- if (options.deleteOriginalAssets) for (const fileName of runner.processedSources) {
355
- if ((runner.emittedBySource.get(fileName) ?? 0) === 0) logger.warn(`deleteOriginalAssets removed "${fileName}" even though no compressed variant was emitted for it`);
356
- delete bundle[fileName];
541
+ if (options.deleteOriginalAssets) {
542
+ const deletedChunks = [];
543
+ for (const fileName of runner.processedSources) {
544
+ if ((runner.emittedBySource.get(fileName) ?? 0) === 0) {
545
+ log.warn(`kept "${fileName}": deleteOriginalAssets only removes an asset once a compressed variant of it exists`);
546
+ continue;
547
+ }
548
+ if (bundle[fileName]?.type === "chunk") deletedChunks.push(fileName);
549
+ delete bundle[fileName];
550
+ }
551
+ warnDeletedChunks(deletedChunks, log);
357
552
  }
358
- logSummary(logger, runner.stats, startedAt);
553
+ const summary = formatSummary(runner.stats, startedAt);
554
+ if (summary !== void 0) log.info(summary);
359
555
  },
360
556
  writeBundle: {
361
557
  order: "post",
362
- async handler(outputOptions) {
558
+ async handler(outputOptions, bundle) {
363
559
  if (!options.stream) return;
364
- if (this.meta?.watchMode && !options.enableInWatchMode) {
365
- logger.info("watch mode detected, skipping compression (set enableInWatchMode to opt in)");
366
- return;
367
- }
560
+ const log = createLogger(this, options.logLevel);
561
+ if (skipsWatchMode(this.meta?.watchMode, log)) return;
368
562
  const outDir = outputOptions.dir !== void 0 ? node_path.default.resolve(outputOptions.dir) : outputOptions.file !== void 0 ? node_path.default.resolve(node_path.default.dirname(outputOptions.file)) : void 0;
369
563
  if (outDir === void 0) {
370
- logger.warn("stream mode could not determine the output directory, skipping compression");
564
+ log.warn("stream mode could not determine the output directory, skipping compression");
371
565
  return;
372
566
  }
373
567
  const startedAt = performance.now();
374
568
  const fail = (message) => this.error(/* @__PURE__ */ new Error(`[${PLUGIN_NAME}] ${message}`));
375
- const runner = createBatchRunner(options, logger, emittedNames, async (artifact, data) => {
376
- const target = node_path.default.join(outDir, artifact.outputFileName);
377
- await (0, node_fs_promises.mkdir)(node_path.default.dirname(target), { recursive: true });
378
- await (0, node_fs_promises.writeFile)(target, data);
379
- }, fail);
380
- for (const absolute of await walkFiles(outDir)) {
381
- const fileName = node_path.default.relative(outDir, absolute).split(node_path.default.sep).join("/");
382
- if (!options.filter(fileName)) continue;
383
- if (COMPRESSED_EXTENSION_RE.test(fileName) || emittedNames.has(fileName)) continue;
384
- if ((await (0, node_fs_promises.stat)(absolute)).size < options.threshold) continue;
385
- await runner.addFile(fileName, await (0, node_fs_promises.readFile)(absolute));
569
+ const sources = await collectStreamSources(outDir, new Set(Object.keys(bundle ?? {})), outputStartedAtMs === void 0 ? -Infinity : outputStartedAtMs - MTIME_SLACK_MS, options, emittedNames);
570
+ const sourceNames = new Set(sources.map((source) => source.fileName));
571
+ const ownedNames = new Set(sources.filter((source) => source.ownedByBuild).map((source) => source.fileName));
572
+ const runner = createBatchRunner({
573
+ options,
574
+ log,
575
+ emittedNames,
576
+ isReserved: (fileName) => sourceNames.has(fileName),
577
+ emit: async (artifact, data) => {
578
+ const target = node_path.default.join(outDir, artifact.outputFileName);
579
+ await (0, node_fs_promises.mkdir)(node_path.default.dirname(target), { recursive: true });
580
+ await (0, node_fs_promises.writeFile)(target, data);
581
+ },
582
+ fail
583
+ });
584
+ const batchBytes = options.chunkSize > 0 ? options.chunkSize : STREAM_DEFAULT_CHUNK_SIZE;
585
+ for (const group of groupBySize(sources, batchBytes)) {
586
+ const buffers = await mapConcurrent(group, group.length, (source) => (0, node_fs_promises.readFile)(source.absolute));
587
+ for (const [index, source] of group.entries()) await runner.addFile(source.fileName, buffers[index]);
386
588
  }
387
589
  await runner.flush();
388
590
  if (runner.processedSources.size === 0) return;
389
591
  if (runner.failures.length > 0) fail(`${runner.failures.length} compression task(s) failed:\n${runner.failures.join("\n")}`);
390
- if (options.deleteOriginalAssets) for (const fileName of runner.processedSources) {
391
- if ((runner.emittedBySource.get(fileName) ?? 0) === 0) logger.warn(`deleteOriginalAssets removed "${fileName}" even though no compressed variant was emitted for it`);
392
- await (0, node_fs_promises.unlink)(node_path.default.join(outDir, fileName));
592
+ if (options.deleteOriginalAssets) {
593
+ const deletedChunks = [];
594
+ for (const fileName of runner.processedSources) {
595
+ if ((runner.emittedBySource.get(fileName) ?? 0) === 0) {
596
+ log.warn(`kept "${fileName}": deleteOriginalAssets only removes an asset once a compressed variant of it exists`);
597
+ continue;
598
+ }
599
+ if (!ownedNames.has(fileName)) {
600
+ log.warn(`kept "${fileName}": deleteOriginalAssets only removes files this build wrote to the output directory`);
601
+ continue;
602
+ }
603
+ if (bundle?.[fileName]?.type === "chunk") deletedChunks.push(fileName);
604
+ await (0, node_fs_promises.unlink)(node_path.default.join(outDir, fileName));
605
+ }
606
+ warnDeletedChunks(deletedChunks, log);
393
607
  }
394
- logSummary(logger, runner.stats, startedAt);
608
+ const summary = formatSummary(runner.stats, startedAt);
609
+ if (summary !== void 0) log.info(summary);
395
610
  }
396
611
  }
397
- }, { apply: "build" });
612
+ };
613
+ plugin.apply = "build";
614
+ return plugin;
398
615
  }
399
616
  //#endregion
400
617
  //#region ts/index.ts
401
618
  /**
619
+ * @medicomind/rolldown-compression
620
+ *
621
+ * Rolldown plugin that compresses emitted assets with gzip, brotli and zstd
622
+ * using a native Rust core (napi-rs + rayon).
623
+ */
624
+ /**
402
625
  * Create the compression plugin.
403
626
  *
404
627
  * Options are validated eagerly: invalid levels, algorithm names, thresholds