@medicomind/rolldown-compression 2.3.5 → 2.4.0
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 +28 -0
- package/README.md +36 -5
- package/dist/index.cjs +293 -70
- package/dist/index.d.cts +60 -9
- package/dist/index.d.mts +60 -9
- package/dist/index.mjs +293 -70
- package/index.js +54 -54
- package/package.json +15 -18
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# @medicomind/rolldown-compression
|
|
2
2
|
|
|
3
|
+
## 2.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [`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.
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
- `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.
|
|
11
|
+
- 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.
|
|
12
|
+
- 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.
|
|
13
|
+
- 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.
|
|
14
|
+
- Per-file detail (`skipped …`) moved from `this.info` to `this.debug`; the build summary stays on `this.info`.
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- [`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.
|
|
20
|
+
|
|
21
|
+
- [`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.
|
|
22
|
+
|
|
23
|
+
- [`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.
|
|
24
|
+
|
|
25
|
+
## 2.3.6
|
|
26
|
+
|
|
27
|
+
### Patch Changes
|
|
28
|
+
|
|
29
|
+
- [#36](https://github.com/Medico-Mind/rolldown-compression/pull/36) [`0b67e41`](https://github.com/Medico-Mind/rolldown-compression/commit/0b67e41be90e4ae8adfe9c6232e5b1be7ef29cc2) Thanks [@Mnwa](https://github.com/Mnwa)! - Reuse Brotli encoder allocations through a process-wide cache to reduce allocation count and volume across concurrent compression jobs.
|
|
30
|
+
|
|
3
31
|
## 2.3.5
|
|
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 [
|
|
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
|
|
90
|
-
| `deleteOriginalAssets` | `boolean` | `false` | Remove the original
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
275
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
331
|
-
|
|
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(
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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)
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
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
|
-
|
|
365
|
-
|
|
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
|
-
|
|
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
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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)
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
608
|
+
const summary = formatSummary(runner.stats, startedAt);
|
|
609
|
+
if (summary !== void 0) log.info(summary);
|
|
395
610
|
}
|
|
396
611
|
}
|
|
397
|
-
}
|
|
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
|