zcompress-vite-plugin 0.1.4 → 0.1.6

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.
Files changed (4) hide show
  1. package/README.md +35 -0
  2. package/index.js +118 -6
  3. package/install.js +90 -45
  4. package/package.json +4 -2
package/README.md CHANGED
@@ -74,8 +74,17 @@ zcompress({
74
74
  })
75
75
  ```
76
76
 
77
+ 也可用环境变量(不改代码):
78
+
79
+ ```bash
80
+ export ZCOMPRESS_BINARY=/absolute/path/to/zcompress
81
+ npm run build
82
+ ```
83
+
77
84
  默认 `failOnError: true`,失败会直接中断构建(不会静默跳过压缩)。
78
85
 
86
+ 从 `v0.1.6` 开始:若找不到二进制,会自动启用 Node 内置 fallback(支持 `gzip` / `brotli`)。`zstd` 仍需 CLI 二进制。
87
+
79
88
  ## License
80
89
 
81
90
  MIT
@@ -161,8 +170,34 @@ zcompress({
161
170
  })
162
171
  ```
163
172
 
173
+ Or use env var (no code change):
174
+
175
+ ```bash
176
+ export ZCOMPRESS_BINARY=/absolute/path/to/zcompress
177
+ npm run build
178
+ ```
179
+
164
180
  Default `failOnError: true` ensures builds fail loudly (instead of silently skipping compression).
165
181
 
182
+ Since `v0.1.6`: if the binary is missing, the plugin automatically falls back to Node built-in compression for `gzip` / `brotli`. `zstd` still requires the CLI binary.
183
+
184
+ ## Maintainer Release Checklist
185
+
186
+ Before running `npm publish`:
187
+
188
+ 1. Build and upload these assets to GitHub Release `vX.Y.Z`:
189
+ - `zcompress-macos-arm64`
190
+ - `zcompress-macos-x64`
191
+ - `zcompress-linux-x64`
192
+ - `zcompress-windows-x64.exe`
193
+ 2. Run:
194
+
195
+ ```bash
196
+ npm run verify:release-assets
197
+ ```
198
+
199
+ `prepublishOnly` enforces this automatically, so broken releases are blocked.
200
+
166
201
  ## License
167
202
 
168
203
  MIT
package/index.js CHANGED
@@ -16,12 +16,18 @@
16
16
  */
17
17
 
18
18
  import { execFileSync } from 'node:child_process';
