hx-cdn-forge 2.0.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/dist/split.js ADDED
@@ -0,0 +1,309 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ var fs = require('fs');
5
+ var path = require('path');
6
+ var crypto = require('crypto');
7
+
8
+ function _interopNamespace(e) {
9
+ if (e && e.__esModule) return e;
10
+ var n = Object.create(null);
11
+ if (e) {
12
+ Object.keys(e).forEach(function (k) {
13
+ if (k !== 'default') {
14
+ var d = Object.getOwnPropertyDescriptor(e, k);
15
+ Object.defineProperty(n, k, d.get ? d : {
16
+ enumerable: true,
17
+ get: function () { return e[k]; }
18
+ });
19
+ }
20
+ });
21
+ }
22
+ n.default = e;
23
+ return Object.freeze(n);
24
+ }
25
+
26
+ var fs__namespace = /*#__PURE__*/_interopNamespace(fs);
27
+ var path__namespace = /*#__PURE__*/_interopNamespace(path);
28
+ var crypto__namespace = /*#__PURE__*/_interopNamespace(crypto);
29
+
30
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
31
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
32
+ }) : x)(function(x) {
33
+ if (typeof require !== "undefined") return require.apply(this, arguments);
34
+ throw Error('Dynamic require of "' + x + '" is not supported');
35
+ });
36
+
37
+ // src/core/manifest.ts
38
+ function serializeInfoYaml(info) {
39
+ const lines = [
40
+ `originalName: ${info.originalName}`,
41
+ `totalSize: ${info.totalSize}`,
42
+ `mimeType: ${info.mimeType}`,
43
+ `chunkSize: ${info.chunkSize}`,
44
+ `createdAt: "${info.createdAt}"`,
45
+ "chunks:"
46
+ ];
47
+ for (const chunk of info.chunks) {
48
+ lines.push(` - fileName: ${chunk.fileName}`);
49
+ lines.push(` index: ${chunk.index}`);
50
+ lines.push(` size: ${chunk.size}`);
51
+ lines.push(` sha256: ${chunk.sha256}`);
52
+ }
53
+ return lines.join("\n") + "\n";
54
+ }
55
+ function parseCacheYaml(text) {
56
+ const lines = text.split("\n");
57
+ let sourcePath = "";
58
+ let sourceHash = "";
59
+ let sourceSize = 0;
60
+ let generatedAt = "";
61
+ for (const line of lines) {
62
+ const trimmed = line.trim();
63
+ if (trimmed.startsWith("sourcePath:")) sourcePath = extractValue(trimmed);
64
+ else if (trimmed.startsWith("sourceHash:")) sourceHash = extractValue(trimmed);
65
+ else if (trimmed.startsWith("sourceSize:")) sourceSize = parseInt(extractValue(trimmed), 10);
66
+ else if (trimmed.startsWith("generatedAt:")) generatedAt = extractValue(trimmed);
67
+ }
68
+ return { sourcePath, sourceHash, sourceSize, generatedAt };
69
+ }
70
+ function serializeCacheYaml(cache) {
71
+ return [
72
+ `sourcePath: ${cache.sourcePath}`,
73
+ `sourceHash: ${cache.sourceHash}`,
74
+ `sourceSize: ${cache.sourceSize}`,
75
+ `generatedAt: "${cache.generatedAt}"`,
76
+ ""
77
+ ].join("\n");
78
+ }
79
+ function extractValue(line) {
80
+ const idx = line.indexOf(":");
81
+ if (idx < 0) return "";
82
+ let val = line.slice(idx + 1).trim();
83
+ if (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'")) {
84
+ val = val.slice(1, -1);
85
+ }
86
+ return val;
87
+ }
88
+
89
+ // src/cli/split.ts
90
+ var DEFAULT_CHUNK_SIZE = 19 * 1024 * 1024;
91
+ async function splitFile(opts) {
92
+ const { source, outputDir, mappingPrefix, chunkSize, force } = opts;
93
+ if (!fs__namespace.existsSync(source)) {
94
+ console.error(`\u274C \u6E90\u6587\u4EF6\u4E0D\u5B58\u5728: ${source}`);
95
+ process.exit(1);
96
+ }
97
+ const stat = fs__namespace.statSync(source);
98
+ if (!stat.isFile()) {
99
+ console.error(`\u274C \u4E0D\u662F\u6587\u4EF6: ${source}`);
100
+ process.exit(1);
101
+ }
102
+ console.log(`\u{1F4E6} \u6E90\u6587\u4EF6: ${source} (${formatSize(stat.size)})`);
103
+ if (stat.size <= chunkSize) {
104
+ console.log(`\u2705 \u6587\u4EF6\u5C0F\u4E8E\u5207\u7247\u9608\u503C (${formatSize(chunkSize)})\uFF0C\u65E0\u9700\u5207\u7247`);
105
+ return;
106
+ }
107
+ const mappedPath = mapPath(source, mappingPrefix);
108
+ const targetDir = path__namespace.join(outputDir, mappedPath);
109
+ const cacheFile = path__namespace.join(targetDir, ".cache.yaml");
110
+ const infoFile = path__namespace.join(targetDir, "info.yaml");
111
+ console.log(`\u{1F4C2} \u8F93\u51FA\u76EE\u5F55: ${targetDir}`);
112
+ console.log(`\u{1F5FA}\uFE0F \u6620\u5C04\u8DEF\u5F84: ${mappedPath}`);
113
+ const sourceHash = await computeFileHash(source);
114
+ if (!force && fs__namespace.existsSync(cacheFile)) {
115
+ try {
116
+ const cacheText = fs__namespace.readFileSync(cacheFile, "utf-8");
117
+ const cache2 = parseCacheYaml(cacheText);
118
+ if (cache2.sourceHash === sourceHash && cache2.sourceSize === stat.size) {
119
+ console.log(`\u23ED\uFE0F \u6E90\u6587\u4EF6\u672A\u53D8\u5316\uFF0C\u8DF3\u8FC7 (hash: ${sourceHash.slice(0, 12)}...)`);
120
+ return;
121
+ }
122
+ console.log(`\u{1F504} \u6E90\u6587\u4EF6\u5DF2\u53D8\u5316\uFF0C\u91CD\u65B0\u751F\u6210\u5207\u7247`);
123
+ } catch {
124
+ }
125
+ }
126
+ fs__namespace.mkdirSync(targetDir, { recursive: true });
127
+ const fileName = path__namespace.basename(source);
128
+ const chunks = [];
129
+ const fileBuffer = fs__namespace.readFileSync(source);
130
+ let offset = 0;
131
+ let index = 0;
132
+ while (offset < fileBuffer.length) {
133
+ const end = Math.min(offset + chunkSize, fileBuffer.length);
134
+ const chunkData = fileBuffer.subarray(offset, end);
135
+ const chunkFileName = `${index}-${fileName}`;
136
+ const chunkPath = path__namespace.join(targetDir, chunkFileName);
137
+ fs__namespace.writeFileSync(chunkPath, chunkData);
138
+ const chunkHash = crypto__namespace.createHash("sha256").update(chunkData).digest("hex");
139
+ chunks.push({
140
+ fileName: chunkFileName,
141
+ index,
142
+ size: chunkData.length,
143
+ sha256: chunkHash
144
+ });
145
+ console.log(` \u2702\uFE0F ${chunkFileName} (${formatSize(chunkData.length)})`);
146
+ offset = end;
147
+ index++;
148
+ }
149
+ const info = {
150
+ originalName: fileName,
151
+ totalSize: stat.size,
152
+ mimeType: guessMimeType(fileName),
153
+ chunkSize,
154
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
155
+ chunks
156
+ };
157
+ fs__namespace.writeFileSync(infoFile, serializeInfoYaml(info), "utf-8");
158
+ console.log(`\u{1F4CB} \u751F\u6210 info.yaml`);
159
+ const cache = {
160
+ sourcePath: source,
161
+ sourceHash,
162
+ sourceSize: stat.size,
163
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString()
164
+ };
165
+ fs__namespace.writeFileSync(cacheFile, serializeCacheYaml(cache), "utf-8");
166
+ console.log(`\u{1F4BE} \u751F\u6210 .cache.yaml`);
167
+ console.log(`
168
+ \u2705 \u5B8C\u6210! ${chunks.length} \u4E2A\u5207\u7247, \u603B\u8BA1 ${formatSize(stat.size)}`);
169
+ }
170
+ function main() {
171
+ const args = process.argv.slice(2);
172
+ if (args.includes("--help") || args.includes("-h") || args.length === 0) {
173
+ printHelp();
174
+ return;
175
+ }
176
+ const opts = {
177
+ source: getArg(args, "--source", "-s") ?? "",
178
+ outputDir: getArg(args, "--output", "-o") ?? "",
179
+ mappingPrefix: getArg(args, "--prefix", "-p") ?? "",
180
+ chunkSize: parseSize(getArg(args, "--chunk-size", "-c") ?? `${DEFAULT_CHUNK_SIZE}`),
181
+ force: args.includes("--force") || args.includes("-f")
182
+ };
183
+ if (!opts.source) {
184
+ console.error("\u274C \u7F3A\u5C11 --source \u53C2\u6570");
185
+ process.exit(1);
186
+ }
187
+ if (!opts.outputDir) {
188
+ console.error("\u274C \u7F3A\u5C11 --output \u53C2\u6570");
189
+ process.exit(1);
190
+ }
191
+ splitFile(opts);
192
+ }
193
+ function printHelp() {
194
+ console.log(`
195
+ hx-cdn-split \u2014 HX-CDN-Forge \u5927\u6587\u4EF6\u5207\u7247\u5DE5\u5177
196
+
197
+ \u7528\u6CD5:
198
+ hx-cdn-split --source <\u8DEF\u5F84> --output <\u76EE\u5F55> [\u9009\u9879]
199
+
200
+ \u5FC5\u586B:
201
+ -s, --source <path> \u6E90\u6587\u4EF6\u8DEF\u5F84
202
+ -o, --output <dir> \u8F93\u51FA\u5B58\u50A8\u6839\u76EE\u5F55
203
+
204
+ \u53EF\u9009:
205
+ -p, --prefix <prefix> \u6620\u5C04\u524D\u7F00 (\u4ECE source \u8DEF\u5F84\u53BB\u9664)
206
+ -c, --chunk-size <size> \u5207\u7247\u5927\u5C0F (\u9ED8\u8BA4 19MB)
207
+ \u652F\u6301 B/KB/MB \u540E\u7F00, \u5982: 19MB, 10240KB
208
+ -f, --force \u5F3A\u5236\u91CD\u65B0\u751F\u6210 (\u5FFD\u7565\u7F13\u5B58)
209
+ -h, --help \u663E\u793A\u5E2E\u52A9
210
+
211
+ \u793A\u4F8B:
212
+ # \u5C06 25MB \u7684 ASS \u6587\u4EF6\u5207\u7247
213
+ hx-cdn-split -s static/ass/loli.ass -o static/cdn-black -p static
214
+
215
+ # \u4F7F\u7528\u81EA\u5B9A\u4E49\u5207\u7247\u5927\u5C0F
216
+ hx-cdn-split -s data/big.bin -o cdn-data -p data -c 10MB
217
+
218
+ # \u5F3A\u5236\u91CD\u65B0\u751F\u6210
219
+ hx-cdn-split -s static/ass/loli.ass -o static/cdn-black -p static -f
220
+ `.trim());
221
+ }
222
+ function getArg(args, long, short) {
223
+ for (let i = 0; i < args.length; i++) {
224
+ if (args[i] === long || args[i] === short) {
225
+ return args[i + 1];
226
+ }
227
+ }
228
+ return void 0;
229
+ }
230
+ function parseSize(str) {
231
+ const match = str.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)?$/i);
232
+ if (!match) return parseInt(str, 10) || DEFAULT_CHUNK_SIZE;
233
+ const num = parseFloat(match[1]);
234
+ const unit = (match[2] ?? "B").toUpperCase();
235
+ switch (unit) {
236
+ case "GB":
237
+ return Math.floor(num * 1024 * 1024 * 1024);
238
+ case "MB":
239
+ return Math.floor(num * 1024 * 1024);
240
+ case "KB":
241
+ return Math.floor(num * 1024);
242
+ default:
243
+ return Math.floor(num);
244
+ }
245
+ }
246
+ function formatSize(bytes) {
247
+ if (bytes >= 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
248
+ if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
249
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
250
+ return `${bytes} B`;
251
+ }
252
+ function mapPath(filePath, prefix) {
253
+ let p = filePath.replace(/\\/g, "/");
254
+ if (prefix) {
255
+ const normalizedPrefix = prefix.replace(/\\/g, "/");
256
+ if (p.startsWith(normalizedPrefix)) {
257
+ p = p.slice(normalizedPrefix.length);
258
+ }
259
+ if (p.startsWith("/")) p = p.slice(1);
260
+ }
261
+ return p;
262
+ }
263
+ async function computeFileHash(filePath) {
264
+ return new Promise((resolve, reject) => {
265
+ const hash = crypto__namespace.createHash("sha256");
266
+ const stream = fs__namespace.createReadStream(filePath);
267
+ stream.on("data", (data) => hash.update(data));
268
+ stream.on("end", () => resolve(hash.digest("hex")));
269
+ stream.on("error", reject);
270
+ });
271
+ }
272
+ function guessMimeType(fileName) {
273
+ const ext = path__namespace.extname(fileName).toLowerCase();
274
+ const mimeMap = {
275
+ ".ass": "text/x-ssa",
276
+ ".srt": "text/plain",
277
+ ".json": "application/json",
278
+ ".yaml": "text/yaml",
279
+ ".yml": "text/yaml",
280
+ ".txt": "text/plain",
281
+ ".bin": "application/octet-stream",
282
+ ".zip": "application/zip",
283
+ ".tar": "application/x-tar",
284
+ ".gz": "application/gzip",
285
+ ".png": "image/png",
286
+ ".jpg": "image/jpeg",
287
+ ".jpeg": "image/jpeg",
288
+ ".gif": "image/gif",
289
+ ".webp": "image/webp",
290
+ ".svg": "image/svg+xml",
291
+ ".mp3": "audio/mpeg",
292
+ ".mp4": "video/mp4",
293
+ ".webm": "video/webm",
294
+ ".wasm": "application/wasm",
295
+ ".pdf": "application/pdf",
296
+ ".css": "text/css",
297
+ ".js": "application/javascript",
298
+ ".mjs": "application/javascript",
299
+ ".ts": "text/typescript",
300
+ ".html": "text/html"
301
+ };
302
+ return mimeMap[ext] ?? "application/octet-stream";
303
+ }
304
+ if (typeof __require !== "undefined" && __require.main === module) {
305
+ main();
306
+ }
307
+
308
+ exports.cli = main;
309
+ exports.splitFile = splitFile;
package/package.json ADDED
@@ -0,0 +1,89 @@
1
+ {
2
+ "name": "hx-cdn-forge",
3
+ "version": "2.0.0",
4
+ "description": "GitHub CDN proxy with transparent large-file splitting, multi-CDN parallel download, and turbo mode",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "hx-cdn-split": "./dist/split.js"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "import": {
14
+ "types": "./dist/index.d.mts",
15
+ "default": "./dist/index.mjs"
16
+ },
17
+ "require": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ }
21
+ },
22
+ "./styles.css": "./dist/index.css"
23
+ },
24
+ "sideEffects": [
25
+ "**/*.css"
26
+ ],
27
+ "keywords": [
28
+ "cdn",
29
+ "cdn-forge",
30
+ "github-cdn",
31
+ "jsdelivr",
32
+ "file-splitting",
33
+ "parallel-download",
34
+ "multi-cdn",
35
+ "turbo-mode",
36
+ "react",
37
+ "typescript",
38
+ "china-friendly",
39
+ "large-file"
40
+ ],
41
+ "author": "HX",
42
+ "license": "MIT",
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "https://github.com/HengXin666/HX-CDN-Forge"
46
+ },
47
+ "bugs": {
48
+ "url": "https://github.com/HengXin666/HX-CDN-Forge/issues"
49
+ },
50
+ "homepage": "https://github.com/HengXin666/HX-CDN-Forge#readme",
51
+ "scripts": {
52
+ "build": "tsup",
53
+ "dev": "vite",
54
+ "type-check": "tsc --noEmit",
55
+ "test": "jest",
56
+ "test:watch": "jest --watch",
57
+ "test:coverage": "jest --coverage",
58
+ "lint": "eslint src/**/*.{ts,tsx}",
59
+ "prepublishOnly": "npm run build"
60
+ },
61
+ "devDependencies": {
62
+ "@types/jest": "^29.5.0",
63
+ "@types/node": "^25.5.0",
64
+ "@types/react": "^18.2.0",
65
+ "@types/react-dom": "^18.2.0",
66
+ "@vitejs/plugin-react": "^4.7.0",
67
+ "jest": "^29.7.0",
68
+ "parcel": "^2.11.0",
69
+ "react": "^18.2.0",
70
+ "react-dom": "^18.2.0",
71
+ "ts-jest": "^29.1.0",
72
+ "tsup": "^8.0.0",
73
+ "typescript": "^5.3.0",
74
+ "vite": "^5.4.21"
75
+ },
76
+ "peerDependencies": {
77
+ "react": ">=16.8.0",
78
+ "react-dom": ">=16.8.0"
79
+ },
80
+ "engines": {
81
+ "node": ">=16.0.0"
82
+ },
83
+ "files": [
84
+ "dist",
85
+ "src",
86
+ "README.md",
87
+ "LICENSE"
88
+ ]
89
+ }
@@ -0,0 +1,311 @@
1
+ /**
2
+ * CLI 差分切片工具
3
+ *
4
+ * 用法:
5
+ * npx hx-cdn-split --source static/ass/loli.ass \
6
+ * --output static/cdn-black \
7
+ * --prefix static \
8
+ * --chunk-size 19MB
9
+ *
10
+ * 或在 package.json scripts 中:
11
+ * "cdn:split": "hx-cdn-split --source ... --output ... --prefix ..."
12
+ *
13
+ * 功能:
14
+ * - 将大文件切片为多个 < threshold 的小文件
15
+ * - 生成 info.yaml (切片清单)
16
+ * - 生成 .cache.yaml (源文件哈希,用于增量更新检测)
17
+ * - 支持增量更新: 源文件未变化时跳过
18
+ */
19
+
20
+ import * as fs from 'fs';
21
+ import * as path from 'path';
22
+ import * as crypto from 'crypto';
23
+ import { serializeInfoYaml, serializeCacheYaml, parseCacheYaml } from '../core/manifest';
24
+ import type { SplitInfo, SplitChunkInfo, SplitCache } from '../types';
25
+
26
+ // ============================================================
27
+ // 配置
28
+ // ============================================================
29
+
30
+ interface SplitOptions {
31
+ source: string; // 源文件路径 (相对或绝对)
32
+ outputDir: string; // 输出根目录
33
+ mappingPrefix: string;// 从 source 路径去除的前缀
34
+ chunkSize: number; // 切片大小 (字节)
35
+ force: boolean; // 是否强制重新生成
36
+ }
37
+
38
+ const DEFAULT_CHUNK_SIZE = 19 * 1024 * 1024; // 19MB (留余量给 20MB 的 CDN 限制)
39
+
40
+ // ============================================================
41
+ // 主函数
42
+ // ============================================================
43
+
44
+ export async function splitFile(opts: SplitOptions): Promise<void> {
45
+ const { source, outputDir, mappingPrefix, chunkSize, force } = opts;
46
+
47
+ // 1. 验证源文件
48
+ if (!fs.existsSync(source)) {
49
+ console.error(`❌ 源文件不存在: ${source}`);
50
+ process.exit(1);
51
+ }
52
+
53
+ const stat = fs.statSync(source);
54
+ if (!stat.isFile()) {
55
+ console.error(`❌ 不是文件: ${source}`);
56
+ process.exit(1);
57
+ }
58
+
59
+ console.log(`📦 源文件: ${source} (${formatSize(stat.size)})`);
60
+
61
+ if (stat.size <= chunkSize) {
62
+ console.log(`✅ 文件小于切片阈值 (${formatSize(chunkSize)}),无需切片`);
63
+ return;
64
+ }
65
+
66
+ // 2. 计算映射路径
67
+ const mappedPath = mapPath(source, mappingPrefix);
68
+ const targetDir = path.join(outputDir, mappedPath);
69
+ const cacheFile = path.join(targetDir, '.cache.yaml');
70
+ const infoFile = path.join(targetDir, 'info.yaml');
71
+
72
+ console.log(`📂 输出目录: ${targetDir}`);
73
+ console.log(`🗺️ 映射路径: ${mappedPath}`);
74
+
75
+ // 3. 检查增量更新
76
+ const sourceHash = await computeFileHash(source);
77
+
78
+ if (!force && fs.existsSync(cacheFile)) {
79
+ try {
80
+ const cacheText = fs.readFileSync(cacheFile, 'utf-8');
81
+ const cache = parseCacheYaml(cacheText);
82
+ if (cache.sourceHash === sourceHash && cache.sourceSize === stat.size) {
83
+ console.log(`⏭️ 源文件未变化,跳过 (hash: ${sourceHash.slice(0, 12)}...)`);
84
+ return;
85
+ }
86
+ console.log(`🔄 源文件已变化,重新生成切片`);
87
+ } catch {
88
+ // cache 解析失败,重新生成
89
+ }
90
+ }
91
+
92
+ // 4. 创建输出目录
93
+ fs.mkdirSync(targetDir, { recursive: true });
94
+
95
+ // 5. 切片
96
+ const fileName = path.basename(source);
97
+ const chunks: SplitChunkInfo[] = [];
98
+ const fileBuffer = fs.readFileSync(source);
99
+ let offset = 0;
100
+ let index = 0;
101
+
102
+ while (offset < fileBuffer.length) {
103
+ const end = Math.min(offset + chunkSize, fileBuffer.length);
104
+ const chunkData = fileBuffer.subarray(offset, end);
105
+ const chunkFileName = `${index}-${fileName}`;
106
+ const chunkPath = path.join(targetDir, chunkFileName);
107
+
108
+ // 写入分片
109
+ fs.writeFileSync(chunkPath, chunkData);
110
+
111
+ // 计算分片哈希
112
+ const chunkHash = crypto.createHash('sha256').update(chunkData).digest('hex');
113
+
114
+ chunks.push({
115
+ fileName: chunkFileName,
116
+ index,
117
+ size: chunkData.length,
118
+ sha256: chunkHash,
119
+ });
120
+
121
+ console.log(` ✂️ ${chunkFileName} (${formatSize(chunkData.length)})`);
122
+
123
+ offset = end;
124
+ index++;
125
+ }
126
+
127
+ // 6. 生成 info.yaml
128
+ const info: SplitInfo = {
129
+ originalName: fileName,
130
+ totalSize: stat.size,
131
+ mimeType: guessMimeType(fileName),
132
+ chunkSize,
133
+ createdAt: new Date().toISOString(),
134
+ chunks,
135
+ };
136
+
137
+ fs.writeFileSync(infoFile, serializeInfoYaml(info), 'utf-8');
138
+ console.log(`📋 生成 info.yaml`);
139
+
140
+ // 7. 生成 .cache.yaml
141
+ const cache: SplitCache = {
142
+ sourcePath: source,
143
+ sourceHash,
144
+ sourceSize: stat.size,
145
+ generatedAt: new Date().toISOString(),
146
+ };
147
+
148
+ fs.writeFileSync(cacheFile, serializeCacheYaml(cache), 'utf-8');
149
+ console.log(`💾 生成 .cache.yaml`);
150
+
151
+ console.log(`\n✅ 完成! ${chunks.length} 个切片, 总计 ${formatSize(stat.size)}`);
152
+ }
153
+
154
+ // ============================================================
155
+ // CLI 入口
156
+ // ============================================================
157
+
158
+ function main(): void {
159
+ const args = process.argv.slice(2);
160
+
161
+ if (args.includes('--help') || args.includes('-h') || args.length === 0) {
162
+ printHelp();
163
+ return;
164
+ }
165
+
166
+ const opts: SplitOptions = {
167
+ source: getArg(args, '--source', '-s') ?? '',
168
+ outputDir: getArg(args, '--output', '-o') ?? '',
169
+ mappingPrefix: getArg(args, '--prefix', '-p') ?? '',
170
+ chunkSize: parseSize(getArg(args, '--chunk-size', '-c') ?? `${DEFAULT_CHUNK_SIZE}`),
171
+ force: args.includes('--force') || args.includes('-f'),
172
+ };
173
+
174
+ if (!opts.source) {
175
+ console.error('❌ 缺少 --source 参数');
176
+ process.exit(1);
177
+ }
178
+ if (!opts.outputDir) {
179
+ console.error('❌ 缺少 --output 参数');
180
+ process.exit(1);
181
+ }
182
+
183
+ splitFile(opts);
184
+ }
185
+
186
+ function printHelp(): void {
187
+ console.log(`
188
+ hx-cdn-split — HX-CDN-Forge 大文件切片工具
189
+
190
+ 用法:
191
+ hx-cdn-split --source <路径> --output <目录> [选项]
192
+
193
+ 必填:
194
+ -s, --source <path> 源文件路径
195
+ -o, --output <dir> 输出存储根目录
196
+
197
+ 可选:
198
+ -p, --prefix <prefix> 映射前缀 (从 source 路径去除)
199
+ -c, --chunk-size <size> 切片大小 (默认 19MB)
200
+ 支持 B/KB/MB 后缀, 如: 19MB, 10240KB
201
+ -f, --force 强制重新生成 (忽略缓存)
202
+ -h, --help 显示帮助
203
+
204
+ 示例:
205
+ # 将 25MB 的 ASS 文件切片
206
+ hx-cdn-split -s static/ass/loli.ass -o static/cdn-black -p static
207
+
208
+ # 使用自定义切片大小
209
+ hx-cdn-split -s data/big.bin -o cdn-data -p data -c 10MB
210
+
211
+ # 强制重新生成
212
+ hx-cdn-split -s static/ass/loli.ass -o static/cdn-black -p static -f
213
+ `.trim());
214
+ }
215
+
216
+ // ============================================================
217
+ // 辅助函数
218
+ // ============================================================
219
+
220
+ function getArg(args: string[], long: string, short: string): string | undefined {
221
+ for (let i = 0; i < args.length; i++) {
222
+ if (args[i] === long || args[i] === short) {
223
+ return args[i + 1];
224
+ }
225
+ }
226
+ return undefined;
227
+ }
228
+
229
+ function parseSize(str: string): number {
230
+ const match = str.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)?$/i);
231
+ if (!match) return parseInt(str, 10) || DEFAULT_CHUNK_SIZE;
232
+
233
+ const num = parseFloat(match[1]!);
234
+ const unit = (match[2] ?? 'B').toUpperCase();
235
+
236
+ switch (unit) {
237
+ case 'GB': return Math.floor(num * 1024 * 1024 * 1024);
238
+ case 'MB': return Math.floor(num * 1024 * 1024);
239
+ case 'KB': return Math.floor(num * 1024);
240
+ default: return Math.floor(num);
241
+ }
242
+ }
243
+
244
+ function formatSize(bytes: number): string {
245
+ if (bytes >= 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
246
+ if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
247
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
248
+ return `${bytes} B`;
249
+ }
250
+
251
+ function mapPath(filePath: string, prefix: string): string {
252
+ let p = filePath.replace(/\\/g, '/');
253
+ if (prefix) {
254
+ const normalizedPrefix = prefix.replace(/\\/g, '/');
255
+ if (p.startsWith(normalizedPrefix)) {
256
+ p = p.slice(normalizedPrefix.length);
257
+ }
258
+ if (p.startsWith('/')) p = p.slice(1);
259
+ }
260
+ return p;
261
+ }
262
+
263
+ async function computeFileHash(filePath: string): Promise<string> {
264
+ return new Promise((resolve, reject) => {
265
+ const hash = crypto.createHash('sha256');
266
+ const stream = fs.createReadStream(filePath);
267
+ stream.on('data', (data) => hash.update(data));
268
+ stream.on('end', () => resolve(hash.digest('hex')));
269
+ stream.on('error', reject);
270
+ });
271
+ }
272
+
273
+ function guessMimeType(fileName: string): string {
274
+ const ext = path.extname(fileName).toLowerCase();
275
+ const mimeMap: Record<string, string> = {
276
+ '.ass': 'text/x-ssa',
277
+ '.srt': 'text/plain',
278
+ '.json': 'application/json',
279
+ '.yaml': 'text/yaml',
280
+ '.yml': 'text/yaml',
281
+ '.txt': 'text/plain',
282
+ '.bin': 'application/octet-stream',
283
+ '.zip': 'application/zip',
284
+ '.tar': 'application/x-tar',
285
+ '.gz': 'application/gzip',
286
+ '.png': 'image/png',
287
+ '.jpg': 'image/jpeg',
288
+ '.jpeg': 'image/jpeg',
289
+ '.gif': 'image/gif',
290
+ '.webp': 'image/webp',
291
+ '.svg': 'image/svg+xml',
292
+ '.mp3': 'audio/mpeg',
293
+ '.mp4': 'video/mp4',
294
+ '.webm': 'video/webm',
295
+ '.wasm': 'application/wasm',
296
+ '.pdf': 'application/pdf',
297
+ '.css': 'text/css',
298
+ '.js': 'application/javascript',
299
+ '.mjs': 'application/javascript',
300
+ '.ts': 'text/typescript',
301
+ '.html': 'text/html',
302
+ };
303
+ return mimeMap[ext] ?? 'application/octet-stream';
304
+ }
305
+
306
+ // 如果直接执行此文件
307
+ if (typeof require !== 'undefined' && require.main === module) {
308
+ main();
309
+ }
310
+
311
+ export { main as cli };