@medicomind/rolldown-compression 0.2.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.
@@ -0,0 +1,112 @@
1
+ import { Plugin } from "rolldown";
2
+ //#region ts/options.d.ts
3
+ /** Accepted algorithm names, including aliases. */
4
+ type AlgorithmName = 'gzip' | 'gz' | 'brotli' | 'br' | 'brotliCompress' | 'zstd' | 'zstandard';
5
+ /** Canonical algorithm names after alias normalization. */
6
+ type CanonicalAlgorithm = 'gzip' | 'brotli' | 'zstd';
7
+ /** Gzip options. `level`: 0-9, default 6. */
8
+ interface GzipOptions {
9
+ level?: number;
10
+ }
11
+ /** Brotli options. `quality`: 0-11, default 11. `windowBits`: 10-24, default 22. */
12
+ interface BrotliOptions {
13
+ quality?: number;
14
+ windowBits?: number;
15
+ }
16
+ /** Zstandard options. `level`: 1-22, default 19. */
17
+ interface ZstdOptions {
18
+ level?: number;
19
+ }
20
+ /** A normalized `(algorithm, options)` pair produced by {@link defineAlgorithm}. */
21
+ interface DefineAlgorithmResult {
22
+ readonly algorithm: CanonicalAlgorithm;
23
+ readonly options: GzipOptions | BrotliOptions | ZstdOptions;
24
+ }
25
+ /** Pattern or callback used to derive compressed artifact names. */
26
+ type FilenameOption = string | ((fileName: string, algorithm: CanonicalAlgorithm) => string);
27
+ type LogLevel = 'silent' | 'error' | 'warn' | 'info';
28
+ /** Options accepted by {@link compression}. */
29
+ interface CompressionOptions {
30
+ /**
31
+ * Files to compress. Strings are picomatch globs, RegExps are tested
32
+ * against the output file name.
33
+ * @default /\.(html|xml|css|json|js|mjs|svg|yaml|yml|toml|txt|wasm)$/
34
+ */
35
+ include?: string | RegExp | Array<string | RegExp>;
36
+ /** Files to exclude. Takes precedence over `include`. */
37
+ exclude?: string | RegExp | Array<string | RegExp>;
38
+ /**
39
+ * Minimum size of the original asset, in bytes, for it to be compressed.
40
+ * @default 0
41
+ */
42
+ threshold?: number;
43
+ /**
44
+ * Algorithms to run, as names or {@link defineAlgorithm} results.
45
+ * @default ['gzip', 'brotli']
46
+ */
47
+ algorithms?: Array<AlgorithmName | DefineAlgorithmResult>;
48
+ /**
49
+ * Name of the emitted artifact. Tokens: `[path]` (directory, with trailing
50
+ * slash), `[base]` (file name with extension), `[name]`, `[ext]` (with
51
+ * leading dot), `[hash]` (8-char content hash). Function form receives the
52
+ * original file name and the canonical algorithm name.
53
+ * @default '[path][base]' + per-algorithm extension (.gz / .br / .zst)
54
+ */
55
+ filename?: FilenameOption;
56
+ /**
57
+ * Remove the original asset from the bundle once all algorithms have
58
+ * processed it.
59
+ * @default false
60
+ */
61
+ deleteOriginalAssets?: boolean;
62
+ /**
63
+ * Do not emit artifacts whose compressed size is >= the original size.
64
+ * @default true
65
+ */
66
+ skipIfLargerOrEqual?: boolean;
67
+ /**
68
+ * Native worker threads used for compression. `0` = number of logical CPUs.
69
+ * @default 0
70
+ */
71
+ concurrency?: number;
72
+ /** @default 'info' */
73
+ logLevel?: LogLevel;
74
+ /**
75
+ * The plugin is a no-op in watch/dev mode unless this is set to `true`.
76
+ * @default false
77
+ */
78
+ enableInWatchMode?: boolean;
79
+ }
80
+ /**
81
+ * Pair an algorithm with its options, validating both eagerly.
82
+ *
83
+ * @example
84
+ * compression({ algorithms: [defineAlgorithm('brotli', { quality: 9 })] })
85
+ */
86
+ declare function defineAlgorithm(name: 'gzip' | 'gz', options?: GzipOptions): DefineAlgorithmResult;
87
+ declare function defineAlgorithm(name: 'brotli' | 'br' | 'brotliCompress', options?: BrotliOptions): DefineAlgorithmResult;
88
+ declare function defineAlgorithm(name: 'zstd' | 'zstandard', options?: ZstdOptions): DefineAlgorithmResult;
89
+ //#endregion
90
+ //#region ts/index.d.ts
91
+ /**
92
+ * Create the compression plugin.
93
+ *
94
+ * Options are validated eagerly: invalid levels, algorithm names, thresholds
95
+ * etc. throw here rather than during the build.
96
+ *
97
+ * @example
98
+ * import { defineConfig } from 'rolldown'
99
+ * import { compression, defineAlgorithm } from '@medicomind/rolldown-compression'
100
+ *
101
+ * export default defineConfig({
102
+ * plugins: [
103
+ * compression({
104
+ * threshold: 1024,
105
+ * algorithms: ['gzip', defineAlgorithm('brotli', { quality: 11 })],
106
+ * }),
107
+ * ],
108
+ * })
109
+ */
110
+ declare function compression(options?: CompressionOptions): Plugin;
111
+ //#endregion
112
+ export { type AlgorithmName, type BrotliOptions, type CanonicalAlgorithm, type CompressionOptions, type DefineAlgorithmResult, type FilenameOption, type GzipOptions, type LogLevel, type ZstdOptions, compression, defineAlgorithm };
package/dist/index.mjs ADDED
@@ -0,0 +1,284 @@
1
+ import { createRequire } from "node:module";
2
+ import { createHash } from "node:crypto";
3
+ import path from "node:path";
4
+ import { createFilter } from "@rollup/pluginutils";
5
+ //#region ts/options.ts
6
+ /**
7
+ * Option types, normalization and validation for the public API.
8
+ *
9
+ * Everything here is validated eagerly at `compression()` call time so that
10
+ * misconfiguration fails when the config is evaluated, not mid-build.
11
+ */
12
+ const DEFAULT_INCLUDE = /\.(html|xml|css|json|js|mjs|svg|yaml|yml|toml|txt|wasm)$/;
13
+ const ALIASES = {
14
+ gzip: "gzip",
15
+ gz: "gzip",
16
+ brotli: "brotli",
17
+ br: "brotli",
18
+ brotliCompress: "brotli",
19
+ zstd: "zstd",
20
+ zstandard: "zstd"
21
+ };
22
+ const EXTENSIONS = {
23
+ gzip: ".gz",
24
+ brotli: ".br",
25
+ zstd: ".zst"
26
+ };
27
+ const DEFAULT_LEVELS = {
28
+ gzip: 6,
29
+ brotli: 11,
30
+ zstd: 19
31
+ };
32
+ const LEVEL_RANGES = {
33
+ gzip: [0, 9],
34
+ brotli: [0, 11],
35
+ zstd: [1, 22]
36
+ };
37
+ const LOG_LEVELS = [
38
+ "silent",
39
+ "error",
40
+ "warn",
41
+ "info"
42
+ ];
43
+ /** File extensions produced by this plugin; used by the re-compression guard. */
44
+ const COMPRESSED_EXTENSION_RE = /\.(gz|br|zst)$/i;
45
+ var OptionValidationError = class extends Error {
46
+ constructor(message) {
47
+ super(`[rolldown-compression] ${message}`);
48
+ this.name = "OptionValidationError";
49
+ }
50
+ };
51
+ function assertIntegerInRange(value, [min, max], label) {
52
+ if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) throw new OptionValidationError(`invalid ${label}: expected an integer between ${min} and ${max}, got ${JSON.stringify(value)}`);
53
+ }
54
+ /** Normalize an algorithm name, throwing on unknown input. */
55
+ function normalizeAlgorithmName(name) {
56
+ const canonical = ALIASES[name];
57
+ if (canonical === void 0) throw new OptionValidationError(`unknown algorithm ${JSON.stringify(name)}: expected one of ${Object.keys(ALIASES).join(", ")}`);
58
+ return canonical;
59
+ }
60
+ function defineAlgorithm(name, options = {}) {
61
+ const algorithm = normalizeAlgorithmName(name);
62
+ validateAlgorithmOptions(algorithm, options);
63
+ return Object.freeze({
64
+ algorithm,
65
+ options: Object.freeze({ ...options })
66
+ });
67
+ }
68
+ function validateAlgorithmOptions(algorithm, options) {
69
+ if (algorithm === "brotli") {
70
+ const { quality, windowBits } = options;
71
+ if (quality !== void 0) assertIntegerInRange(quality, LEVEL_RANGES.brotli, "brotli quality");
72
+ if (windowBits !== void 0) assertIntegerInRange(windowBits, [10, 24], "brotli windowBits");
73
+ return;
74
+ }
75
+ const { level } = options;
76
+ if (level !== void 0) assertIntegerInRange(level, LEVEL_RANGES[algorithm], `${algorithm} level`);
77
+ }
78
+ function resolveAlgorithm(entry) {
79
+ const { algorithm, options } = typeof entry === "string" ? {
80
+ algorithm: normalizeAlgorithmName(entry),
81
+ options: {}
82
+ } : validateDefined(entry);
83
+ const resolved = {
84
+ algorithm,
85
+ level: algorithm === "brotli" ? options.quality ?? DEFAULT_LEVELS.brotli : options.level ?? DEFAULT_LEVELS[algorithm],
86
+ extension: EXTENSIONS[algorithm]
87
+ };
88
+ if (algorithm === "brotli") {
89
+ const { windowBits } = options;
90
+ if (windowBits !== void 0) resolved.windowBits = windowBits;
91
+ }
92
+ return resolved;
93
+ }
94
+ function validateDefined(entry) {
95
+ if (entry === null || typeof entry !== "object" || typeof entry.algorithm !== "string" || entry.options === null || typeof entry.options !== "object") throw new OptionValidationError(`invalid algorithms entry ${JSON.stringify(entry)}: expected an algorithm name or a defineAlgorithm() result`);
96
+ const algorithm = normalizeAlgorithmName(entry.algorithm);
97
+ validateAlgorithmOptions(algorithm, entry.options);
98
+ return {
99
+ algorithm,
100
+ options: entry.options
101
+ };
102
+ }
103
+ /** Validate and normalize user options. Throws at `compression()` call time. */
104
+ function resolveOptions(options = {}) {
105
+ if (options === null || typeof options !== "object") throw new OptionValidationError("options must be an object");
106
+ const { include = DEFAULT_INCLUDE, exclude, threshold = 0, algorithms = ["gzip", "brotli"], filename, deleteOriginalAssets = false, skipIfLargerOrEqual = true, concurrency = 0, logLevel = "info", enableInWatchMode = false } = options;
107
+ if (typeof threshold !== "number" || Number.isNaN(threshold) || threshold < 0) throw new OptionValidationError(`invalid threshold: expected a non-negative number, got ${JSON.stringify(threshold)}`);
108
+ if (!Number.isInteger(concurrency) || concurrency < 0) throw new OptionValidationError(`invalid concurrency: expected a non-negative integer, got ${JSON.stringify(concurrency)}`);
109
+ if (!LOG_LEVELS.includes(logLevel)) throw new OptionValidationError(`invalid logLevel ${JSON.stringify(logLevel)}: expected one of ${LOG_LEVELS.join(", ")}`);
110
+ if (filename !== void 0 && typeof filename !== "string" && typeof filename !== "function") throw new OptionValidationError("invalid filename: expected a string pattern or a function");
111
+ if (!Array.isArray(algorithms) || algorithms.length === 0) throw new OptionValidationError("invalid algorithms: expected a non-empty array");
112
+ return {
113
+ filter: createFilter(include, exclude, { resolve: false }),
114
+ threshold,
115
+ algorithms: algorithms.map(resolveAlgorithm),
116
+ filename,
117
+ deleteOriginalAssets: Boolean(deleteOriginalAssets),
118
+ skipIfLargerOrEqual: Boolean(skipIfLargerOrEqual),
119
+ concurrency,
120
+ logLevel,
121
+ enableInWatchMode: Boolean(enableInWatchMode)
122
+ };
123
+ }
124
+ /**
125
+ * Resolve the emitted file name for a compressed artifact.
126
+ *
127
+ * Supported tokens: `[path]`, `[base]`, `[name]`, `[ext]`, `[hash]`.
128
+ */
129
+ function resolveOutputFileName(filename, fileName, algorithm, source) {
130
+ if (typeof filename === "function") return filename(fileName, algorithm.algorithm);
131
+ const pattern = filename ?? `[path][base]${algorithm.extension}`;
132
+ const dir = path.posix.dirname(fileName);
133
+ const base = path.posix.basename(fileName);
134
+ const ext = path.posix.extname(fileName);
135
+ return pattern.replaceAll("[path]", dir === "." ? "" : `${dir}/`).replaceAll("[base]", base).replaceAll("[name]", base.slice(0, base.length - ext.length)).replaceAll("[ext]", ext).replaceAll("[hash]", () => createHash("sha256").update(source).digest("hex").slice(0, 8));
136
+ }
137
+ /**
138
+ * Compress a batch of buffers in the native rayon thread pool, off the JS
139
+ * main thread. `tasks[i]` describes how to compress `buffers[i]`.
140
+ */
141
+ const compressBuffers = createRequire(import.meta.url)("../index.js").compressBuffers;
142
+ //#endregion
143
+ //#region ts/plugin.ts
144
+ const PLUGIN_NAME = "rolldown-compression";
145
+ const LOG_PRIORITY = {
146
+ silent: 0,
147
+ error: 1,
148
+ warn: 2,
149
+ info: 3
150
+ };
151
+ function createLogger(level) {
152
+ const priority = LOG_PRIORITY[level];
153
+ const prefix = `[${PLUGIN_NAME}]`;
154
+ return {
155
+ info: (message) => {
156
+ if (priority >= LOG_PRIORITY.info) console.info(`${prefix} ${message}`);
157
+ },
158
+ warn: (message) => {
159
+ if (priority >= LOG_PRIORITY.warn) console.warn(`${prefix} ${message}`);
160
+ },
161
+ error: (message) => {
162
+ if (priority >= LOG_PRIORITY.error) console.error(`${prefix} ${message}`);
163
+ }
164
+ };
165
+ }
166
+ function toBuffer(source) {
167
+ return typeof source === "string" ? Buffer.from(source, "utf8") : Buffer.from(source);
168
+ }
169
+ function formatBytes(bytes) {
170
+ if (bytes < 1024) return `${bytes} B`;
171
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} kB`;
172
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
173
+ }
174
+ function createCompressionPlugin(options) {
175
+ const logger = createLogger(options.logLevel);
176
+ const emittedNames = /* @__PURE__ */ new Set();
177
+ return Object.assign({
178
+ name: PLUGIN_NAME,
179
+ async generateBundle(_outputOptions, bundle) {
180
+ if (this.meta?.watchMode && !options.enableInWatchMode) {
181
+ logger.info("watch mode detected, skipping compression (set enableInWatchMode to opt in)");
182
+ return;
183
+ }
184
+ const startedAt = performance.now();
185
+ const pending = [];
186
+ const processedSources = /* @__PURE__ */ new Set();
187
+ for (const [fileName, output] of Object.entries(bundle)) {
188
+ if (!options.filter(fileName)) continue;
189
+ if (COMPRESSED_EXTENSION_RE.test(fileName) || emittedNames.has(fileName)) continue;
190
+ const buffer = toBuffer(output.type === "chunk" ? output.code : output.source);
191
+ if (buffer.byteLength < options.threshold) continue;
192
+ for (const algorithm of options.algorithms) {
193
+ const outputFileName = resolveOutputFileName(options.filename, fileName, algorithm, buffer);
194
+ if (outputFileName === fileName) this.error(/* @__PURE__ */ new Error(`[${PLUGIN_NAME}] the filename option resolved "${fileName}" (${algorithm.algorithm}) to the same name as the source asset; refusing to overwrite it`));
195
+ pending.push({
196
+ task: {
197
+ fileName,
198
+ algorithm: algorithm.algorithm,
199
+ level: algorithm.level,
200
+ windowBits: algorithm.windowBits
201
+ },
202
+ buffer,
203
+ sourceFileName: fileName,
204
+ outputFileName
205
+ });
206
+ }
207
+ processedSources.add(fileName);
208
+ }
209
+ if (pending.length === 0) return;
210
+ const results = await compressBuffers(pending.map((artifact) => artifact.task), pending.map((artifact) => artifact.buffer), {
211
+ concurrency: options.concurrency,
212
+ skipIfLargerOrEqual: options.skipIfLargerOrEqual
213
+ });
214
+ const failures = [];
215
+ const emittedBySource = /* @__PURE__ */ new Map();
216
+ const stats = /* @__PURE__ */ new Map();
217
+ for (const [index, result] of results.entries()) {
218
+ const artifact = pending[index];
219
+ if (artifact === void 0) continue;
220
+ if (result.error !== void 0 && result.error !== null) {
221
+ failures.push(`${result.fileName} (${result.algorithm}): ${result.error}`);
222
+ continue;
223
+ }
224
+ if (result.skipped) {
225
+ logger.info(`skipped ${artifact.outputFileName}: ${result.algorithm} output would not be smaller than the original`);
226
+ continue;
227
+ }
228
+ this.emitFile({
229
+ type: "asset",
230
+ fileName: artifact.outputFileName,
231
+ source: result.data
232
+ });
233
+ emittedNames.add(artifact.outputFileName);
234
+ emittedBySource.set(artifact.sourceFileName, (emittedBySource.get(artifact.sourceFileName) ?? 0) + 1);
235
+ const stat = stats.get(result.algorithm) ?? {
236
+ count: 0,
237
+ originalBytes: 0,
238
+ outputBytes: 0
239
+ };
240
+ stat.count += 1;
241
+ stat.originalBytes += result.originalSize;
242
+ stat.outputBytes += result.compressedSize;
243
+ stats.set(result.algorithm, stat);
244
+ }
245
+ if (failures.length > 0) this.error(/* @__PURE__ */ new Error(`[${PLUGIN_NAME}] ${failures.length} compression task(s) failed:\n${failures.join("\n")}`));
246
+ if (options.deleteOriginalAssets) for (const fileName of processedSources) {
247
+ if ((emittedBySource.get(fileName) ?? 0) === 0) logger.warn(`deleteOriginalAssets removed "${fileName}" even though no compressed variant was emitted for it`);
248
+ delete bundle[fileName];
249
+ }
250
+ const elapsedMs = performance.now() - startedAt;
251
+ const summary = [...stats.entries()].map(([algorithm, stat]) => `${algorithm}: ${stat.count} file(s), ${formatBytes(stat.originalBytes)} -> ${formatBytes(stat.outputBytes)}`).join("; ");
252
+ if (summary.length > 0) {
253
+ const totalSaved = [...stats.values()].reduce((sum, stat) => sum + (stat.originalBytes - stat.outputBytes), 0);
254
+ logger.info(`${summary}; saved ${formatBytes(totalSaved)} in ${(elapsedMs / 1e3).toFixed(2)}s`);
255
+ }
256
+ }
257
+ }, { apply: "build" });
258
+ }
259
+ //#endregion
260
+ //#region ts/index.ts
261
+ /**
262
+ * Create the compression plugin.
263
+ *
264
+ * Options are validated eagerly: invalid levels, algorithm names, thresholds
265
+ * etc. throw here rather than during the build.
266
+ *
267
+ * @example
268
+ * import { defineConfig } from 'rolldown'
269
+ * import { compression, defineAlgorithm } from '@medicomind/rolldown-compression'
270
+ *
271
+ * export default defineConfig({
272
+ * plugins: [
273
+ * compression({
274
+ * threshold: 1024,
275
+ * algorithms: ['gzip', defineAlgorithm('brotli', { quality: 11 })],
276
+ * }),
277
+ * ],
278
+ * })
279
+ */
280
+ function compression(options) {
281
+ return createCompressionPlugin(resolveOptions(options));
282
+ }
283
+ //#endregion
284
+ export { compression, defineAlgorithm };
package/index.d.ts ADDED
@@ -0,0 +1,56 @@
1
+ /* auto-generated by NAPI-RS */
2
+ /* eslint-disable */
3
+ /** Batch-wide options for [`compress_buffers`]. */
4
+ export interface BatchOptions {
5
+ /** Worker threads; 0 or omitted = number of logical CPUs. */
6
+ concurrency?: number
7
+ /**
8
+ * Mark results whose compressed size >= original as skipped and
9
+ * return no data for them. Default: false.
10
+ */
11
+ skipIfLargerOrEqual?: boolean
12
+ }
13
+
14
+ /**
15
+ * Compress a batch of buffers off the JS main thread.
16
+ *
17
+ * `tasks[i]` describes how to compress `buffers[i]`. Algorithm names and
18
+ * levels are validated synchronously so misconfiguration fails fast;
19
+ * I/O-shaped failures during compression are reported per task via
20
+ * [`CompressResult::error`].
21
+ */
22
+ export declare function compressBuffers(tasks: Array<CompressTask>, buffers: Array<Buffer>, options?: BatchOptions | undefined | null): Promise<Array<CompressResult>>
23
+
24
+ /** Outcome of a single task within a batch. */
25
+ export interface CompressResult {
26
+ fileName: string
27
+ algorithm: string
28
+ /** Compressed bytes. Empty when `skipped` is true or `error` is set. */
29
+ data: Buffer
30
+ originalSize: number
31
+ compressedSize: number
32
+ /**
33
+ * True when compression would not shrink the input and
34
+ * `skipIfLargerOrEqual` was requested.
35
+ */
36
+ skipped: boolean
37
+ /** Per-task failure. A failed task never aborts the rest of the batch. */
38
+ error?: string
39
+ }
40
+
41
+ /**
42
+ * One compression task: pairs with the buffer at the same index in the
43
+ * `buffers` argument of [`compress_buffers`].
44
+ */
45
+ export interface CompressTask {
46
+ fileName: string
47
+ /** Canonical algorithm name: "gzip" | "brotli" | "zstd". */
48
+ algorithm: string
49
+ /**
50
+ * Compression level; algorithm default when omitted
51
+ * (gzip 6, brotli 11, zstd 19).
52
+ */
53
+ level?: number
54
+ /** Brotli only: log2 window size (10-24, default 22). */
55
+ windowBits?: number
56
+ }