19
- import { existsSync, statSync, readdirSync } from 'node:fs';
20
- import { join, dirname, isAbsolute } from 'node:path';
19
+ import { existsSync, statSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
20
+ import { join, dirname, isAbsolute, extname } from 'node:path';
21
21
  import { fileURLToPath } from 'node:url';
22
+ import { gzipSync, brotliCompressSync, constants as zlibConstants } from 'node:zlib';
22
23
 
23
24
  const __dirname = dirname(fileURLToPath(import.meta.url));
24
25
 
26
+ const DEFAULT_EXTENSIONS = [
27
+ '.js', '.mjs', '.cjs', '.css', '.html', '.htm', '.json', '.svg',
28
+ '.png', '.jpg', '.jpeg', '.gif', '.ico', '.ttf', '.woff', '.woff2', '.xml', '.csv', '.wasm',
29
+ ];
30
+
25
31
  /**
26
32
  * @typedef {Object} ZCompressOptions
27
33
  * @property {string} [algo='gzip'] - Compression algorithm: gzip, zstd, brotli
@@ -49,14 +55,18 @@ const DEFAULT_OPTIONS = {
49
55
 
50
56
  /**
51
57
  * Find the zcompress binary.
52
- * Order: 1) downloaded by postinstall 2) local zig build 3) PATH
58
+ * Order: 1) ZCOMPRESS_BINARY env 2) downloaded by postinstall 3) local zig build 4) PATH
53
59
  */
54
60
  function findBinary() {
55
- // 1) postinstall-downloaded binary inside package
61
+ // 1) explicit env override
62
+ const envBinary = process.env.ZCOMPRESS_BINARY;
63
+ if (envBinary) return envBinary;
64
+
65
+ // 2) postinstall-downloaded binary inside package
56
66
  const packagedBin = join(__dirname, 'bin', process.platform === 'win32' ? 'zcompress.exe' : 'zcompress');
57
67
  if (existsSync(packagedBin)) return packagedBin;
58
68
 
59
- // 2) local zig build
69
+ // 3) local zig build
60
70
  const zigPaths = [
61
71
  join(process.cwd(), 'zig-out', 'bin', 'zcompress'),
62
72
  join(process.cwd(), 'zig-out', 'bin', 'zcompress.exe'),
@@ -65,7 +75,7 @@ function findBinary() {
65
75
  if (existsSync(p)) return p;
66
76
  }
67
77
 
68
- // 3) PATH
78
+ // 4) PATH
69
79
  return 'zcompress';
70
80
  }
71
81
 
@@ -143,6 +153,33 @@ export default function zcompressPlugin(userOptions = {}) {
143
153
 
144
154
  console.log(`[zcompress] ✅ Compressed ${formatSize(srcSize)} → ${formatSize(destSize)} (saved ${savedPct}%) in ${elapsed}s`);
145
155
  } catch (err) {
156
+ // Binary missing: fallback to built-in Node compression for gzip/brotli.
157
+ if (isBinaryNotFoundError(err)) {
158
+ try {
159
+ const fb = fallbackCompressAssets(outDir, compressedDir, options);
160
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
161
+ const savedPct = fb.srcSize > 0
162
+ ? ((1 - fb.destSize / fb.srcSize) * 100).toFixed(1)
163
+ : '0.0';
164
+
165
+ console.warn('[zcompress] ⚠ zcompress binary not found, using built-in Node fallback compressor.');
166
+ console.log(`[zcompress] ✅ Compressed ${formatSize(fb.srcSize)} → ${formatSize(fb.destSize)} (saved ${savedPct}%) in ${elapsed}s`);
167
+ if (fb.skipped > 0) console.log(`[zcompress] 💾 ${fb.skipped} file(s) skipped (cache)`);
168
+ return;
169
+ } catch (fallbackErr) {
170
+ const fallbackMessage = [
171
+ `[zcompress] ❌ Compression failed: ${err.message}`,
172
+ `[zcompress] Binary used: ${binary}`,
173
+ `[zcompress] Fallback also failed: ${fallbackErr.message}`,
174
+ '[zcompress] Workaround: install CLI manually (`zig build -Doptimize=ReleaseFast`) and set `binaryPath`.',
175
+ ].join('\n');
176
+
177
+ if (options.failOnError !== false) throw new Error(fallbackMessage);
178
+ console.error(fallbackMessage);
179
+ return;
180
+ }
181
+ }
182
+
146
183
  const message = [
147
184
  `[zcompress] ❌ Compression failed: ${err.message}`,
148
185
  `[zcompress] Binary used: ${binary}`,
@@ -160,6 +197,81 @@ export default function zcompressPlugin(userOptions = {}) {
160
197
  };
161
198
  }
162
199
 
200
+ function isBinaryNotFoundError(err) {
201
+ return err && (err.code === 'ENOENT' || String(err.message || '').includes('ENOENT'));
202
+ }
203
+
204
+ function shouldCompressFile(filePath, options) {
205
+ const ext = extname(filePath).toLowerCase();
206
+ const include = options.include.length > 0
207
+ ? options.include.map((e) => e.toLowerCase())
208
+ : DEFAULT_EXTENSIONS;
209
+ const exclude = options.exclude.map((e) => e.toLowerCase());
210
+
211
+ if (!include.includes(ext)) return false;
212
+ if (exclude.includes(ext)) return false;
213
+ return true;
214
+ }
215
+
216
+ function compressionSuffix(algo) {
217
+ if (algo === 'gzip') return '.gz';
218
+ if (algo === 'brotli') return '.br';
219
+ throw new Error('Node fallback currently supports only gzip and brotli. Use CLI binary for zstd.');
220
+ }
221
+
222
+ function compressBuffer(buf, options) {
223
+ if (options.algo === 'gzip') {
224
+ const level = Math.max(1, Math.min(9, Number(options.level) || 6));
225
+ return gzipSync(buf, { level });
226
+ }
227
+ if (options.algo === 'brotli') {
228
+ const quality = Math.max(1, Math.min(11, Number(options.level) || 6));
229
+ return brotliCompressSync(buf, {
230
+ params: {
231
+ [zlibConstants.BROTLI_PARAM_QUALITY]: quality,
232
+ },
233
+ });
234
+ }
235
+ throw new Error('Node fallback currently supports only gzip and brotli.');
236
+ }
237
+
238
+ function fallbackCompressAssets(outDir, compressedDir, options) {
239
+ const allFiles = [];
240
+ walkDir(outDir, (file) => allFiles.push(file));
241
+
242
+ const suffix = compressionSuffix(options.algo);
243
+ let srcSize = 0;
244
+ let destSize = 0;
245
+ let skipped = 0;
246
+
247
+ for (const file of allFiles) {
248
+ if (!shouldCompressFile(file, options)) continue;
249
+
250
+ const rel = file.slice(outDir.length + 1);
251
+ const dest = join(compressedDir, `${rel}${suffix}`);
252
+
253
+ const srcStat = statSync(file);
254
+ if (options.cache && existsSync(dest)) {
255
+ const dstStat = statSync(dest);
256
+ if (dstStat.mtimeMs >= srcStat.mtimeMs) {
257
+ skipped++;
258
+ continue;
259
+ }
260
+ }
261
+
262
+ const data = readFileSync(file);
263
+ const compressed = compressBuffer(data, options);
264
+
265
+ mkdirSync(dirname(dest), { recursive: true });
266
+ writeFileSync(dest, compressed);
267
+
268
+ srcSize += srcStat.size;
269
+ destSize += compressed.length;
270
+ }
271
+
272
+ return { srcSize, destSize, skipped };
273
+ }
274
+
163
275
  /**
164
276
  * Calculate total directory size in bytes.
165
277
  *
package/install.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  /**
4
4
  * Postinstall script — downloads the prebuilt zcompress binary from GitHub Releases.
5
- * Falls back gracefully if the binary can't be downloaded (e.g. air-gapped, unsupported platform).
5
+ * Falls back gracefully if binary can't be fetched.
6
6
  */
7
7
 
8
8
  import { createWriteStream, existsSync, chmodSync, mkdirSync, unlinkSync, readFileSync } from 'node:fs';
@@ -20,10 +20,10 @@ const pkgJson = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8')
20
20
  const VERSION = pkgJson.version;
21
21
 
22
22
  const PLATFORM_MAP = {
23
- 'darwin-arm64': 'zcompress-macos-arm64',
24
- 'darwin-x64': 'zcompress-macos-x64',
25
- 'linux-x64': 'zcompress-linux-x64',
26
- 'win32-x64': 'zcompress-windows-x64.exe',
23
+ 'darwin-arm64': 'zcompress-macos-arm64',
24
+ 'darwin-x64': 'zcompress-macos-x64',
25
+ 'linux-x64': 'zcompress-linux-x64',
26
+ 'win32-x64': 'zcompress-windows-x64.exe',
27
27
  };
28
28
 
29
29
  function getPlatformKey() {
@@ -33,8 +33,54 @@ function getPlatformKey() {
33
33
  }
34
34
 
35
35
  function getBinaryName() {
36
- const key = getPlatformKey();
37
- return PLATFORM_MAP[key] || null;
36
+ return PLATFORM_MAP[getPlatformKey()] || null;
37
+ }
38
+
39
+ function request(url) {
40
+ return new Promise((resolve, reject) => {
41
+ get(url, {
42
+ headers: {
43
+ 'User-Agent': 'zcompress-vite-plugin-install',
44
+ Accept: 'application/vnd.github+json',
45
+ },
46
+ }, (res) => resolve(res)).on('error', reject);
47
+ });
48
+ }
49
+
50
+ async function fetchJson(url) {
51
+ const res = await request(url);
52
+ if (res.statusCode !== 200) {
53
+ throw new Error(`HTTP ${res.statusCode}`);
54
+ }
55
+ const chunks = [];
56
+ for await (const chunk of res) chunks.push(chunk);
57
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
58
+ }
59
+
60
+ async function downloadToFile(url, destPath) {
61
+ const res = await request(url);
62
+
63
+ // Follow redirect (GitHub release assets usually 302)
64
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
65
+ return downloadToFile(res.headers.location, destPath);
66
+ }
67
+
68
+ if (res.statusCode !== 200) {
69
+ throw new Error(`HTTP ${res.statusCode}`);
70
+ }
71
+
72
+ const file = createWriteStream(destPath);
73
+ await pipeline(res, file);
74
+ }
75
+
76
+ async function resolveFallbackAssetUrl(binaryName) {
77
+ // Fallback strategy: use latest release asset for this platform when current version asset is missing.
78
+ const latest = await fetchJson(`https://api.github.com/repos/${REPO}/releases/latest`);
79
+ const asset = (latest.assets || []).find((a) => a.name === binaryName);
80
+ if (!asset?.browser_download_url) {
81
+ throw new Error('Latest release has no matching asset');
82
+ }
83
+ return asset.browser_download_url;
38
84
  }
39
85
 
40
86
  async function downloadBinary() {
@@ -52,56 +98,55 @@ async function downloadBinary() {
52
98
  return true;
53
99
  }
54
100
 
55
- const url = `https://github.com/${REPO}/releases/download/v${VERSION}/${binaryName}`;
56
- console.log(`[zcompress] ↓ Downloading ${url} ...`);
101
+ mkdirSync(BIN_DIR, { recursive: true });
57
102
 
103
+ const versionedUrl = `https://github.com/${REPO}/releases/download/v${VERSION}/${binaryName}`;
104
+ const tried = [];
105
+
106
+ // Attempt 1: exact npm version
58
107
  try {
59
- mkdirSync(BIN_DIR, { recursive: true });
60
-
61
- await new Promise((resolve, reject) => {
62
- const file = createWriteStream(destPath);
63
- get(url, (response) => {
64
- // Follow redirects
65
- if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
66
- get(response.headers.location, (redirectRes) => {
67
- pipeline(redirectRes, file).then(resolve).catch(reject);
68
- }).on('error', reject);
69
- return;
70
- }
71
- if (response.statusCode !== 200) {
72
- file.close();
73
- unlinkSync(destPath);
74
- reject(new Error(`HTTP ${response.statusCode}`));
75
- return;
76
- }
77
- pipeline(response, file).then(resolve).catch(reject);
78
- }).on('error', reject);
79
- });
80
-
81
- // Make executable on Unix
82
- if (process.platform !== 'win32') {
83
- chmodSync(destPath, 0o755);
84
- }
108
+ tried.push(versionedUrl);
109
+ console.log(`[zcompress] ↓ Downloading ${versionedUrl} ...`);
110
+ await downloadToFile(versionedUrl, destPath);
85
111
 
112
+ if (process.platform !== 'win32') chmodSync(destPath, 0o755);
86
113
  console.log(`[zcompress] ✓ Binary installed: ${destPath}`);
87
114
  return true;
88
115
  } catch (err) {
89
- if (String(err.message).includes('HTTP 404')) {
90
- console.log(`[zcompress] Release asset not found for v${VERSION}: ${binaryName}`);
91
- console.log(`[zcompress] ⚠ Expected URL: ${url}`);
92
- console.log('[zcompress] This usually means the npm version was published before uploading binary assets to GitHub Releases.');
116
+ // cleanup partial
117
+ try { unlinkSync(destPath); } catch {}
118
+
119
+ // If 404 or similar, try latest release as fallback
120
+ const msg = String(err?.message || err);
121
+ if (!msg.includes('404')) {
122
+ console.log(`[zcompress] ⚠ Download failed (${msg}).`);
93
123
  } else {
94
- console.log(`[zcompress] ⚠ Could not download binary (${err.message}).`);
124
+ console.log(`[zcompress] ⚠ Asset missing for v${VERSION}: ${binaryName}`);
95
125
  }
126
+ }
127
+
128
+ // Attempt 2: latest release fallback
129
+ try {
130
+ const latestAssetUrl = await resolveFallbackAssetUrl(binaryName);
131
+ tried.push(latestAssetUrl);
132
+ console.log(`[zcompress] ↓ Falling back to latest release asset: ${latestAssetUrl}`);
133
+ await downloadToFile(latestAssetUrl, destPath);
134
+
135
+ if (process.platform !== 'win32') chmodSync(destPath, 0o755);
136
+ console.log(`[zcompress] ✓ Binary installed from latest release: ${destPath}`);
137
+ return true;
138
+ } catch (err) {
139
+ try { unlinkSync(destPath); } catch {}
140
+ console.log(`[zcompress] ⚠ Fallback download failed (${err.message}).`);
141
+ console.log('[zcompress] Tried URLs:');
142
+ for (const u of tried) console.log(` - ${u}`);
96
143
  console.log('[zcompress] ℹ Build from source: cd zcompress && zig build -Doptimize=ReleaseFast');
97
- // Clean up partial download
98
- try { unlinkSync(destPath); } catch (_) { /* ignore */ }
99
144
  return false;
100
145
  }
101
146
  }
102
147
 
103
148
  // Run download, but don't fail install if it doesn't work
104
- downloadBinary().catch(() => {
105
- console.log('[zcompress] Binary download skipped. The plugin requires the zcompress CLI.');
106
- console.log('[zcompress] ℹ Install it via: brew install zcompress OR zig build install');
149
+ downloadBinary().catch((err) => {
150
+ console.log(`[zcompress] Binary download skipped (${err.message}).`);
151
+ console.log('[zcompress] ℹ Install it manually and set plugin option `binaryPath`.');
107
152
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zcompress-vite-plugin",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "High-performance multi-threaded asset compression for Vite — powered by Zig",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -13,7 +13,9 @@
13
13
  ],
14
14
  "scripts": {
15
15
  "postinstall": "node install.js",
16
- "test": "node test.js"
16
+ "test": "node test.js",
17
+ "verify:release-assets": "node verify-release-assets.js",
18
+ "prepublishOnly": "node verify-release-assets.js --warn-only"
17
19
  },
18
20
  "keywords": [
19
21
  "vite",