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.
@@ -0,0 +1,142 @@
1
+ import {
2
+ CDN_NODE_PRESETS,
3
+ DEFAULT_GITHUB_CDN_NODES,
4
+ createWorkerNode,
5
+ CDNTester,
6
+ getSortedNodesWithLatency,
7
+ } from '../cdnNodes';
8
+ import { createForgeConfig, normalizeConfig } from '../config';
9
+ import type { LatencyResult } from '../../types';
10
+
11
+ describe('CDN_NODE_PRESETS', () => {
12
+ const ctx = { user: 'owner', repo: 'repo', ref: 'v1.0' };
13
+
14
+ test('jsDelivr main builds correct URL', () => {
15
+ const url = CDN_NODE_PRESETS.jsdelivr_main.buildUrl(ctx, '/docs/readme.md');
16
+ expect(url).toBe('https://cdn.jsdelivr.net/gh/owner/repo@v1.0/docs/readme.md');
17
+ });
18
+
19
+ test('jsDelivr fastly builds correct URL', () => {
20
+ const url = CDN_NODE_PRESETS.jsdelivr_fastly.buildUrl(ctx, '/file.txt');
21
+ expect(url).toBe('https://fastly.jsdelivr.net/gh/owner/repo@v1.0/file.txt');
22
+ });
23
+
24
+ test('GitHub Raw builds correct URL', () => {
25
+ const url = CDN_NODE_PRESETS.github_raw.buildUrl(ctx, '/README.md');
26
+ expect(url).toBe('https://raw.githubusercontent.com/owner/repo/v1.0/README.md');
27
+ });
28
+
29
+ test('JSD Mirror builds correct URL', () => {
30
+ const url = CDN_NODE_PRESETS.jsd_mirror.buildUrl(ctx, '/data.json');
31
+ expect(url).toBe('https://cdn.jsdmirror.com/gh/owner/repo@v1.0/data.json');
32
+ });
33
+
34
+ test('handles path without leading slash', () => {
35
+ const url = CDN_NODE_PRESETS.jsdelivr_main.buildUrl(ctx, 'no-slash.txt');
36
+ expect(url).toBe('https://cdn.jsdelivr.net/gh/owner/repo@v1.0/no-slash.txt');
37
+ });
38
+ });
39
+
40
+ describe('DEFAULT_GITHUB_CDN_NODES', () => {
41
+ test('has 6 default nodes', () => {
42
+ expect(DEFAULT_GITHUB_CDN_NODES).toHaveLength(6);
43
+ });
44
+
45
+ test('all nodes have required fields', () => {
46
+ for (const node of DEFAULT_GITHUB_CDN_NODES) {
47
+ expect(node.id).toBeTruthy();
48
+ expect(node.name).toBeTruthy();
49
+ expect(node.baseUrl).toBeTruthy();
50
+ expect(node.region).toBeTruthy();
51
+ expect(typeof node.buildUrl).toBe('function');
52
+ expect(typeof node.maxFileSize).toBe('number');
53
+ expect(typeof node.supportsRange).toBe('boolean');
54
+ }
55
+ });
56
+ });
57
+
58
+ describe('createWorkerNode', () => {
59
+ test('creates valid node', () => {
60
+ const node = createWorkerNode('my.workers.dev');
61
+ expect(node.id).toBe('cf-worker-my-workers-dev');
62
+ expect(node.name).toContain('my.workers.dev');
63
+ expect(node.baseUrl).toBe('https://my.workers.dev');
64
+ expect(node.maxFileSize).toBe(-1);
65
+ expect(node.supportsRange).toBe(true);
66
+ });
67
+
68
+ test('builds correct proxy URL', () => {
69
+ const node = createWorkerNode('proxy.example.com');
70
+ const ctx = { user: 'u', repo: 'r', ref: 'main' };
71
+ const url = node.buildUrl(ctx, '/file.txt');
72
+ expect(url).toBe('https://proxy.example.com/https://raw.githubusercontent.com/u/r/main/file.txt');
73
+ });
74
+ });
75
+
76
+ describe('CDNTester', () => {
77
+ test('getBestNodeId finds lowest latency', () => {
78
+ const tester = new CDNTester();
79
+ const results: LatencyResult[] = [
80
+ { nodeId: 'slow', latency: 500, success: true, timestamp: 1 },
81
+ { nodeId: 'fast', latency: 50, success: true, timestamp: 1 },
82
+ { nodeId: 'mid', latency: 200, success: true, timestamp: 1 },
83
+ { nodeId: 'fail', latency: -1, success: false, timestamp: 1 },
84
+ ];
85
+ expect(tester.getBestNodeId(results)).toBe('fast');
86
+ });
87
+
88
+ test('getBestNodeId returns null if all failed', () => {
89
+ const tester = new CDNTester();
90
+ const results: LatencyResult[] = [
91
+ { nodeId: 'a', latency: -1, success: false, timestamp: 1 },
92
+ { nodeId: 'b', latency: -1, success: false, timestamp: 1 },
93
+ ];
94
+ expect(tester.getBestNodeId(results)).toBeNull();
95
+ });
96
+ });
97
+
98
+ describe('getSortedNodesWithLatency', () => {
99
+ test('sorts by latency', () => {
100
+ const nodes = DEFAULT_GITHUB_CDN_NODES.slice(0, 3);
101
+ const results = new Map<string, LatencyResult>([
102
+ [nodes[0]!.id, { nodeId: nodes[0]!.id, latency: 300, success: true, timestamp: 1 }],
103
+ [nodes[1]!.id, { nodeId: nodes[1]!.id, latency: 100, success: true, timestamp: 1 }],
104
+ [nodes[2]!.id, { nodeId: nodes[2]!.id, latency: 200, success: true, timestamp: 1 }],
105
+ ]);
106
+
107
+ const sorted = getSortedNodesWithLatency(nodes, results);
108
+ expect(sorted[0]!.latency).toBe(100);
109
+ expect(sorted[1]!.latency).toBe(200);
110
+ expect(sorted[2]!.latency).toBe(300);
111
+ });
112
+
113
+ test('failed nodes sort to the end', () => {
114
+ const nodes = DEFAULT_GITHUB_CDN_NODES.slice(0, 2);
115
+ const results = new Map<string, LatencyResult>([
116
+ [nodes[0]!.id, { nodeId: nodes[0]!.id, latency: -1, success: false, timestamp: 1 }],
117
+ [nodes[1]!.id, { nodeId: nodes[1]!.id, latency: 100, success: true, timestamp: 1 }],
118
+ ]);
119
+
120
+ const sorted = getSortedNodesWithLatency(nodes, results);
121
+ expect(sorted[0]!.latency).toBe(100);
122
+ expect(sorted[1]!.latency).toBe(-1);
123
+ });
124
+ });
125
+
126
+ describe('createForgeConfig', () => {
127
+ test('creates config with defaults', () => {
128
+ const config = createForgeConfig({ user: 'u', repo: 'r', ref: 'main' });
129
+ expect(config.github.user).toBe('u');
130
+ expect(config.github.repo).toBe('r');
131
+ expect(config.github.ref).toBe('main');
132
+ });
133
+
134
+ test('normalizeConfig fills defaults', () => {
135
+ const config = createForgeConfig({ user: 'u', repo: 'r', ref: 'v1' });
136
+ const full = normalizeConfig(config);
137
+ expect(full.splitThreshold).toBe(20 * 1024 * 1024);
138
+ expect(full.maxConcurrency).toBe(6);
139
+ expect(full.turboMode).toBe(false);
140
+ expect(full.nodes).toHaveLength(6);
141
+ });
142
+ });
@@ -0,0 +1,117 @@
1
+ import {
2
+ parseInfoYaml,
3
+ serializeInfoYaml,
4
+ parseCacheYaml,
5
+ serializeCacheYaml,
6
+ } from '../manifest';
7
+ import type { SplitInfo, SplitCache } from '../../types';
8
+
9
+ describe('parseInfoYaml', () => {
10
+ test('parses complete info.yaml', () => {
11
+ const yaml = `originalName: loli.ass
12
+ totalSize: 26214400
13
+ mimeType: text/x-ssa
14
+ chunkSize: 19922944
15
+ createdAt: "2026-03-29T12:00:00Z"
16
+ chunks:
17
+ - fileName: 0-loli.ass
18
+ index: 0
19
+ size: 19922944
20
+ sha256: abc123def456
21
+ - fileName: 1-loli.ass
22
+ index: 1
23
+ size: 6291456
24
+ sha256: 789ghi012jkl
25
+ `;
26
+
27
+ const info = parseInfoYaml(yaml);
28
+
29
+ expect(info.originalName).toBe('loli.ass');
30
+ expect(info.totalSize).toBe(26214400);
31
+ expect(info.mimeType).toBe('text/x-ssa');
32
+ expect(info.chunkSize).toBe(19922944);
33
+ expect(info.createdAt).toBe('2026-03-29T12:00:00Z');
34
+ expect(info.chunks).toHaveLength(2);
35
+ expect(info.chunks[0]!.fileName).toBe('0-loli.ass');
36
+ expect(info.chunks[0]!.index).toBe(0);
37
+ expect(info.chunks[0]!.size).toBe(19922944);
38
+ expect(info.chunks[0]!.sha256).toBe('abc123def456');
39
+ expect(info.chunks[1]!.fileName).toBe('1-loli.ass');
40
+ expect(info.chunks[1]!.size).toBe(6291456);
41
+ });
42
+
43
+ test('handles single chunk', () => {
44
+ const yaml = `originalName: small.bin
45
+ totalSize: 1000
46
+ mimeType: application/octet-stream
47
+ chunkSize: 1000
48
+ createdAt: "2026-01-01T00:00:00Z"
49
+ chunks:
50
+ - fileName: 0-small.bin
51
+ index: 0
52
+ size: 1000
53
+ sha256: aaa111
54
+ `;
55
+ const info = parseInfoYaml(yaml);
56
+ expect(info.chunks).toHaveLength(1);
57
+ expect(info.chunks[0]!.sha256).toBe('aaa111');
58
+ });
59
+ });
60
+
61
+ describe('serializeInfoYaml', () => {
62
+ test('round-trips correctly', () => {
63
+ const info: SplitInfo = {
64
+ originalName: 'test.bin',
65
+ totalSize: 50000000,
66
+ mimeType: 'application/octet-stream',
67
+ chunkSize: 19922944,
68
+ createdAt: '2026-03-29T12:00:00Z',
69
+ chunks: [
70
+ { fileName: '0-test.bin', index: 0, size: 19922944, sha256: 'hash1' },
71
+ { fileName: '1-test.bin', index: 1, size: 19922944, sha256: 'hash2' },
72
+ { fileName: '2-test.bin', index: 2, size: 10154112, sha256: 'hash3' },
73
+ ],
74
+ };
75
+
76
+ const yaml = serializeInfoYaml(info);
77
+ const parsed = parseInfoYaml(yaml);
78
+
79
+ expect(parsed.originalName).toBe(info.originalName);
80
+ expect(parsed.totalSize).toBe(info.totalSize);
81
+ expect(parsed.chunks).toHaveLength(3);
82
+ expect(parsed.chunks[2]!.sha256).toBe('hash3');
83
+ });
84
+ });
85
+
86
+ describe('parseCacheYaml', () => {
87
+ test('parses .cache.yaml', () => {
88
+ const yaml = `sourcePath: static/ass/loli.ass
89
+ sourceHash: abcdef1234567890
90
+ sourceSize: 26214400
91
+ generatedAt: "2026-03-29T12:00:00Z"
92
+ `;
93
+ const cache = parseCacheYaml(yaml);
94
+ expect(cache.sourcePath).toBe('static/ass/loli.ass');
95
+ expect(cache.sourceHash).toBe('abcdef1234567890');
96
+ expect(cache.sourceSize).toBe(26214400);
97
+ expect(cache.generatedAt).toBe('2026-03-29T12:00:00Z');
98
+ });
99
+ });
100
+
101
+ describe('serializeCacheYaml', () => {
102
+ test('round-trips correctly', () => {
103
+ const cache: SplitCache = {
104
+ sourcePath: 'data/big.bin',
105
+ sourceHash: 'deadbeef',
106
+ sourceSize: 99999999,
107
+ generatedAt: '2026-06-01T00:00:00Z',
108
+ };
109
+
110
+ const yaml = serializeCacheYaml(cache);
111
+ const parsed = parseCacheYaml(yaml);
112
+
113
+ expect(parsed.sourcePath).toBe(cache.sourcePath);
114
+ expect(parsed.sourceHash).toBe(cache.sourceHash);
115
+ expect(parsed.sourceSize).toBe(cache.sourceSize);
116
+ });
117
+ });
@@ -0,0 +1,251 @@
1
+ /**
2
+ * CDN 节点定义 + 测速工具
3
+ * 专注 GitHub 文件代理
4
+ */
5
+
6
+ import type {
7
+ CDNNode,
8
+ CDNNodeWithLatency,
9
+ GitHubContext,
10
+ LatencyResult,
11
+ } from '../types';
12
+
13
+ // ============================================================
14
+ // URL 构建器
15
+ // ============================================================
16
+
17
+ /** jsDelivr 风格: cdn.jsdelivr.net/gh/{user}/{repo}@{ref}/{path} */
18
+ const buildJsDelivrUrl = (baseUrl: string) =>
19
+ (ctx: GitHubContext, filePath: string): string => {
20
+ const path = filePath.startsWith('/') ? filePath : `/${filePath}`;
21
+ return `${baseUrl}/${ctx.user}/${ctx.repo}@${ctx.ref}${path}`;
22
+ };
23
+
24
+ /** GitHub Raw: raw.githubusercontent.com/{user}/{repo}/{ref}/{path} */
25
+ const buildGithubRawUrl = (ctx: GitHubContext, filePath: string): string => {
26
+ const path = filePath.startsWith('/') ? filePath : `/${filePath}`;
27
+ return `https://raw.githubusercontent.com/${ctx.user}/${ctx.repo}/${ctx.ref}${path}`;
28
+ };
29
+
30
+ /** Cloudflare Worker 代理: {domain}/https://raw.githubusercontent.com/... */
31
+ const buildCfWorkerUrl = (domain: string) =>
32
+ (ctx: GitHubContext, filePath: string): string => {
33
+ const rawUrl = buildGithubRawUrl(ctx, filePath);
34
+ return `https://${domain}/${rawUrl}`;
35
+ };
36
+
37
+ // ============================================================
38
+ // 预定义节点
39
+ // ============================================================
40
+
41
+ export const CDN_NODE_PRESETS = {
42
+ jsdelivr_main: {
43
+ id: 'jsdelivr-main',
44
+ name: 'jsDelivr (Main)',
45
+ baseUrl: 'https://cdn.jsdelivr.net/gh',
46
+ region: 'global' as const,
47
+ buildUrl: buildJsDelivrUrl('https://cdn.jsdelivr.net/gh'),
48
+ maxFileSize: 20 * 1024 * 1024,
49
+ supportsRange: true,
50
+ description: 'jsDelivr 主节点,全球 CDN',
51
+ },
52
+ jsdelivr_fastly: {
53
+ id: 'jsdelivr-fastly',
54
+ name: 'jsDelivr (Fastly)',
55
+ baseUrl: 'https://fastly.jsdelivr.net/gh',
56
+ region: 'global' as const,
57
+ buildUrl: buildJsDelivrUrl('https://fastly.jsdelivr.net/gh'),
58
+ maxFileSize: 20 * 1024 * 1024,
59
+ supportsRange: true,
60
+ description: 'jsDelivr Fastly 节点',
61
+ },
62
+ jsdelivr_testing: {
63
+ id: 'jsdelivr-testing',
64
+ name: 'jsDelivr (Testing)',
65
+ baseUrl: 'https://testing.jsdelivr.net/gh',
66
+ region: 'global' as const,
67
+ buildUrl: buildJsDelivrUrl('https://testing.jsdelivr.net/gh'),
68
+ maxFileSize: 20 * 1024 * 1024,
69
+ supportsRange: true,
70
+ description: 'jsDelivr 测试节点',
71
+ },
72
+ jsd_mirror: {
73
+ id: 'jsd-mirror',
74
+ name: 'JSD Mirror',
75
+ baseUrl: 'https://cdn.jsdmirror.com/gh',
76
+ region: 'china' as const,
77
+ buildUrl: buildJsDelivrUrl('https://cdn.jsdmirror.com/gh'),
78
+ maxFileSize: 20 * 1024 * 1024,
79
+ supportsRange: true,
80
+ description: 'jsDelivr 国内镜像,腾讯云 EdgeOne 加速',
81
+ },
82
+ zstatic: {
83
+ id: 'zstatic',
84
+ name: 'Zstatic',
85
+ baseUrl: 'https://jsd.zstatic.net/gh',
86
+ region: 'china' as const,
87
+ buildUrl: buildJsDelivrUrl('https://jsd.zstatic.net/gh'),
88
+ maxFileSize: 20 * 1024 * 1024,
89
+ supportsRange: true,
90
+ description: 'Zstatic CDN 镜像',
91
+ },
92
+ github_raw: {
93
+ id: 'github-raw',
94
+ name: 'GitHub Raw',
95
+ baseUrl: 'https://raw.githubusercontent.com',
96
+ region: 'global' as const,
97
+ buildUrl: buildGithubRawUrl,
98
+ maxFileSize: 100 * 1024 * 1024,
99
+ supportsRange: true,
100
+ description: 'GitHub 原始文件服务',
101
+ },
102
+ } satisfies Record<string, CDNNode>;
103
+
104
+ /** 默认的 GitHub CDN 节点列表 */
105
+ export const DEFAULT_GITHUB_CDN_NODES: CDNNode[] = [
106
+ CDN_NODE_PRESETS.jsdelivr_main,
107
+ CDN_NODE_PRESETS.jsdelivr_fastly,
108
+ CDN_NODE_PRESETS.jsdelivr_testing,
109
+ CDN_NODE_PRESETS.jsd_mirror,
110
+ CDN_NODE_PRESETS.zstatic,
111
+ CDN_NODE_PRESETS.github_raw,
112
+ ];
113
+
114
+ /**
115
+ * 创建 Cloudflare Worker 代理节点
116
+ */
117
+ export function createWorkerNode(domain: string): CDNNode {
118
+ return {
119
+ id: `cf-worker-${domain.replace(/\./g, '-')}`,
120
+ name: `CF Worker (${domain})`,
121
+ baseUrl: `https://${domain}`,
122
+ region: 'global',
123
+ buildUrl: buildCfWorkerUrl(domain),
124
+ maxFileSize: -1,
125
+ supportsRange: true,
126
+ description: `Cloudflare Worker 代理 (${domain})`,
127
+ };
128
+ }
129
+
130
+ // ============================================================
131
+ // 测速工具
132
+ // ============================================================
133
+
134
+ export class CDNTester {
135
+ private timeout: number;
136
+ private retryCount: number;
137
+
138
+ constructor(timeout = 5000, retryCount = 2) {
139
+ this.timeout = timeout;
140
+ this.retryCount = retryCount;
141
+ }
142
+
143
+ /** 测试单节点延迟 */
144
+ async testNode(node: CDNNode, testUrl?: string): Promise<LatencyResult> {
145
+ const url = testUrl ?? node.baseUrl;
146
+
147
+ for (let attempt = 0; attempt <= this.retryCount; attempt++) {
148
+ try {
149
+ const start = performance.now();
150
+ const ctrl = new AbortController();
151
+ const tid = setTimeout(() => ctrl.abort(), this.timeout);
152
+
153
+ await fetch(url, {
154
+ method: 'HEAD',
155
+ mode: 'no-cors',
156
+ cache: 'no-cache',
157
+ signal: ctrl.signal,
158
+ });
159
+ clearTimeout(tid);
160
+
161
+ return {
162
+ nodeId: node.id,
163
+ latency: Math.round((performance.now() - start) * 100) / 100,
164
+ success: true,
165
+ timestamp: Date.now(),
166
+ };
167
+ } catch (err) {
168
+ if (attempt === this.retryCount) {
169
+ return {
170
+ nodeId: node.id,
171
+ latency: -1,
172
+ success: false,
173
+ timestamp: Date.now(),
174
+ error: err instanceof Error ? err.message : 'Unknown error',
175
+ };
176
+ }
177
+ await delay(200 * (attempt + 1));
178
+ }
179
+ }
180
+
181
+ return { nodeId: node.id, latency: -1, success: false, timestamp: Date.now() };
182
+ }
183
+
184
+ /** 并发测试所有启用节点 */
185
+ async testAll(nodes: CDNNode[]): Promise<LatencyResult[]> {
186
+ const enabled = nodes.filter((n) => n.enabled !== false);
187
+ return Promise.all(enabled.map((n) => this.testNode(n)));
188
+ }
189
+
190
+ /** 流式测试: 每完成一个就回调 */
191
+ async testAllStreaming(
192
+ nodes: CDNNode[],
193
+ onResult: (r: LatencyResult) => void,
194
+ ): Promise<LatencyResult[]> {
195
+ const enabled = nodes.filter((n) => n.enabled !== false);
196
+ const results: LatencyResult[] = [];
197
+
198
+ const promises = enabled.map(async (node) => {
199
+ const r = await this.testNode(node);
200
+ results.push(r);
201
+ onResult(r);
202
+ return r;
203
+ });
204
+
205
+ await Promise.all(promises);
206
+ return results;
207
+ }
208
+
209
+ /** 找延迟最低的节点 ID */
210
+ getBestNodeId(results: LatencyResult[]): string | null {
211
+ const sorted = results
212
+ .filter((r) => r.success && r.latency >= 0)
213
+ .sort((a, b) => a.latency - b.latency);
214
+ return sorted.length > 0 ? sorted[0]!.nodeId : null;
215
+ }
216
+
217
+ setTimeout(ms: number): void { this.timeout = ms; }
218
+ setRetryCount(n: number): void { this.retryCount = n; }
219
+ }
220
+
221
+ /** 获取排序后的节点列表 (带延迟信息) */
222
+ export function getSortedNodesWithLatency(
223
+ nodes: CDNNode[],
224
+ results: Map<string, LatencyResult>,
225
+ ): CDNNodeWithLatency[] {
226
+ return nodes
227
+ .filter((n) => n.enabled !== false)
228
+ .map((node) => {
229
+ const r = results.get(node.id);
230
+ return {
231
+ ...node,
232
+ latency: r?.success ? r.latency : r ? -1 : undefined,
233
+ latencyStatus: r
234
+ ? r.success ? ('success' as const) : ('failed' as const)
235
+ : ('idle' as const),
236
+ };
237
+ })
238
+ .sort((a, b) => {
239
+ if (a.latency === undefined && b.latency === undefined) return 0;
240
+ if (a.latency === undefined) return 1;
241
+ if (b.latency === undefined) return -1;
242
+ if (a.latency < 0 && b.latency < 0) return 0;
243
+ if (a.latency < 0) return 1;
244
+ if (b.latency < 0) return -1;
245
+ return a.latency - b.latency;
246
+ });
247
+ }
248
+
249
+ function delay(ms: number): Promise<void> {
250
+ return new Promise((resolve) => setTimeout(resolve, ms));
251
+ }