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/index.js ADDED
@@ -0,0 +1,1405 @@
1
+ 'use strict';
2
+
3
+ var react = require('react');
4
+ var jsxRuntime = require('react/jsx-runtime');
5
+
6
+ // src/core/cdnNodes.ts
7
+ var buildJsDelivrUrl = (baseUrl) => (ctx, filePath) => {
8
+ const path = filePath.startsWith("/") ? filePath : `/${filePath}`;
9
+ return `${baseUrl}/${ctx.user}/${ctx.repo}@${ctx.ref}${path}`;
10
+ };
11
+ var buildGithubRawUrl = (ctx, filePath) => {
12
+ const path = filePath.startsWith("/") ? filePath : `/${filePath}`;
13
+ return `https://raw.githubusercontent.com/${ctx.user}/${ctx.repo}/${ctx.ref}${path}`;
14
+ };
15
+ var buildCfWorkerUrl = (domain) => (ctx, filePath) => {
16
+ const rawUrl = buildGithubRawUrl(ctx, filePath);
17
+ return `https://${domain}/${rawUrl}`;
18
+ };
19
+ var CDN_NODE_PRESETS = {
20
+ jsdelivr_main: {
21
+ id: "jsdelivr-main",
22
+ name: "jsDelivr (Main)",
23
+ baseUrl: "https://cdn.jsdelivr.net/gh",
24
+ region: "global",
25
+ buildUrl: buildJsDelivrUrl("https://cdn.jsdelivr.net/gh"),
26
+ maxFileSize: 20 * 1024 * 1024,
27
+ supportsRange: true,
28
+ description: "jsDelivr \u4E3B\u8282\u70B9\uFF0C\u5168\u7403 CDN"
29
+ },
30
+ jsdelivr_fastly: {
31
+ id: "jsdelivr-fastly",
32
+ name: "jsDelivr (Fastly)",
33
+ baseUrl: "https://fastly.jsdelivr.net/gh",
34
+ region: "global",
35
+ buildUrl: buildJsDelivrUrl("https://fastly.jsdelivr.net/gh"),
36
+ maxFileSize: 20 * 1024 * 1024,
37
+ supportsRange: true,
38
+ description: "jsDelivr Fastly \u8282\u70B9"
39
+ },
40
+ jsdelivr_testing: {
41
+ id: "jsdelivr-testing",
42
+ name: "jsDelivr (Testing)",
43
+ baseUrl: "https://testing.jsdelivr.net/gh",
44
+ region: "global",
45
+ buildUrl: buildJsDelivrUrl("https://testing.jsdelivr.net/gh"),
46
+ maxFileSize: 20 * 1024 * 1024,
47
+ supportsRange: true,
48
+ description: "jsDelivr \u6D4B\u8BD5\u8282\u70B9"
49
+ },
50
+ jsd_mirror: {
51
+ id: "jsd-mirror",
52
+ name: "JSD Mirror",
53
+ baseUrl: "https://cdn.jsdmirror.com/gh",
54
+ region: "china",
55
+ buildUrl: buildJsDelivrUrl("https://cdn.jsdmirror.com/gh"),
56
+ maxFileSize: 20 * 1024 * 1024,
57
+ supportsRange: true,
58
+ description: "jsDelivr \u56FD\u5185\u955C\u50CF\uFF0C\u817E\u8BAF\u4E91 EdgeOne \u52A0\u901F"
59
+ },
60
+ zstatic: {
61
+ id: "zstatic",
62
+ name: "Zstatic",
63
+ baseUrl: "https://jsd.zstatic.net/gh",
64
+ region: "china",
65
+ buildUrl: buildJsDelivrUrl("https://jsd.zstatic.net/gh"),
66
+ maxFileSize: 20 * 1024 * 1024,
67
+ supportsRange: true,
68
+ description: "Zstatic CDN \u955C\u50CF"
69
+ },
70
+ github_raw: {
71
+ id: "github-raw",
72
+ name: "GitHub Raw",
73
+ baseUrl: "https://raw.githubusercontent.com",
74
+ region: "global",
75
+ buildUrl: buildGithubRawUrl,
76
+ maxFileSize: 100 * 1024 * 1024,
77
+ supportsRange: true,
78
+ description: "GitHub \u539F\u59CB\u6587\u4EF6\u670D\u52A1"
79
+ }
80
+ };
81
+ var DEFAULT_GITHUB_CDN_NODES = [
82
+ CDN_NODE_PRESETS.jsdelivr_main,
83
+ CDN_NODE_PRESETS.jsdelivr_fastly,
84
+ CDN_NODE_PRESETS.jsdelivr_testing,
85
+ CDN_NODE_PRESETS.jsd_mirror,
86
+ CDN_NODE_PRESETS.zstatic,
87
+ CDN_NODE_PRESETS.github_raw
88
+ ];
89
+ function createWorkerNode(domain) {
90
+ return {
91
+ id: `cf-worker-${domain.replace(/\./g, "-")}`,
92
+ name: `CF Worker (${domain})`,
93
+ baseUrl: `https://${domain}`,
94
+ region: "global",
95
+ buildUrl: buildCfWorkerUrl(domain),
96
+ maxFileSize: -1,
97
+ supportsRange: true,
98
+ description: `Cloudflare Worker \u4EE3\u7406 (${domain})`
99
+ };
100
+ }
101
+ var CDNTester = class {
102
+ constructor(timeout = 5e3, retryCount = 2) {
103
+ this.timeout = timeout;
104
+ this.retryCount = retryCount;
105
+ }
106
+ /** 测试单节点延迟 */
107
+ async testNode(node, testUrl) {
108
+ const url = testUrl ?? node.baseUrl;
109
+ for (let attempt = 0; attempt <= this.retryCount; attempt++) {
110
+ try {
111
+ const start = performance.now();
112
+ const ctrl = new AbortController();
113
+ const tid = setTimeout(() => ctrl.abort(), this.timeout);
114
+ await fetch(url, {
115
+ method: "HEAD",
116
+ mode: "no-cors",
117
+ cache: "no-cache",
118
+ signal: ctrl.signal
119
+ });
120
+ clearTimeout(tid);
121
+ return {
122
+ nodeId: node.id,
123
+ latency: Math.round((performance.now() - start) * 100) / 100,
124
+ success: true,
125
+ timestamp: Date.now()
126
+ };
127
+ } catch (err) {
128
+ if (attempt === this.retryCount) {
129
+ return {
130
+ nodeId: node.id,
131
+ latency: -1,
132
+ success: false,
133
+ timestamp: Date.now(),
134
+ error: err instanceof Error ? err.message : "Unknown error"
135
+ };
136
+ }
137
+ await delay(200 * (attempt + 1));
138
+ }
139
+ }
140
+ return { nodeId: node.id, latency: -1, success: false, timestamp: Date.now() };
141
+ }
142
+ /** 并发测试所有启用节点 */
143
+ async testAll(nodes) {
144
+ const enabled = nodes.filter((n) => n.enabled !== false);
145
+ return Promise.all(enabled.map((n) => this.testNode(n)));
146
+ }
147
+ /** 流式测试: 每完成一个就回调 */
148
+ async testAllStreaming(nodes, onResult) {
149
+ const enabled = nodes.filter((n) => n.enabled !== false);
150
+ const results = [];
151
+ const promises = enabled.map(async (node) => {
152
+ const r = await this.testNode(node);
153
+ results.push(r);
154
+ onResult(r);
155
+ return r;
156
+ });
157
+ await Promise.all(promises);
158
+ return results;
159
+ }
160
+ /** 找延迟最低的节点 ID */
161
+ getBestNodeId(results) {
162
+ const sorted = results.filter((r) => r.success && r.latency >= 0).sort((a, b) => a.latency - b.latency);
163
+ return sorted.length > 0 ? sorted[0].nodeId : null;
164
+ }
165
+ setTimeout(ms) {
166
+ this.timeout = ms;
167
+ }
168
+ setRetryCount(n) {
169
+ this.retryCount = n;
170
+ }
171
+ };
172
+ function getSortedNodesWithLatency(nodes, results) {
173
+ return nodes.filter((n) => n.enabled !== false).map((node) => {
174
+ const r = results.get(node.id);
175
+ return {
176
+ ...node,
177
+ latency: r?.success ? r.latency : r ? -1 : void 0,
178
+ latencyStatus: r ? r.success ? "success" : "failed" : "idle"
179
+ };
180
+ }).sort((a, b) => {
181
+ if (a.latency === void 0 && b.latency === void 0) return 0;
182
+ if (a.latency === void 0) return 1;
183
+ if (b.latency === void 0) return -1;
184
+ if (a.latency < 0 && b.latency < 0) return 0;
185
+ if (a.latency < 0) return 1;
186
+ if (b.latency < 0) return -1;
187
+ return a.latency - b.latency;
188
+ });
189
+ }
190
+ function delay(ms) {
191
+ return new Promise((resolve) => setTimeout(resolve, ms));
192
+ }
193
+
194
+ // src/core/config.ts
195
+ var DEFAULTS = {
196
+ splitThreshold: 20 * 1024 * 1024,
197
+ // 20MB
198
+ chunkSizeForSplit: 19 * 1024 * 1024,
199
+ // 切片时使用 19MB (留余量)
200
+ testTimeout: 5e3,
201
+ testRetries: 2,
202
+ maxConcurrency: 6,
203
+ chunkTimeout: 3e4,
204
+ maxRetries: 3,
205
+ enableWorkStealing: true,
206
+ turboMode: false,
207
+ turboConcurrentCDNs: 3,
208
+ storageKey: "hx-cdn-forge-node",
209
+ autoTest: true
210
+ };
211
+ function normalizeConfig(config) {
212
+ return {
213
+ github: config.github,
214
+ nodes: config.nodes ?? DEFAULT_GITHUB_CDN_NODES,
215
+ defaultNodeId: config.defaultNodeId ?? "",
216
+ autoTest: config.autoTest ?? DEFAULTS.autoTest,
217
+ testTimeout: config.testTimeout ?? DEFAULTS.testTimeout,
218
+ testRetries: config.testRetries ?? DEFAULTS.testRetries,
219
+ splitThreshold: config.splitThreshold ?? DEFAULTS.splitThreshold,
220
+ mappingPrefix: config.mappingPrefix ?? "",
221
+ splitStoragePath: config.splitStoragePath ?? "",
222
+ storageKey: config.storageKey ?? DEFAULTS.storageKey,
223
+ maxConcurrency: config.maxConcurrency ?? DEFAULTS.maxConcurrency,
224
+ chunkTimeout: config.chunkTimeout ?? DEFAULTS.chunkTimeout,
225
+ maxRetries: config.maxRetries ?? DEFAULTS.maxRetries,
226
+ enableWorkStealing: config.enableWorkStealing ?? DEFAULTS.enableWorkStealing,
227
+ turboMode: config.turboMode ?? DEFAULTS.turboMode,
228
+ turboConcurrentCDNs: config.turboConcurrentCDNs ?? DEFAULTS.turboConcurrentCDNs
229
+ };
230
+ }
231
+ function createForgeConfig(github, options) {
232
+ return {
233
+ github,
234
+ ...options
235
+ };
236
+ }
237
+
238
+ // src/core/manifest.ts
239
+ function parseInfoYaml(text) {
240
+ const lines = text.split("\n").map((l) => l.trimEnd());
241
+ let originalName = "";
242
+ let totalSize = 0;
243
+ let mimeType = "application/octet-stream";
244
+ let chunkSize = 0;
245
+ let createdAt = "";
246
+ const chunks = [];
247
+ let inChunks = false;
248
+ let currentChunk = null;
249
+ for (const line of lines) {
250
+ const trimmed = line.trimStart();
251
+ if (!inChunks) {
252
+ if (trimmed.startsWith("originalName:")) {
253
+ originalName = extractValue(trimmed);
254
+ } else if (trimmed.startsWith("totalSize:")) {
255
+ totalSize = parseInt(extractValue(trimmed), 10);
256
+ } else if (trimmed.startsWith("mimeType:")) {
257
+ mimeType = extractValue(trimmed);
258
+ } else if (trimmed.startsWith("chunkSize:")) {
259
+ chunkSize = parseInt(extractValue(trimmed), 10);
260
+ } else if (trimmed.startsWith("createdAt:")) {
261
+ createdAt = extractValue(trimmed);
262
+ } else if (trimmed.startsWith("chunks:")) {
263
+ inChunks = true;
264
+ }
265
+ continue;
266
+ }
267
+ if (trimmed.startsWith("- fileName:") || trimmed === "- fileName:") {
268
+ if (currentChunk && currentChunk.fileName) {
269
+ chunks.push(currentChunk);
270
+ }
271
+ currentChunk = { fileName: extractValue(trimmed.replace(/^-\s*/, "")) };
272
+ } else if (currentChunk) {
273
+ if (trimmed.startsWith("index:")) {
274
+ currentChunk.index = parseInt(extractValue(trimmed), 10);
275
+ } else if (trimmed.startsWith("size:")) {
276
+ currentChunk.size = parseInt(extractValue(trimmed), 10);
277
+ } else if (trimmed.startsWith("sha256:")) {
278
+ currentChunk.sha256 = extractValue(trimmed);
279
+ }
280
+ }
281
+ }
282
+ if (currentChunk && currentChunk.fileName) {
283
+ chunks.push(currentChunk);
284
+ }
285
+ return { originalName, totalSize, mimeType, chunkSize, createdAt, chunks };
286
+ }
287
+ function serializeInfoYaml(info) {
288
+ const lines = [
289
+ `originalName: ${info.originalName}`,
290
+ `totalSize: ${info.totalSize}`,
291
+ `mimeType: ${info.mimeType}`,
292
+ `chunkSize: ${info.chunkSize}`,
293
+ `createdAt: "${info.createdAt}"`,
294
+ "chunks:"
295
+ ];
296
+ for (const chunk of info.chunks) {
297
+ lines.push(` - fileName: ${chunk.fileName}`);
298
+ lines.push(` index: ${chunk.index}`);
299
+ lines.push(` size: ${chunk.size}`);
300
+ lines.push(` sha256: ${chunk.sha256}`);
301
+ }
302
+ return lines.join("\n") + "\n";
303
+ }
304
+ function parseCacheYaml(text) {
305
+ const lines = text.split("\n");
306
+ let sourcePath = "";
307
+ let sourceHash = "";
308
+ let sourceSize = 0;
309
+ let generatedAt = "";
310
+ for (const line of lines) {
311
+ const trimmed = line.trim();
312
+ if (trimmed.startsWith("sourcePath:")) sourcePath = extractValue(trimmed);
313
+ else if (trimmed.startsWith("sourceHash:")) sourceHash = extractValue(trimmed);
314
+ else if (trimmed.startsWith("sourceSize:")) sourceSize = parseInt(extractValue(trimmed), 10);
315
+ else if (trimmed.startsWith("generatedAt:")) generatedAt = extractValue(trimmed);
316
+ }
317
+ return { sourcePath, sourceHash, sourceSize, generatedAt };
318
+ }
319
+ function serializeCacheYaml(cache) {
320
+ return [
321
+ `sourcePath: ${cache.sourcePath}`,
322
+ `sourceHash: ${cache.sourceHash}`,
323
+ `sourceSize: ${cache.sourceSize}`,
324
+ `generatedAt: "${cache.generatedAt}"`,
325
+ ""
326
+ ].join("\n");
327
+ }
328
+ function extractValue(line) {
329
+ const idx = line.indexOf(":");
330
+ if (idx < 0) return "";
331
+ let val = line.slice(idx + 1).trim();
332
+ if (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'")) {
333
+ val = val.slice(1, -1);
334
+ }
335
+ return val;
336
+ }
337
+
338
+ // src/core/chunkedFetcher.ts
339
+ var LoadBalancer = class {
340
+ constructor(nodeIds, alpha = 0.3) {
341
+ this.stats = /* @__PURE__ */ new Map();
342
+ this.alpha = alpha;
343
+ for (const id of nodeIds) {
344
+ this.stats.set(id, {
345
+ nodeId: id,
346
+ totalBytes: 0,
347
+ totalTime: 0,
348
+ speedBps: 0,
349
+ completedChunks: 0,
350
+ consecutiveFailures: 0,
351
+ available: true
352
+ });
353
+ }
354
+ }
355
+ initFromLatency(results) {
356
+ for (const [nodeId, r] of results) {
357
+ const s = this.stats.get(nodeId);
358
+ if (s && r.success) {
359
+ s.speedBps = 1024 * 1024 / Math.max(r.latency, 10);
360
+ }
361
+ }
362
+ }
363
+ report(nodeId, bytes, timeMs, success) {
364
+ const s = this.stats.get(nodeId);
365
+ if (!s) return;
366
+ if (success) {
367
+ s.totalBytes += bytes;
368
+ s.totalTime += timeMs;
369
+ s.completedChunks++;
370
+ s.consecutiveFailures = 0;
371
+ const instant = bytes / Math.max(timeMs, 1);
372
+ s.speedBps = s.speedBps > 0 ? s.speedBps * (1 - this.alpha) + instant * this.alpha : instant;
373
+ } else {
374
+ s.consecutiveFailures++;
375
+ if (s.consecutiveFailures >= 3) s.available = false;
376
+ }
377
+ }
378
+ selectBest(pendingPerNode) {
379
+ let best = null;
380
+ let bestETA = Infinity;
381
+ for (const [nodeId, s] of this.stats) {
382
+ if (!s.available) continue;
383
+ const pending = pendingPerNode.get(nodeId) ?? 0;
384
+ const eta = s.speedBps > 0 ? pending / s.speedBps : Infinity;
385
+ if (eta < bestETA) {
386
+ bestETA = eta;
387
+ best = nodeId;
388
+ }
389
+ }
390
+ return best;
391
+ }
392
+ findStealable(tasks) {
393
+ const avail = [...this.stats.values()].filter((s) => s.available);
394
+ if (avail.length < 2) return null;
395
+ const sorted = avail.sort((a, b) => b.speedBps - a.speedBps);
396
+ const fastest = sorted[0];
397
+ const slowest = sorted[sorted.length - 1];
398
+ if (fastest.speedBps < slowest.speedBps * 2) return null;
399
+ for (let i = tasks.length - 1; i >= 0; i--) {
400
+ const t = tasks[i];
401
+ if (t.assignedNodeId === slowest.nodeId && t.status === "pending") {
402
+ return { from: slowest.nodeId, taskIndex: i };
403
+ }
404
+ }
405
+ return null;
406
+ }
407
+ getAvailableNodes() {
408
+ return [...this.stats.entries()].filter(([, s]) => s.available).map(([id]) => id);
409
+ }
410
+ getStats() {
411
+ return new Map(this.stats);
412
+ }
413
+ };
414
+ var ChunkedFetcher = class {
415
+ constructor(opts = {}) {
416
+ this.opts = {
417
+ maxConcurrency: opts.maxConcurrency ?? 6,
418
+ chunkTimeout: opts.chunkTimeout ?? 3e4,
419
+ maxRetries: opts.maxRetries ?? 3,
420
+ enableWorkStealing: opts.enableWorkStealing ?? true,
421
+ turboMode: opts.turboMode ?? false,
422
+ turboConcurrentCDNs: opts.turboConcurrentCDNs ?? 3
423
+ };
424
+ }
425
+ /**
426
+ * 并行下载多个分片文件,然后拼接
427
+ *
428
+ * @param chunkUrls - 每个分片的 URL 列表 (外层=分片索引, 内层=各CDN节点的URL)
429
+ * @param chunkSizes - 各分片的预期大小
430
+ * @param totalSize - 总大小
431
+ * @param contentType - MIME 类型
432
+ * @param nodes - 可用节点列表 (用于负载均衡)
433
+ * @param latencyResults - 延迟数据
434
+ * @param onProgress - 进度回调
435
+ */
436
+ async downloadChunks(chunkUrls, chunkSizes, totalSize, contentType, nodes, latencyResults, onProgress) {
437
+ const startTime = performance.now();
438
+ const numChunks = chunkUrls.length;
439
+ if (numChunks === 0) {
440
+ throw new Error("No chunks to download");
441
+ }
442
+ if (this.opts.turboMode && chunkUrls[0].length > 1) {
443
+ return this.downloadTurbo(
444
+ chunkUrls,
445
+ chunkSizes,
446
+ totalSize,
447
+ contentType,
448
+ nodes,
449
+ startTime,
450
+ onProgress
451
+ );
452
+ }
453
+ return this.downloadStandard(
454
+ chunkUrls,
455
+ chunkSizes,
456
+ totalSize,
457
+ contentType,
458
+ nodes,
459
+ latencyResults,
460
+ startTime,
461
+ onProgress
462
+ );
463
+ }
464
+ // ============================================================
465
+ // 标准模式
466
+ // ============================================================
467
+ async downloadStandard(chunkUrls, chunkSizes, totalSize, contentType, nodes, latencyResults, startTime, onProgress) {
468
+ const nodeIds = nodes.filter((n) => n.enabled !== false).map((n) => n.id);
469
+ const balancer = new LoadBalancer(nodeIds);
470
+ if (latencyResults) balancer.initFromLatency(latencyResults);
471
+ const tasks = chunkUrls.map((urls, i) => ({
472
+ chunk: { index: i, start: 0, end: chunkSizes[i] - 1, size: chunkSizes[i] },
473
+ status: "pending",
474
+ assignedNodeId: nodeIds[i % nodeIds.length],
475
+ retries: 0
476
+ }));
477
+ const concurrency = Math.min(this.opts.maxConcurrency, tasks.length);
478
+ let completedBytes = 0;
479
+ const emitProgress = () => {
480
+ if (!onProgress) return;
481
+ const elapsed = (performance.now() - startTime) / 1e3;
482
+ const speed = completedBytes / Math.max(elapsed, 1e-3);
483
+ const remaining = totalSize - completedBytes;
484
+ onProgress({
485
+ loaded: completedBytes,
486
+ total: totalSize,
487
+ percentage: Math.round(completedBytes / totalSize * 1e4) / 100,
488
+ speed,
489
+ eta: speed > 0 ? remaining / speed : Infinity,
490
+ completedChunks: tasks.filter((t) => t.status === "completed").length,
491
+ totalChunks: tasks.length
492
+ });
493
+ };
494
+ const workerFn = async () => {
495
+ while (true) {
496
+ const idx = tasks.findIndex((t) => t.status === "pending");
497
+ if (idx === -1) break;
498
+ const task = tasks[idx];
499
+ task.status = "downloading";
500
+ const nodeIndex = nodeIds.indexOf(task.assignedNodeId);
501
+ const urlIndex = nodeIndex >= 0 && nodeIndex < chunkUrls[task.chunk.index].length ? nodeIndex : 0;
502
+ const url = chunkUrls[task.chunk.index][urlIndex];
503
+ try {
504
+ const t0 = performance.now();
505
+ const data = await this.fetchChunk(url);
506
+ const dt = performance.now() - t0;
507
+ task.data = data;
508
+ task.status = "completed";
509
+ completedBytes += data.byteLength;
510
+ balancer.report(task.assignedNodeId, data.byteLength, dt, true);
511
+ emitProgress();
512
+ if (this.opts.enableWorkStealing) {
513
+ const steal = balancer.findStealable(tasks);
514
+ if (steal) {
515
+ const stolen = tasks[steal.taskIndex];
516
+ const fastNodes = balancer.getAvailableNodes();
517
+ if (fastNodes.length > 0) {
518
+ stolen.assignedNodeId = fastNodes[0];
519
+ stolen.status = "pending";
520
+ }
521
+ }
522
+ }
523
+ } catch (err) {
524
+ task.retries++;
525
+ balancer.report(task.assignedNodeId, 0, 0, false);
526
+ if (task.retries < this.opts.maxRetries) {
527
+ const pendingMap = getPendingCount(tasks);
528
+ const newNode = balancer.selectBest(pendingMap);
529
+ if (newNode) task.assignedNodeId = newNode;
530
+ task.status = "pending";
531
+ } else {
532
+ task.status = "failed";
533
+ task.error = err instanceof Error ? err.message : "Unknown error";
534
+ }
535
+ }
536
+ }
537
+ };
538
+ const workers = Array.from({ length: concurrency }, () => workerFn());
539
+ await Promise.all(workers);
540
+ const failed = tasks.filter((t) => t.status === "failed");
541
+ if (failed.length > 0) {
542
+ throw new Error(
543
+ `Failed to download ${failed.length} chunks: ${failed.map((t) => `#${t.chunk.index}: ${t.error}`).join("; ")}`
544
+ );
545
+ }
546
+ return this.buildResult(tasks, contentType, startTime, balancer, true);
547
+ }
548
+ // ============================================================
549
+ // 极速模式: 同一分片多 CDN 竞速
550
+ // ============================================================
551
+ async downloadTurbo(chunkUrls, chunkSizes, totalSize, contentType, nodes, startTime, onProgress) {
552
+ const turboCDNs = Math.min(this.opts.turboConcurrentCDNs, chunkUrls[0].length);
553
+ const results = new Array(chunkUrls.length);
554
+ const nodeContrib = /* @__PURE__ */ new Map();
555
+ let completedBytes = 0;
556
+ const emitProgress = () => {
557
+ if (!onProgress) return;
558
+ const elapsed = (performance.now() - startTime) / 1e3;
559
+ const speed = completedBytes / Math.max(elapsed, 1e-3);
560
+ onProgress({
561
+ loaded: completedBytes,
562
+ total: totalSize,
563
+ percentage: Math.round(completedBytes / totalSize * 1e4) / 100,
564
+ speed,
565
+ eta: speed > 0 ? (totalSize - completedBytes) / speed : Infinity,
566
+ completedChunks: results.filter(Boolean).length,
567
+ totalChunks: chunkUrls.length
568
+ });
569
+ };
570
+ const concurrency = Math.min(this.opts.maxConcurrency, chunkUrls.length);
571
+ let nextChunkIndex = 0;
572
+ const worker = async () => {
573
+ while (true) {
574
+ const ci = nextChunkIndex++;
575
+ if (ci >= chunkUrls.length) break;
576
+ const urls = chunkUrls[ci].slice(0, turboCDNs);
577
+ const data = await this.raceDownload(urls, nodes, turboCDNs);
578
+ results[ci] = data.buffer;
579
+ completedBytes += data.buffer.byteLength;
580
+ const existing = nodeContrib.get(data.winnerId) ?? { bytes: 0, chunks: 0 };
581
+ existing.bytes += data.buffer.byteLength;
582
+ existing.chunks++;
583
+ nodeContrib.set(data.winnerId, existing);
584
+ emitProgress();
585
+ }
586
+ };
587
+ const workers = Array.from({ length: concurrency }, () => worker());
588
+ await Promise.all(workers);
589
+ const parts = results.filter(Boolean);
590
+ if (parts.length !== chunkUrls.length) {
591
+ throw new Error(`Only downloaded ${parts.length}/${chunkUrls.length} chunks`);
592
+ }
593
+ const blob = new Blob(results, { type: contentType });
594
+ const contributions = /* @__PURE__ */ new Map();
595
+ for (const [id, c] of nodeContrib) {
596
+ contributions.set(id, { ...c, avgSpeed: 0 });
597
+ }
598
+ return {
599
+ blob,
600
+ totalSize,
601
+ totalTime: performance.now() - startTime,
602
+ nodeContributions: contributions,
603
+ usedParallelMode: true,
604
+ contentType
605
+ };
606
+ }
607
+ /** 多 CDN 竞速下载同一个 URL */
608
+ async raceDownload(urls, nodes, _count) {
609
+ const controllers = [];
610
+ const promises = urls.map(async (url, i) => {
611
+ const ctrl = new AbortController();
612
+ controllers.push(ctrl);
613
+ const tid = setTimeout(() => ctrl.abort(), this.opts.chunkTimeout);
614
+ try {
615
+ const resp = await fetch(url, { mode: "cors", signal: ctrl.signal });
616
+ clearTimeout(tid);
617
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
618
+ const buffer = await resp.arrayBuffer();
619
+ return {
620
+ buffer,
621
+ winnerId: i < nodes.length ? nodes[i].id : `cdn-${i}`
622
+ };
623
+ } catch (err) {
624
+ clearTimeout(tid);
625
+ throw err;
626
+ }
627
+ });
628
+ try {
629
+ const result = await Promise.any(promises);
630
+ for (const ctrl of controllers) {
631
+ try {
632
+ ctrl.abort();
633
+ } catch {
634
+ }
635
+ }
636
+ return result;
637
+ } catch {
638
+ throw new Error("All CDN nodes failed for this chunk");
639
+ }
640
+ }
641
+ // ============================================================
642
+ // 辅助
643
+ // ============================================================
644
+ async fetchChunk(url) {
645
+ const ctrl = new AbortController();
646
+ const tid = setTimeout(() => ctrl.abort(), this.opts.chunkTimeout);
647
+ try {
648
+ const resp = await fetch(url, { mode: "cors", signal: ctrl.signal });
649
+ clearTimeout(tid);
650
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
651
+ return await resp.arrayBuffer();
652
+ } catch (err) {
653
+ clearTimeout(tid);
654
+ throw err;
655
+ }
656
+ }
657
+ buildResult(tasks, contentType, startTime, balancer, parallel) {
658
+ const sorted = [...tasks].filter((t) => t.status === "completed" && t.data).sort((a, b) => a.chunk.index - b.chunk.index);
659
+ const blob = new Blob(sorted.map((t) => t.data), { type: contentType });
660
+ const contributions = /* @__PURE__ */ new Map();
661
+ for (const task of tasks) {
662
+ if (task.status !== "completed") continue;
663
+ const existing = contributions.get(task.assignedNodeId) ?? { bytes: 0, chunks: 0, avgSpeed: 0 };
664
+ existing.bytes += task.data?.byteLength ?? 0;
665
+ existing.chunks++;
666
+ contributions.set(task.assignedNodeId, existing);
667
+ }
668
+ for (const [nodeId, stats] of contributions) {
669
+ const ws = balancer.getStats().get(nodeId);
670
+ if (ws && ws.totalTime > 0) {
671
+ stats.avgSpeed = ws.totalBytes / ws.totalTime * 1e3;
672
+ }
673
+ }
674
+ return {
675
+ blob,
676
+ totalSize: blob.size,
677
+ totalTime: performance.now() - startTime,
678
+ nodeContributions: contributions,
679
+ usedParallelMode: parallel,
680
+ contentType
681
+ };
682
+ }
683
+ };
684
+ function getPendingCount(tasks) {
685
+ const m = /* @__PURE__ */ new Map();
686
+ for (const t of tasks) {
687
+ if (t.status === "pending" || t.status === "downloading") {
688
+ m.set(t.assignedNodeId, (m.get(t.assignedNodeId) ?? 0) + 1);
689
+ }
690
+ }
691
+ return m;
692
+ }
693
+
694
+ // src/core/fetcher.ts
695
+ var ForgeEngine = class {
696
+ constructor(config) {
697
+ this.currentNodeId = null;
698
+ this.latencyResults = /* @__PURE__ */ new Map();
699
+ this.initialized = false;
700
+ /**
701
+ * "就绪" Promise — 第一个成功测速结果回来就 resolve
702
+ * reqByCDN 等方法 await 这个而不是等全部测完
703
+ */
704
+ this.readyResolve = null;
705
+ // info.yaml 缓存: filePath → SplitInfo | null
706
+ this.splitInfoCache = /* @__PURE__ */ new Map();
707
+ this.config = normalizeConfig(config);
708
+ this.tester = new CDNTester(this.config.testTimeout, this.config.testRetries);
709
+ this.fetcher = new ChunkedFetcher({
710
+ maxConcurrency: this.config.maxConcurrency,
711
+ chunkTimeout: this.config.chunkTimeout,
712
+ maxRetries: this.config.maxRetries,
713
+ enableWorkStealing: this.config.enableWorkStealing,
714
+ turboMode: this.config.turboMode,
715
+ turboConcurrentCDNs: this.config.turboConcurrentCDNs
716
+ });
717
+ this.readyPromise = new Promise((resolve) => {
718
+ this.readyResolve = resolve;
719
+ });
720
+ this.loadSelectedNode();
721
+ if (!this.currentNodeId && this.config.defaultNodeId) {
722
+ this.currentNodeId = this.config.defaultNodeId;
723
+ }
724
+ if (!this.currentNodeId) {
725
+ const first = this.getNodes().find((n) => n.enabled !== false);
726
+ if (first) this.currentNodeId = first.id;
727
+ }
728
+ }
729
+ // ---- 初始化 ----
730
+ /** 标记引擎已就绪 (第一个成功结果/不需要测速时) */
731
+ markReady() {
732
+ if (!this.initialized) {
733
+ this.initialized = true;
734
+ this.readyResolve?.();
735
+ this.readyResolve = null;
736
+ }
737
+ }
738
+ /**
739
+ * 等待引擎就绪 — 不等全部测完,只等第一个成功结果
740
+ * reqByCDN / buildUrl 内部使用
741
+ */
742
+ waitReady() {
743
+ return this.readyPromise;
744
+ }
745
+ async initialize() {
746
+ if (this.initialized) return;
747
+ if (this.config.autoTest) {
748
+ await this.testAndSelectBest();
749
+ }
750
+ this.markReady();
751
+ }
752
+ /**
753
+ * 流式初始化 — 每完成一个节点测速就回调
754
+ * 第一个**成功**结果到达时立刻标记 initialized,不等超时节点
755
+ * 剩余节点在后台继续测速
756
+ *
757
+ * @param onResult 每完成一个节点的回调
758
+ * @param onReady 第一个成功结果到达时触发(可选)
759
+ */
760
+ async initializeStreaming(onResult, onReady) {
761
+ if (this.initialized) return;
762
+ if (this.config.autoTest) {
763
+ await this.testAndSelectBest((r) => {
764
+ onResult(r);
765
+ if (r.success && !this.initialized) {
766
+ this.markReady();
767
+ onReady?.();
768
+ }
769
+ });
770
+ }
771
+ this.markReady();
772
+ }
773
+ // ---- 节点管理 ----
774
+ getNodes() {
775
+ return this.config.nodes;
776
+ }
777
+ getCurrentNode() {
778
+ return this.config.nodes.find((n) => n.id === this.currentNodeId) ?? null;
779
+ }
780
+ selectNode(nodeId) {
781
+ const node = this.config.nodes.find((n) => n.id === nodeId);
782
+ if (node) {
783
+ this.currentNodeId = nodeId;
784
+ this.saveSelectedNode(nodeId);
785
+ return node;
786
+ }
787
+ return null;
788
+ }
789
+ getSortedNodes() {
790
+ return getSortedNodesWithLatency(this.config.nodes, this.latencyResults);
791
+ }
792
+ getLatencyResults() {
793
+ return new Map(this.latencyResults);
794
+ }
795
+ // ---- 测速 ----
796
+ async testAndSelectBest(onResult) {
797
+ const results = onResult ? await this.testAllNodesStreaming((r) => {
798
+ const bestId2 = this.tester.getBestNodeId([...this.latencyResults.values()]);
799
+ if (bestId2) this.selectNode(bestId2);
800
+ onResult(r);
801
+ }) : await this.testAllNodes();
802
+ const bestId = this.tester.getBestNodeId(results);
803
+ if (bestId) this.selectNode(bestId);
804
+ return results;
805
+ }
806
+ async testAllNodes() {
807
+ const results = await this.tester.testAll(this.config.nodes);
808
+ this.latencyResults.clear();
809
+ for (const r of results) this.latencyResults.set(r.nodeId, r);
810
+ return results;
811
+ }
812
+ async testAllNodesStreaming(onResult) {
813
+ this.latencyResults.clear();
814
+ return this.tester.testAllStreaming(this.config.nodes, (r) => {
815
+ this.latencyResults.set(r.nodeId, r);
816
+ onResult(r);
817
+ });
818
+ }
819
+ // ---- URL 构建 ----
820
+ buildUrl(filePath) {
821
+ const node = this.getCurrentNode();
822
+ if (!node) throw new Error("No CDN node selected");
823
+ return node.buildUrl(this.config.github, filePath);
824
+ }
825
+ // ---- 核心: reqByCDN ----
826
+ /**
827
+ * 统一请求接口 — 对使用者完全透明
828
+ *
829
+ * @param filePath - GitHub 仓库中相对于根目录的文件路径
830
+ * @param onProgress - 进度回调
831
+ *
832
+ * 内部会自动判断:
833
+ * - 是否有切片版本 (在 splitStoragePath 下查找 info.yaml)
834
+ * - 如果有 → 并行下载分片 → 拼接
835
+ * - 如果没有 → 直接下载
836
+ */
837
+ async reqByCDN(filePath, onProgress) {
838
+ const startTime = performance.now();
839
+ if (!this.initialized) await this.waitReady();
840
+ const splitInfo = await this.tryGetSplitInfo(filePath);
841
+ if (splitInfo) {
842
+ return this.downloadSplit(filePath, splitInfo, startTime, onProgress);
843
+ }
844
+ return this.downloadDirect(filePath, startTime, onProgress);
845
+ }
846
+ // ============================================================
847
+ // 私有: 分片下载
848
+ // ============================================================
849
+ /** 尝试获取文件的切片信息 */
850
+ async tryGetSplitInfo(filePath) {
851
+ const { splitStoragePath, mappingPrefix } = this.config;
852
+ if (!splitStoragePath) return null;
853
+ if (this.splitInfoCache.has(filePath)) {
854
+ return this.splitInfoCache.get(filePath);
855
+ }
856
+ const mappedPath = this.mapFilePath(filePath, mappingPrefix);
857
+ const infoPath = `${splitStoragePath}/${mappedPath}/info.yaml`;
858
+ try {
859
+ const node = this.getCurrentNode() ?? this.getNodes()[0];
860
+ if (!node) return null;
861
+ const url = node.buildUrl(this.config.github, infoPath);
862
+ const ctrl = new AbortController();
863
+ const tid = setTimeout(() => ctrl.abort(), 1e4);
864
+ const resp = await fetch(url, { mode: "cors", signal: ctrl.signal });
865
+ clearTimeout(tid);
866
+ if (!resp.ok) {
867
+ this.splitInfoCache.set(filePath, null);
868
+ return null;
869
+ }
870
+ const text = await resp.text();
871
+ const info = parseInfoYaml(text);
872
+ this.splitInfoCache.set(filePath, info);
873
+ return info;
874
+ } catch {
875
+ this.splitInfoCache.set(filePath, null);
876
+ return null;
877
+ }
878
+ }
879
+ /** 下载切片并拼接 */
880
+ async downloadSplit(filePath, splitInfo, startTime, onProgress) {
881
+ const { splitStoragePath, mappingPrefix } = this.config;
882
+ const mappedPath = this.mapFilePath(filePath, mappingPrefix);
883
+ const enabledNodes = this.config.nodes.filter((n) => n.enabled !== false);
884
+ if (enabledNodes.length === 0) throw new Error("No CDN nodes available");
885
+ const chunkUrls = splitInfo.chunks.map((chunk) => {
886
+ const chunkPath = `${splitStoragePath}/${mappedPath}/${chunk.fileName}`;
887
+ return enabledNodes.map(
888
+ (node) => node.buildUrl(this.config.github, chunkPath)
889
+ );
890
+ });
891
+ const chunkSizes = splitInfo.chunks.map((c) => c.size);
892
+ const result = await this.fetcher.downloadChunks(
893
+ chunkUrls,
894
+ chunkSizes,
895
+ splitInfo.totalSize,
896
+ splitInfo.mimeType,
897
+ enabledNodes,
898
+ this.latencyResults,
899
+ onProgress
900
+ );
901
+ const blob = result.blob;
902
+ return {
903
+ blob,
904
+ arrayBuffer: () => blob.arrayBuffer(),
905
+ totalSize: result.totalSize,
906
+ totalTime: performance.now() - startTime,
907
+ contentType: result.contentType,
908
+ usedSplitMode: true,
909
+ usedParallelMode: result.usedParallelMode,
910
+ nodeContributions: result.nodeContributions
911
+ };
912
+ }
913
+ /** 直接下载 (无切片) */
914
+ async downloadDirect(filePath, startTime, onProgress) {
915
+ const node = this.getCurrentNode();
916
+ if (!node) throw new Error("No CDN node selected");
917
+ const url = node.buildUrl(this.config.github, filePath);
918
+ const ctrl = new AbortController();
919
+ const tid = setTimeout(() => ctrl.abort(), this.config.chunkTimeout * 3);
920
+ try {
921
+ const resp = await fetch(url, { mode: "cors", signal: ctrl.signal });
922
+ clearTimeout(tid);
923
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
924
+ const contentLength = parseInt(resp.headers.get("Content-Length") ?? "0", 10);
925
+ const contentType = resp.headers.get("Content-Type") ?? "application/octet-stream";
926
+ let blob;
927
+ if (contentLength > 0 && resp.body && onProgress) {
928
+ blob = await this.readStreamWithProgress(
929
+ resp.body,
930
+ contentLength,
931
+ contentType,
932
+ startTime,
933
+ onProgress
934
+ );
935
+ } else {
936
+ blob = await resp.blob();
937
+ onProgress?.({
938
+ loaded: blob.size,
939
+ total: blob.size,
940
+ percentage: 100,
941
+ speed: 0,
942
+ eta: 0,
943
+ completedChunks: 1,
944
+ totalChunks: 1
945
+ });
946
+ }
947
+ return {
948
+ blob,
949
+ arrayBuffer: () => blob.arrayBuffer(),
950
+ totalSize: blob.size,
951
+ totalTime: performance.now() - startTime,
952
+ contentType,
953
+ usedSplitMode: false,
954
+ usedParallelMode: false,
955
+ nodeContributions: /* @__PURE__ */ new Map([[node.id, { bytes: blob.size, chunks: 1, avgSpeed: 0 }]])
956
+ };
957
+ } catch (err) {
958
+ clearTimeout(tid);
959
+ throw err;
960
+ }
961
+ }
962
+ /** 流式读取并报告进度 */
963
+ async readStreamWithProgress(body, total, contentType, startTime, onProgress) {
964
+ const reader = body.getReader();
965
+ const chunks = [];
966
+ let loaded = 0;
967
+ while (true) {
968
+ const { done, value } = await reader.read();
969
+ if (done) break;
970
+ chunks.push(value);
971
+ loaded += value.byteLength;
972
+ const elapsed = (performance.now() - startTime) / 1e3;
973
+ const speed = loaded / Math.max(elapsed, 1e-3);
974
+ onProgress({
975
+ loaded,
976
+ total,
977
+ percentage: Math.round(loaded / total * 1e4) / 100,
978
+ speed,
979
+ eta: speed > 0 ? (total - loaded) / speed : Infinity,
980
+ completedChunks: loaded >= total ? 1 : 0,
981
+ totalChunks: 1
982
+ });
983
+ }
984
+ return new Blob(chunks, { type: contentType });
985
+ }
986
+ // ============================================================
987
+ // 私有: 路径映射
988
+ // ============================================================
989
+ /**
990
+ * 映射文件路径
991
+ * 例: filePath="static/ass/loli.ass", prefix="static"
992
+ * → "ass/loli.ass"
993
+ */
994
+ mapFilePath(filePath, prefix) {
995
+ let p = filePath.startsWith("/") ? filePath.slice(1) : filePath;
996
+ if (prefix && p.startsWith(prefix)) {
997
+ p = p.slice(prefix.length);
998
+ if (p.startsWith("/")) p = p.slice(1);
999
+ }
1000
+ return p;
1001
+ }
1002
+ // ============================================================
1003
+ // 私有: 持久化
1004
+ // ============================================================
1005
+ loadSelectedNode() {
1006
+ if (typeof window === "undefined") return;
1007
+ try {
1008
+ const id = localStorage.getItem(this.config.storageKey);
1009
+ if (id) {
1010
+ const node = this.config.nodes.find((n) => n.id === id);
1011
+ if (node) this.currentNodeId = id;
1012
+ }
1013
+ } catch {
1014
+ }
1015
+ }
1016
+ saveSelectedNode(nodeId) {
1017
+ if (typeof window === "undefined") return;
1018
+ try {
1019
+ localStorage.setItem(this.config.storageKey, nodeId);
1020
+ } catch {
1021
+ }
1022
+ }
1023
+ // ============================================================
1024
+ // 公开: 配置
1025
+ // ============================================================
1026
+ getConfig() {
1027
+ return this.config;
1028
+ }
1029
+ isInitialized() {
1030
+ return this.initialized;
1031
+ }
1032
+ /** 清除 info.yaml 缓存 */
1033
+ clearSplitInfoCache() {
1034
+ this.splitInfoCache.clear();
1035
+ }
1036
+ };
1037
+ var CDNCtx = react.createContext(null);
1038
+ function CDNProvider({
1039
+ config,
1040
+ children,
1041
+ onInitialized,
1042
+ onNodeChange
1043
+ }) {
1044
+ const engineRef = react.useRef(null);
1045
+ if (!engineRef.current) {
1046
+ engineRef.current = new ForgeEngine(config);
1047
+ }
1048
+ const [currentNode, setCurrentNode] = react.useState(
1049
+ () => engineRef.current?.getCurrentNode() ?? null
1050
+ );
1051
+ const [nodes, setNodes] = react.useState([]);
1052
+ const [isTesting, setIsTesting] = react.useState(false);
1053
+ const [latencyResults, setLatencyResults] = react.useState(/* @__PURE__ */ new Map());
1054
+ const [isInitialized, setIsInitialized] = react.useState(false);
1055
+ const isInitializedRef = react.useRef(false);
1056
+ react.useEffect(() => {
1057
+ isInitializedRef.current = isInitialized;
1058
+ }, [isInitialized]);
1059
+ react.useEffect(() => {
1060
+ const engine = engineRef.current;
1061
+ if (!engine) return;
1062
+ let cancelled = false;
1063
+ setNodes(engine.getSortedNodes());
1064
+ setCurrentNode(engine.getCurrentNode());
1065
+ const init = async () => {
1066
+ setIsTesting(true);
1067
+ setNodes(
1068
+ (prev) => prev.map((n) => ({ ...n, latencyStatus: "testing", latency: void 0 }))
1069
+ );
1070
+ try {
1071
+ await engine.initializeStreaming(
1072
+ (result) => {
1073
+ if (cancelled) return;
1074
+ setLatencyResults(engine.getLatencyResults());
1075
+ setNodes(engine.getSortedNodes().map((n) => {
1076
+ const hasResult = engine.getLatencyResults().has(n.id);
1077
+ if (!hasResult) return { ...n, latencyStatus: "testing", latency: void 0 };
1078
+ return n;
1079
+ }));
1080
+ if (!cancelled) {
1081
+ setCurrentNode(engine.getCurrentNode());
1082
+ }
1083
+ },
1084
+ () => {
1085
+ if (cancelled) return;
1086
+ setCurrentNode(engine.getCurrentNode());
1087
+ setIsInitialized(true);
1088
+ onInitialized?.(engine.getCurrentNode());
1089
+ }
1090
+ );
1091
+ } finally {
1092
+ if (!cancelled) {
1093
+ setCurrentNode(engine.getCurrentNode());
1094
+ setNodes(engine.getSortedNodes());
1095
+ setLatencyResults(engine.getLatencyResults());
1096
+ setIsTesting(false);
1097
+ if (!isInitializedRef.current) {
1098
+ setIsInitialized(true);
1099
+ onInitialized?.(engine.getCurrentNode());
1100
+ }
1101
+ }
1102
+ }
1103
+ };
1104
+ init();
1105
+ return () => {
1106
+ cancelled = true;
1107
+ };
1108
+ }, []);
1109
+ const selectNode = react.useCallback(
1110
+ (nodeId) => {
1111
+ const engine = engineRef.current;
1112
+ if (!engine) return;
1113
+ const node = engine.selectNode(nodeId);
1114
+ if (node) {
1115
+ setCurrentNode(node);
1116
+ setNodes(engine.getSortedNodes());
1117
+ onNodeChange?.(node);
1118
+ }
1119
+ },
1120
+ [onNodeChange]
1121
+ );
1122
+ const testAllNodes = react.useCallback(async () => {
1123
+ const engine = engineRef.current;
1124
+ if (!engine) return [];
1125
+ setIsTesting(true);
1126
+ setNodes(
1127
+ (prev) => prev.map((n) => ({ ...n, latencyStatus: "testing", latency: void 0 }))
1128
+ );
1129
+ try {
1130
+ const results = await engine.testAllNodesStreaming((result) => {
1131
+ setLatencyResults(engine.getLatencyResults());
1132
+ setNodes(engine.getSortedNodes().map((n) => {
1133
+ const hasResult = engine.getLatencyResults().has(n.id);
1134
+ if (!hasResult) return { ...n, latencyStatus: "testing", latency: void 0 };
1135
+ return n;
1136
+ }));
1137
+ });
1138
+ setLatencyResults(engine.getLatencyResults());
1139
+ setNodes(engine.getSortedNodes());
1140
+ return results;
1141
+ } finally {
1142
+ setIsTesting(false);
1143
+ }
1144
+ }, []);
1145
+ const reqByCDN = react.useCallback(
1146
+ async (filePath, onProgress) => {
1147
+ const engine = engineRef.current;
1148
+ if (!engine) throw new Error("ForgeEngine not initialized");
1149
+ return engine.reqByCDN(filePath, onProgress);
1150
+ },
1151
+ []
1152
+ );
1153
+ const buildUrl = react.useCallback((filePath) => {
1154
+ const engine = engineRef.current;
1155
+ if (!engine) return "";
1156
+ try {
1157
+ return engine.buildUrl(filePath);
1158
+ } catch {
1159
+ return "";
1160
+ }
1161
+ }, []);
1162
+ const getSortedNodes = react.useCallback(() => {
1163
+ const engine = engineRef.current;
1164
+ if (!engine) return [];
1165
+ return engine.getSortedNodes();
1166
+ }, []);
1167
+ const value = react.useMemo(
1168
+ () => ({
1169
+ config,
1170
+ currentNode,
1171
+ nodes,
1172
+ isTesting,
1173
+ isInitialized,
1174
+ latencyResults,
1175
+ selectNode,
1176
+ testAllNodes,
1177
+ reqByCDN,
1178
+ buildUrl,
1179
+ getSortedNodes
1180
+ }),
1181
+ [
1182
+ config,
1183
+ currentNode,
1184
+ nodes,
1185
+ isTesting,
1186
+ isInitialized,
1187
+ latencyResults,
1188
+ selectNode,
1189
+ testAllNodes,
1190
+ reqByCDN,
1191
+ buildUrl,
1192
+ getSortedNodes
1193
+ ]
1194
+ );
1195
+ return /* @__PURE__ */ jsxRuntime.jsx(CDNCtx.Provider, { value, children });
1196
+ }
1197
+ function useCDN() {
1198
+ const ctx = react.useContext(CDNCtx);
1199
+ if (!ctx) throw new Error("useCDN() must be used within a <CDNProvider>");
1200
+ return ctx;
1201
+ }
1202
+ function useCDNUrl(relativePath) {
1203
+ const { buildUrl } = useCDN();
1204
+ return buildUrl(relativePath);
1205
+ }
1206
+ function useCurrentCDNNode() {
1207
+ const { currentNode } = useCDN();
1208
+ return currentNode;
1209
+ }
1210
+ function useCDNStatus() {
1211
+ return useCDN();
1212
+ }
1213
+ function useReqByCDN() {
1214
+ const { reqByCDN } = useCDN();
1215
+ return reqByCDN;
1216
+ }
1217
+ var ChevronDownIcon = () => /* @__PURE__ */ jsxRuntime.jsx("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 20 20", fill: "currentColor", children: /* @__PURE__ */ jsxRuntime.jsx("path", { fillRule: "evenodd", d: "M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z", clipRule: "evenodd" }) });
1218
+ var RefreshIcon = () => /* @__PURE__ */ jsxRuntime.jsx("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 20 20", fill: "currentColor", children: /* @__PURE__ */ jsxRuntime.jsx("path", { fillRule: "evenodd", d: "M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H4.598a.75.75 0 0 0-.75.75v3.634a.75.75 0 0 0 1.5 0v-2.033l.312.311a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm-10.624-2.85a5.5 5.5 0 0 1 9.201-2.465l.312.31H11.77a.75.75 0 0 0 0 1.5h3.634a.75.75 0 0 0 .75-.75V3.536a.75.75 0 0 0-1.5 0v2.033l-.312-.31A7 7 0 0 0 2.63 8.384a.75.75 0 0 0 1.449.39l.609.8Z", clipRule: "evenodd" }) });
1219
+ var REGION_LABELS = {
1220
+ china: "\u4E2D\u56FD\u5927\u9646",
1221
+ asia: "\u4E9A\u592A",
1222
+ global: "\u5168\u7403"
1223
+ };
1224
+ function getLatencyText(node) {
1225
+ if (node.latencyStatus === "testing") return "\u6D4B\u901F\u4E2D";
1226
+ if (node.latencyStatus === "failed" || node.latency !== void 0 && node.latency < 0) return "\u5931\u8D25";
1227
+ if (node.latencyStatus === "idle" || node.latency === void 0) return "--";
1228
+ return `${Math.round(node.latency)}ms`;
1229
+ }
1230
+ function getLatencyClassName(node) {
1231
+ if (node.latencyStatus === "testing") return "latency-testing";
1232
+ if (node.latencyStatus === "failed" || node.latency !== void 0 && node.latency < 0) return "latency-failed";
1233
+ if (node.latencyStatus === "idle" || node.latency === void 0) return "latency-idle";
1234
+ if (node.latency < 100) return "latency-excellent";
1235
+ if (node.latency < 200) return "latency-good";
1236
+ if (node.latency < 500) return "latency-normal";
1237
+ return "latency-slow";
1238
+ }
1239
+ function CDNNodeSelector({
1240
+ className = "",
1241
+ style,
1242
+ showLatency = true,
1243
+ showRegion = true,
1244
+ title,
1245
+ showRefreshButton = true,
1246
+ disabled = false,
1247
+ compact = false,
1248
+ onChange,
1249
+ onTestComplete,
1250
+ renderTrigger,
1251
+ renderNode,
1252
+ renderEmpty,
1253
+ renderLoading
1254
+ }) {
1255
+ const { currentNode, nodes, isTesting, isInitialized, selectNode, testAllNodes } = useCDN();
1256
+ const isAutoSelecting = isTesting && !isInitialized;
1257
+ const [isOpen, setIsOpen] = react.useState(false);
1258
+ const containerRef = react.useRef(null);
1259
+ react.useEffect(() => {
1260
+ if (!isOpen) return;
1261
+ const handleClickOutside = (e) => {
1262
+ if (containerRef.current && !containerRef.current.contains(e.target)) {
1263
+ setIsOpen(false);
1264
+ }
1265
+ };
1266
+ document.addEventListener("mousedown", handleClickOutside);
1267
+ return () => document.removeEventListener("mousedown", handleClickOutside);
1268
+ }, [isOpen]);
1269
+ react.useEffect(() => {
1270
+ if (!isOpen) return;
1271
+ const handleKeyDown = (e) => {
1272
+ if (e.key === "Escape") setIsOpen(false);
1273
+ };
1274
+ document.addEventListener("keydown", handleKeyDown);
1275
+ return () => document.removeEventListener("keydown", handleKeyDown);
1276
+ }, [isOpen]);
1277
+ const handleSelect = react.useCallback(
1278
+ (nodeId) => {
1279
+ if (disabled) return;
1280
+ selectNode(nodeId);
1281
+ const node = nodes.find((n) => n.id === nodeId);
1282
+ if (node) onChange?.(node);
1283
+ setIsOpen(false);
1284
+ },
1285
+ [disabled, selectNode, nodes, onChange]
1286
+ );
1287
+ const handleRefresh = react.useCallback(async () => {
1288
+ if (disabled || isTesting) return;
1289
+ const results = await testAllNodes();
1290
+ onTestComplete?.(results);
1291
+ }, [disabled, isTesting, testAllNodes, onTestComplete]);
1292
+ const toggleOpen = react.useCallback(() => {
1293
+ if (!disabled) setIsOpen((prev) => !prev);
1294
+ }, [disabled]);
1295
+ const currentNodeWithLatency = currentNode ? nodes.find((n) => n.id === currentNode.id) ?? { ...currentNode, latencyStatus: "idle" } : null;
1296
+ const rootClassName = [
1297
+ "cdn-node-selector",
1298
+ compact ? "cdn-compact" : "",
1299
+ className
1300
+ ].filter(Boolean).join(" ");
1301
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: rootClassName, style, ref: containerRef, children: [
1302
+ title && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cdn-selector-title", children: title }),
1303
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cdn-selector-container", children: [
1304
+ renderTrigger ? /* @__PURE__ */ jsxRuntime.jsx("div", { onClick: toggleOpen, style: { flex: 1, cursor: disabled ? "not-allowed" : "pointer" }, children: renderTrigger({ currentNode, isOpen, isTesting }) }) : /* @__PURE__ */ jsxRuntime.jsxs(
1305
+ "button",
1306
+ {
1307
+ className: `cdn-current-node${isAutoSelecting ? " cdn-auto-selecting" : ""}`,
1308
+ onClick: toggleOpen,
1309
+ disabled,
1310
+ type: "button",
1311
+ "aria-haspopup": "listbox",
1312
+ "aria-expanded": isOpen,
1313
+ children: [
1314
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cdn-node-info", children: isAutoSelecting ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1315
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cdn-node-name cdn-auto-selecting-text", children: "\u81EA\u52A8\u9009\u62E9\u4E2D\u2026" }),
1316
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "cdn-node-region", style: { fontSize: "11px" }, children: [
1317
+ "\u6B63\u5728\u6D4B\u901F\uFF0C\u4F7F\u7528\u4E34\u65F6\u8282\u70B9: ",
1318
+ currentNode?.name ?? "\u2014"
1319
+ ] })
1320
+ ] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1321
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cdn-node-name", children: currentNode?.name ?? "\u672A\u9009\u62E9\u8282\u70B9" }),
1322
+ showRegion && currentNode?.region && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cdn-node-region", children: REGION_LABELS[currentNode.region] ?? currentNode.region })
1323
+ ] }) }),
1324
+ !isAutoSelecting && showLatency && currentNodeWithLatency && /* @__PURE__ */ jsxRuntime.jsx("span", { className: `cdn-latency ${getLatencyClassName(currentNodeWithLatency)}`, children: getLatencyText(currentNodeWithLatency) }),
1325
+ isAutoSelecting && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cdn-latency latency-testing", children: "\u6D4B\u901F\u4E2D" }),
1326
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: `cdn-selector-arrow ${isOpen ? "cdn-arrow-open" : ""}`, children: /* @__PURE__ */ jsxRuntime.jsx(ChevronDownIcon, {}) })
1327
+ ]
1328
+ }
1329
+ ),
1330
+ showRefreshButton && /* @__PURE__ */ jsxRuntime.jsx(
1331
+ "button",
1332
+ {
1333
+ className: `cdn-refresh-button ${isTesting ? "cdn-refreshing" : ""}`,
1334
+ onClick: handleRefresh,
1335
+ disabled: disabled || isTesting,
1336
+ type: "button",
1337
+ title: "\u5237\u65B0\u6D4B\u901F",
1338
+ "aria-label": "\u5237\u65B0\u6D4B\u901F",
1339
+ children: /* @__PURE__ */ jsxRuntime.jsx(RefreshIcon, {})
1340
+ }
1341
+ ),
1342
+ isOpen && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cdn-node-list", role: "listbox", children: nodes.length === 0 ? renderEmpty ? renderEmpty() : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cdn-no-nodes", children: "\u6682\u65E0\u53EF\u7528\u8282\u70B9" }) : isTesting && renderLoading ? renderLoading() : nodes.map((node) => {
1343
+ const isSelected = currentNode?.id === node.id;
1344
+ const isDisabled = disabled || node.enabled === false;
1345
+ const latencyText = getLatencyText(node);
1346
+ const latencyClass = getLatencyClassName(node);
1347
+ if (renderNode) {
1348
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { role: "option", "aria-selected": isSelected, children: renderNode({
1349
+ node,
1350
+ isSelected,
1351
+ isDisabled,
1352
+ latencyText,
1353
+ latencyClassName: latencyClass,
1354
+ onSelect: () => handleSelect(node.id)
1355
+ }) }, node.id);
1356
+ }
1357
+ return /* @__PURE__ */ jsxRuntime.jsxs(
1358
+ "button",
1359
+ {
1360
+ className: `cdn-node-option ${isSelected ? "cdn-node-selected" : ""} cdn-region-${node.region}`,
1361
+ onClick: () => handleSelect(node.id),
1362
+ disabled: isDisabled,
1363
+ type: "button",
1364
+ role: "option",
1365
+ "aria-selected": isSelected,
1366
+ children: [
1367
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cdn-node-info", children: [
1368
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cdn-node-name", children: node.name }),
1369
+ showRegion && node.region && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cdn-node-region", children: REGION_LABELS[node.region] ?? node.region }),
1370
+ node.description && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cdn-node-desc", children: node.description })
1371
+ ] }),
1372
+ showLatency && /* @__PURE__ */ jsxRuntime.jsx("span", { className: `cdn-latency ${latencyClass}`, children: latencyText })
1373
+ ]
1374
+ },
1375
+ node.id
1376
+ );
1377
+ }) })
1378
+ ] })
1379
+ ] });
1380
+ }
1381
+
1382
+ exports.CDNNodeSelector = CDNNodeSelector;
1383
+ exports.CDNProvider = CDNProvider;
1384
+ exports.CDNTester = CDNTester;
1385
+ exports.CDN_NODE_PRESETS = CDN_NODE_PRESETS;
1386
+ exports.ChunkedFetcher = ChunkedFetcher;
1387
+ exports.DEFAULTS = DEFAULTS;
1388
+ exports.DEFAULT_GITHUB_CDN_NODES = DEFAULT_GITHUB_CDN_NODES;
1389
+ exports.ForgeEngine = ForgeEngine;
1390
+ exports.REGION_LABELS = REGION_LABELS;
1391
+ exports.createForgeConfig = createForgeConfig;
1392
+ exports.createWorkerNode = createWorkerNode;
1393
+ exports.getLatencyClassName = getLatencyClassName;
1394
+ exports.getLatencyText = getLatencyText;
1395
+ exports.getSortedNodesWithLatency = getSortedNodesWithLatency;
1396
+ exports.normalizeConfig = normalizeConfig;
1397
+ exports.parseCacheYaml = parseCacheYaml;
1398
+ exports.parseInfoYaml = parseInfoYaml;
1399
+ exports.serializeCacheYaml = serializeCacheYaml;
1400
+ exports.serializeInfoYaml = serializeInfoYaml;
1401
+ exports.useCDN = useCDN;
1402
+ exports.useCDNStatus = useCDNStatus;
1403
+ exports.useCDNUrl = useCDNUrl;
1404
+ exports.useCurrentCDNNode = useCurrentCDNNode;
1405
+ exports.useReqByCDN = useReqByCDN;