@medicomind/rolldown-compression 2.3.6 → 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 +22 -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 +9 -12
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { createFilter } from "@rollup/pluginutils";
|
|
5
4
|
import { mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
6
5
|
//#region ts/options.ts
|
|
7
6
|
/**
|
|
@@ -41,6 +40,25 @@ const LOG_LEVELS = [
|
|
|
41
40
|
"warn",
|
|
42
41
|
"info"
|
|
43
42
|
];
|
|
43
|
+
function toArray(patterns) {
|
|
44
|
+
if (patterns === void 0) return [];
|
|
45
|
+
return Array.isArray(patterns) ? patterns : [patterns];
|
|
46
|
+
}
|
|
47
|
+
function createFilter(include, exclude) {
|
|
48
|
+
const includes = toArray(include);
|
|
49
|
+
const excludes = toArray(exclude);
|
|
50
|
+
const matches = (fileName, pattern) => {
|
|
51
|
+
if (typeof pattern === "string") return path.matchesGlob(fileName, pattern);
|
|
52
|
+
pattern.lastIndex = 0;
|
|
53
|
+
return pattern.test(fileName);
|
|
54
|
+
};
|
|
55
|
+
return (fileName) => {
|
|
56
|
+
if (typeof fileName !== "string" || fileName.includes("\0")) return false;
|
|
57
|
+
const normalizedFileName = fileName.replaceAll("\\", "/");
|
|
58
|
+
if (excludes.some((pattern) => matches(normalizedFileName, pattern))) return false;
|
|
59
|
+
return includes.length === 0 || includes.some((pattern) => matches(normalizedFileName, pattern));
|
|
60
|
+
};
|
|
61
|
+
}
|
|
44
62
|
/** File extensions produced by this plugin; used by the re-compression guard. */
|
|
45
63
|
const COMPRESSED_EXTENSION_RE = /\.(gz|br|zst)$/i;
|
|
46
64
|
var OptionValidationError = class extends Error {
|
|
@@ -114,7 +132,7 @@ function resolveOptions(options = {}) {
|
|
|
114
132
|
if (filename !== void 0 && typeof filename !== "string" && typeof filename !== "function") throw new OptionValidationError("invalid filename: expected a string pattern or a function");
|
|
115
133
|
if (!Array.isArray(algorithms) || algorithms.length === 0) throw new OptionValidationError("invalid algorithms: expected a non-empty array");
|
|
116
134
|
return {
|
|
117
|
-
filter: createFilter(include, exclude
|
|
135
|
+
filter: createFilter(include, exclude),
|
|
118
136
|
threshold,
|
|
119
137
|
algorithms: algorithms.map(resolveAlgorithm),
|
|
120
138
|
filename,
|
|
@@ -128,9 +146,46 @@ function resolveOptions(options = {}) {
|
|
|
128
146
|
};
|
|
129
147
|
}
|
|
130
148
|
/**
|
|
149
|
+
* Validate a resolved artifact name before anything is emitted or written.
|
|
150
|
+
*
|
|
151
|
+
* A `filename` function is arbitrary user code, so its result is checked the
|
|
152
|
+
* same way a path from outside would be: it has to stay inside the output
|
|
153
|
+
* directory and must not claim the name of the asset it was derived from.
|
|
154
|
+
* Returns the normalized name to use, or the reason it was rejected.
|
|
155
|
+
*/
|
|
156
|
+
function checkArtifactName(outputFileName, sourceFileName, algorithm) {
|
|
157
|
+
const context = `the filename option resolved "${sourceFileName}" (${algorithm})`;
|
|
158
|
+
if (typeof outputFileName !== "string" || outputFileName.length === 0) return {
|
|
159
|
+
ok: false,
|
|
160
|
+
message: `${context} to ${JSON.stringify(outputFileName)}; expected a non-empty file name relative to the output directory`
|
|
161
|
+
};
|
|
162
|
+
if (outputFileName.includes("\0")) return {
|
|
163
|
+
ok: false,
|
|
164
|
+
message: `${context} to a name containing a NUL byte`
|
|
165
|
+
};
|
|
166
|
+
const normalized = path.posix.normalize(outputFileName.replaceAll("\\", "/"));
|
|
167
|
+
if (path.posix.isAbsolute(normalized) || /^[a-zA-Z]:/.test(normalized)) return {
|
|
168
|
+
ok: false,
|
|
169
|
+
message: `${context} to the absolute path "${outputFileName}"; expected a path relative to the output directory`
|
|
170
|
+
};
|
|
171
|
+
if (normalized === ".." || normalized.startsWith("../") || normalized.endsWith("/")) return {
|
|
172
|
+
ok: false,
|
|
173
|
+
message: `${context} to "${outputFileName}", which does not name a file inside the output directory`
|
|
174
|
+
};
|
|
175
|
+
if (normalized === sourceFileName) return {
|
|
176
|
+
ok: false,
|
|
177
|
+
message: `${context} to the same name as the source asset; refusing to overwrite it`
|
|
178
|
+
};
|
|
179
|
+
return {
|
|
180
|
+
ok: true,
|
|
181
|
+
fileName: normalized
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
131
185
|
* Resolve the emitted file name for a compressed artifact.
|
|
132
186
|
*
|
|
133
|
-
* Supported tokens: `[path]`, `[base]`, `[name]`, `[ext]`, `[hash]`.
|
|
187
|
+
* Supported tokens: `[path]`, `[base]`, `[name]`, `[ext]`, `[hash]`. The
|
|
188
|
+
* result is not validated here — see {@link checkArtifactName}.
|
|
134
189
|
*/
|
|
135
190
|
function resolveOutputFileName(filename, fileName, algorithm, source) {
|
|
136
191
|
if (typeof filename === "function") return filename(fileName, algorithm.algorithm);
|
|
@@ -165,49 +220,69 @@ const compressBuffers = createRequire(import.meta.url)("../index.js").compressBu
|
|
|
165
220
|
* {@link STREAM_DEFAULT_CHUNK_SIZE} when `chunkSize` is 0 — so the whole
|
|
166
221
|
* build is never held in memory and assets written to disk by other
|
|
167
222
|
* plugins' `writeBundle` hooks are compressed as well.
|
|
223
|
+
*
|
|
224
|
+
* Rolldown runs the output hooks once per output, so the per-output state
|
|
225
|
+
* below is reset in `renderStart` rather than shared across a whole build.
|
|
168
226
|
*/
|
|
169
|
-
|
|
227
|
+
/**
|
|
228
|
+
* Rolldown asks plugins to be named with a `rolldown-plugin-` prefix so they
|
|
229
|
+
* are recognizable in logs, errors and hook traces.
|
|
230
|
+
*/
|
|
231
|
+
const PLUGIN_NAME = "rolldown-plugin-compression";
|
|
232
|
+
const PACKAGE_NAME = "@medicomind/rolldown-compression";
|
|
170
233
|
/** Source bytes per batch in stream mode when `chunkSize` is 0. */
|
|
171
234
|
const STREAM_DEFAULT_CHUNK_SIZE = 4194304;
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
};
|
|
235
|
+
/**
|
|
236
|
+
* Slack applied when deciding whether a file on disk was written by the
|
|
237
|
+
* current output. Some filesystems store mtimes at one-second granularity,
|
|
238
|
+
* and the wall clock can drift against them, so a strict comparison would
|
|
239
|
+
* occasionally misread a file the build had just produced as a leftover.
|
|
240
|
+
*/
|
|
241
|
+
const MTIME_SLACK_MS = 2e3;
|
|
242
|
+
/** Concurrent `stat` calls while scanning the output directory. */
|
|
243
|
+
const STAT_CONCURRENCY = 32;
|
|
244
|
+
/** Version of the shipping package, surfaced through `plugin.version`. */
|
|
245
|
+
const PACKAGE_VERSION = readPackageVersion();
|
|
246
|
+
function readPackageVersion() {
|
|
247
|
+
try {
|
|
248
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
249
|
+
return typeof version === "string" ? version : void 0;
|
|
250
|
+
} catch {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
192
253
|
}
|
|
193
254
|
function toBuffer(source) {
|
|
194
|
-
|
|
255
|
+
if (typeof source === "string") return Buffer.from(source, "utf8");
|
|
256
|
+
if (Buffer.isBuffer(source)) return source;
|
|
257
|
+
return Buffer.from(source.buffer, source.byteOffset, source.byteLength);
|
|
195
258
|
}
|
|
196
259
|
function formatBytes(bytes) {
|
|
197
260
|
if (bytes < 1024) return `${bytes} B`;
|
|
198
261
|
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} kB`;
|
|
199
262
|
return `${(bytes / 1048576).toFixed(2)} MB`;
|
|
200
263
|
}
|
|
264
|
+
function createLogger(context, logLevel) {
|
|
265
|
+
const verbose = logLevel === "info";
|
|
266
|
+
const warns = logLevel === "info" || logLevel === "warn";
|
|
267
|
+
const noop = () => {};
|
|
268
|
+
return {
|
|
269
|
+
debug: verbose ? (message) => context.debug?.(message) : noop,
|
|
270
|
+
info: verbose ? (message) => context.info?.(message) : noop,
|
|
271
|
+
warn: warns ? (message) => context.warn?.(message) : noop
|
|
272
|
+
};
|
|
273
|
+
}
|
|
201
274
|
/**
|
|
202
275
|
* Shared batching core: queues per-algorithm tasks, flushes them to the
|
|
203
276
|
* native module and hands successful results to `emit`. Only one batch of
|
|
204
277
|
* source buffers is referenced at a time once a flush trigger is set.
|
|
205
278
|
*/
|
|
206
|
-
function createBatchRunner(options,
|
|
279
|
+
function createBatchRunner({ options, log, emittedNames, isReserved, emit, fail }) {
|
|
207
280
|
const processedSources = /* @__PURE__ */ new Set();
|
|
208
281
|
const emittedBySource = /* @__PURE__ */ new Map();
|
|
209
282
|
const failures = [];
|
|
210
283
|
const stats = /* @__PURE__ */ new Map();
|
|
284
|
+
/** Artifact name -> the `source (algorithm)` that claimed it, for collision reporting. */
|
|
285
|
+
const claimed = /* @__PURE__ */ new Map();
|
|
211
286
|
const chunkSize = options.chunkSize > 0 ? options.chunkSize : options.stream ? STREAM_DEFAULT_CHUNK_SIZE : 0;
|
|
212
287
|
let pending = [];
|
|
213
288
|
let pendingSourceBytes = 0;
|
|
@@ -228,7 +303,7 @@ function createBatchRunner(options, logger, emittedNames, emit, fail) {
|
|
|
228
303
|
continue;
|
|
229
304
|
}
|
|
230
305
|
if (result.skipped) {
|
|
231
|
-
|
|
306
|
+
log.debug(`skipped ${artifact.outputFileName}: ${result.algorithm} output would not be smaller than the original`);
|
|
232
307
|
continue;
|
|
233
308
|
}
|
|
234
309
|
await emit(artifact, result.data);
|
|
@@ -247,8 +322,13 @@ function createBatchRunner(options, logger, emittedNames, emit, fail) {
|
|
|
247
322
|
};
|
|
248
323
|
const addFile = async (fileName, buffer) => {
|
|
249
324
|
for (const algorithm of options.algorithms) {
|
|
250
|
-
const
|
|
251
|
-
if (
|
|
325
|
+
const check = checkArtifactName(resolveOutputFileName(options.filename, fileName, algorithm, buffer), fileName, algorithm.algorithm);
|
|
326
|
+
if (!check.ok) return fail(check.message);
|
|
327
|
+
const outputFileName = check.fileName;
|
|
328
|
+
const owner = claimed.get(outputFileName);
|
|
329
|
+
if (owner !== void 0) fail(`both ${owner} and ${fileName} (${algorithm.algorithm}) resolve to the artifact "${outputFileName}"; make the filename option depend on the algorithm`);
|
|
330
|
+
if (isReserved(outputFileName)) fail(`the artifact "${outputFileName}" for ${fileName} (${algorithm.algorithm}) would overwrite a file this build already owns`);
|
|
331
|
+
claimed.set(outputFileName, `${fileName} (${algorithm.algorithm})`);
|
|
252
332
|
pending.push({
|
|
253
333
|
task: {
|
|
254
334
|
fileName,
|
|
@@ -275,12 +355,12 @@ function createBatchRunner(options, logger, emittedNames, emit, fail) {
|
|
|
275
355
|
stats
|
|
276
356
|
};
|
|
277
357
|
}
|
|
278
|
-
function
|
|
358
|
+
function formatSummary(stats, startedAt) {
|
|
279
359
|
const summary = [...stats.entries()].map(([algorithm, stat]) => `${algorithm}: ${stat.count} file(s), ${formatBytes(stat.originalBytes)} -> ${formatBytes(stat.outputBytes)}`).join("; ");
|
|
280
|
-
if (summary.length === 0) return;
|
|
360
|
+
if (summary.length === 0) return void 0;
|
|
281
361
|
const totalSaved = [...stats.values()].reduce((sum, stat) => sum + (stat.originalBytes - stat.outputBytes), 0);
|
|
282
362
|
const elapsedMs = performance.now() - startedAt;
|
|
283
|
-
|
|
363
|
+
return `${summary}; saved ${formatBytes(totalSaved)} in ${(elapsedMs / 1e3).toFixed(2)}s`;
|
|
284
364
|
}
|
|
285
365
|
/** Recursively list every file under `root`, in a deterministic order. */
|
|
286
366
|
async function walkFiles(root) {
|
|
@@ -297,26 +377,133 @@ async function walkFiles(root) {
|
|
|
297
377
|
await visit(root);
|
|
298
378
|
return files;
|
|
299
379
|
}
|
|
380
|
+
/** Run `task` over `items` with at most `limit` in flight, preserving order. */
|
|
381
|
+
async function mapConcurrent(items, limit, task) {
|
|
382
|
+
const results = new Array(items.length);
|
|
383
|
+
let next = 0;
|
|
384
|
+
const worker = async () => {
|
|
385
|
+
for (let index = next++; index < items.length; index = next++) results[index] = await task(items[index]);
|
|
386
|
+
};
|
|
387
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
388
|
+
return results;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Every file under `outDir` eligible for compression, each tagged with whether
|
|
392
|
+
* this build produced it.
|
|
393
|
+
*
|
|
394
|
+
* Compression stays deliberately broad — whatever is in the output directory
|
|
395
|
+
* and matches the filter gets compressed, which is the point of stream mode.
|
|
396
|
+
* Deletion does not: removing a file is not undoable, so it is restricted to
|
|
397
|
+
* files the build owns and never reaches leftovers from an earlier build in a
|
|
398
|
+
* directory that was not emptied.
|
|
399
|
+
*/
|
|
400
|
+
async function collectStreamSources(outDir, bundleNames, writtenSinceMs, options, emittedNames) {
|
|
401
|
+
const absolutePaths = await walkFiles(outDir);
|
|
402
|
+
const infos = await mapConcurrent(absolutePaths, STAT_CONCURRENCY, async (absolute) => {
|
|
403
|
+
try {
|
|
404
|
+
return await stat(absolute);
|
|
405
|
+
} catch {
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
const sources = [];
|
|
410
|
+
for (const [index, absolute] of absolutePaths.entries()) {
|
|
411
|
+
const info = infos[index];
|
|
412
|
+
if (info === void 0) continue;
|
|
413
|
+
const fileName = path.relative(outDir, absolute).split(path.sep).join("/");
|
|
414
|
+
if (!options.filter(fileName)) continue;
|
|
415
|
+
if (COMPRESSED_EXTENSION_RE.test(fileName) || emittedNames.has(fileName)) continue;
|
|
416
|
+
if (info.size < options.threshold) continue;
|
|
417
|
+
sources.push({
|
|
418
|
+
fileName,
|
|
419
|
+
absolute,
|
|
420
|
+
size: info.size,
|
|
421
|
+
ownedByBuild: bundleNames.has(fileName) || info.mtimeMs >= writtenSinceMs
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
return sources;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Split sources into read groups that mirror the batch boundaries
|
|
428
|
+
* {@link createBatchRunner} would pick, so a group can be read concurrently
|
|
429
|
+
* without ever holding more than one batch of file contents in memory.
|
|
430
|
+
*/
|
|
431
|
+
function* groupBySize(sources, maxBytes) {
|
|
432
|
+
let group = [];
|
|
433
|
+
let bytes = 0;
|
|
434
|
+
for (const source of sources) {
|
|
435
|
+
group.push(source);
|
|
436
|
+
bytes += source.size;
|
|
437
|
+
if (bytes >= maxBytes) {
|
|
438
|
+
yield group;
|
|
439
|
+
group = [];
|
|
440
|
+
bytes = 0;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (group.length > 0) yield group;
|
|
444
|
+
}
|
|
300
445
|
function createCompressionPlugin(options) {
|
|
301
|
-
const logger = createLogger(options.logLevel);
|
|
302
446
|
const emittedNames = /* @__PURE__ */ new Set();
|
|
303
|
-
|
|
447
|
+
/**
|
|
448
|
+
* Wall-clock start of the current output. Stream mode compares file mtimes
|
|
449
|
+
* against it to tell what this build wrote from what it merely found in the
|
|
450
|
+
* output directory, which is what `deleteOriginalAssets` is allowed to touch.
|
|
451
|
+
*/
|
|
452
|
+
let outputStartedAtMs;
|
|
453
|
+
/** `deleteOriginalAssets` removing a chunk is worth saying once, not every build. */
|
|
454
|
+
let chunkDeletionWarned = false;
|
|
455
|
+
const warnDeletedChunks = (names, log) => {
|
|
456
|
+
if (names.length === 0 || chunkDeletionWarned) return;
|
|
457
|
+
chunkDeletionWarned = true;
|
|
458
|
+
const sample = names.slice(0, 3).join(", ");
|
|
459
|
+
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`);
|
|
460
|
+
};
|
|
461
|
+
const skipsWatchMode = (watchMode, log) => {
|
|
462
|
+
if (!watchMode || options.enableInWatchMode) return false;
|
|
463
|
+
log.info("watch mode detected, skipping compression (set enableInWatchMode to opt in)");
|
|
464
|
+
return true;
|
|
465
|
+
};
|
|
466
|
+
const plugin = {
|
|
304
467
|
name: PLUGIN_NAME,
|
|
468
|
+
...PACKAGE_VERSION === void 0 ? {} : { version: PACKAGE_VERSION },
|
|
469
|
+
meta: {
|
|
470
|
+
packageName: PACKAGE_NAME,
|
|
471
|
+
...PACKAGE_VERSION === void 0 ? {} : { version: PACKAGE_VERSION },
|
|
472
|
+
description: "Compresses emitted assets with gzip, brotli and zstd through a native Rust core"
|
|
473
|
+
},
|
|
474
|
+
api: {
|
|
475
|
+
packageName: PACKAGE_NAME,
|
|
476
|
+
algorithms: options.algorithms.map((algorithm) => algorithm.algorithm),
|
|
477
|
+
extensions: options.algorithms.map((algorithm) => algorithm.extension),
|
|
478
|
+
emittedFileNames: () => [...emittedNames]
|
|
479
|
+
},
|
|
480
|
+
renderStart() {
|
|
481
|
+
emittedNames.clear();
|
|
482
|
+
outputStartedAtMs = Date.now();
|
|
483
|
+
},
|
|
305
484
|
async generateBundle(_outputOptions, bundle) {
|
|
306
|
-
if (options.stream)
|
|
307
|
-
|
|
308
|
-
logger.info("watch mode detected, skipping compression (set enableInWatchMode to opt in)");
|
|
485
|
+
if (options.stream) {
|
|
486
|
+
outputStartedAtMs ??= Date.now();
|
|
309
487
|
return;
|
|
310
488
|
}
|
|
489
|
+
const log = createLogger(this, options.logLevel);
|
|
490
|
+
if (skipsWatchMode(this.meta?.watchMode, log)) return;
|
|
311
491
|
const startedAt = performance.now();
|
|
312
492
|
const fail = (message) => this.error(/* @__PURE__ */ new Error(`[${PLUGIN_NAME}] ${message}`));
|
|
313
|
-
const runner = createBatchRunner(
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
493
|
+
const runner = createBatchRunner({
|
|
494
|
+
options,
|
|
495
|
+
log,
|
|
496
|
+
emittedNames,
|
|
497
|
+
isReserved: (fileName) => Object.hasOwn(bundle, fileName),
|
|
498
|
+
emit: (artifact, data) => {
|
|
499
|
+
this.emitFile({
|
|
500
|
+
type: "asset",
|
|
501
|
+
fileName: artifact.outputFileName,
|
|
502
|
+
source: data
|
|
503
|
+
});
|
|
504
|
+
},
|
|
505
|
+
fail
|
|
506
|
+
});
|
|
320
507
|
for (const [fileName, output] of Object.entries(bundle)) {
|
|
321
508
|
if (!options.filter(fileName)) continue;
|
|
322
509
|
if (COMPRESSED_EXTENSION_RE.test(fileName) || emittedNames.has(fileName)) continue;
|
|
@@ -327,54 +514,90 @@ function createCompressionPlugin(options) {
|
|
|
327
514
|
await runner.flush();
|
|
328
515
|
if (runner.processedSources.size === 0) return;
|
|
329
516
|
if (runner.failures.length > 0) fail(`${runner.failures.length} compression task(s) failed:\n${runner.failures.join("\n")}`);
|
|
330
|
-
if (options.deleteOriginalAssets)
|
|
331
|
-
|
|
332
|
-
|
|
517
|
+
if (options.deleteOriginalAssets) {
|
|
518
|
+
const deletedChunks = [];
|
|
519
|
+
for (const fileName of runner.processedSources) {
|
|
520
|
+
if ((runner.emittedBySource.get(fileName) ?? 0) === 0) {
|
|
521
|
+
log.warn(`kept "${fileName}": deleteOriginalAssets only removes an asset once a compressed variant of it exists`);
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (bundle[fileName]?.type === "chunk") deletedChunks.push(fileName);
|
|
525
|
+
delete bundle[fileName];
|
|
526
|
+
}
|
|
527
|
+
warnDeletedChunks(deletedChunks, log);
|
|
333
528
|
}
|
|
334
|
-
|
|
529
|
+
const summary = formatSummary(runner.stats, startedAt);
|
|
530
|
+
if (summary !== void 0) log.info(summary);
|
|
335
531
|
},
|
|
336
532
|
writeBundle: {
|
|
337
533
|
order: "post",
|
|
338
|
-
async handler(outputOptions) {
|
|
534
|
+
async handler(outputOptions, bundle) {
|
|
339
535
|
if (!options.stream) return;
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
return;
|
|
343
|
-
}
|
|
536
|
+
const log = createLogger(this, options.logLevel);
|
|
537
|
+
if (skipsWatchMode(this.meta?.watchMode, log)) return;
|
|
344
538
|
const outDir = outputOptions.dir !== void 0 ? path.resolve(outputOptions.dir) : outputOptions.file !== void 0 ? path.resolve(path.dirname(outputOptions.file)) : void 0;
|
|
345
539
|
if (outDir === void 0) {
|
|
346
|
-
|
|
540
|
+
log.warn("stream mode could not determine the output directory, skipping compression");
|
|
347
541
|
return;
|
|
348
542
|
}
|
|
349
543
|
const startedAt = performance.now();
|
|
350
544
|
const fail = (message) => this.error(/* @__PURE__ */ new Error(`[${PLUGIN_NAME}] ${message}`));
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
545
|
+
const sources = await collectStreamSources(outDir, new Set(Object.keys(bundle ?? {})), outputStartedAtMs === void 0 ? -Infinity : outputStartedAtMs - MTIME_SLACK_MS, options, emittedNames);
|
|
546
|
+
const sourceNames = new Set(sources.map((source) => source.fileName));
|
|
547
|
+
const ownedNames = new Set(sources.filter((source) => source.ownedByBuild).map((source) => source.fileName));
|
|
548
|
+
const runner = createBatchRunner({
|
|
549
|
+
options,
|
|
550
|
+
log,
|
|
551
|
+
emittedNames,
|
|
552
|
+
isReserved: (fileName) => sourceNames.has(fileName),
|
|
553
|
+
emit: async (artifact, data) => {
|
|
554
|
+
const target = path.join(outDir, artifact.outputFileName);
|
|
555
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
556
|
+
await writeFile(target, data);
|
|
557
|
+
},
|
|
558
|
+
fail
|
|
559
|
+
});
|
|
560
|
+
const batchBytes = options.chunkSize > 0 ? options.chunkSize : STREAM_DEFAULT_CHUNK_SIZE;
|
|
561
|
+
for (const group of groupBySize(sources, batchBytes)) {
|
|
562
|
+
const buffers = await mapConcurrent(group, group.length, (source) => readFile(source.absolute));
|
|
563
|
+
for (const [index, source] of group.entries()) await runner.addFile(source.fileName, buffers[index]);
|
|
362
564
|
}
|
|
363
565
|
await runner.flush();
|
|
364
566
|
if (runner.processedSources.size === 0) return;
|
|
365
567
|
if (runner.failures.length > 0) fail(`${runner.failures.length} compression task(s) failed:\n${runner.failures.join("\n")}`);
|
|
366
|
-
if (options.deleteOriginalAssets)
|
|
367
|
-
|
|
368
|
-
|
|
568
|
+
if (options.deleteOriginalAssets) {
|
|
569
|
+
const deletedChunks = [];
|
|
570
|
+
for (const fileName of runner.processedSources) {
|
|
571
|
+
if ((runner.emittedBySource.get(fileName) ?? 0) === 0) {
|
|
572
|
+
log.warn(`kept "${fileName}": deleteOriginalAssets only removes an asset once a compressed variant of it exists`);
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
if (!ownedNames.has(fileName)) {
|
|
576
|
+
log.warn(`kept "${fileName}": deleteOriginalAssets only removes files this build wrote to the output directory`);
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
if (bundle?.[fileName]?.type === "chunk") deletedChunks.push(fileName);
|
|
580
|
+
await unlink(path.join(outDir, fileName));
|
|
581
|
+
}
|
|
582
|
+
warnDeletedChunks(deletedChunks, log);
|
|
369
583
|
}
|
|
370
|
-
|
|
584
|
+
const summary = formatSummary(runner.stats, startedAt);
|
|
585
|
+
if (summary !== void 0) log.info(summary);
|
|
371
586
|
}
|
|
372
587
|
}
|
|
373
|
-
}
|
|
588
|
+
};
|
|
589
|
+
plugin.apply = "build";
|
|
590
|
+
return plugin;
|
|
374
591
|
}
|
|
375
592
|
//#endregion
|
|
376
593
|
//#region ts/index.ts
|
|
377
594
|
/**
|
|
595
|
+
* @medicomind/rolldown-compression
|
|
596
|
+
*
|
|
597
|
+
* Rolldown plugin that compresses emitted assets with gzip, brotli and zstd
|
|
598
|
+
* using a native Rust core (napi-rs + rayon).
|
|
599
|
+
*/
|
|
600
|
+
/**
|
|
378
601
|
* Create the compression plugin.
|
|
379
602
|
*
|
|
380
603
|
* Options are validated eagerly: invalid levels, algorithm names, thresholds
|