@esmx/fetch 3.0.0-rc.10
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/fetch-pkg.d.ts +7 -0
- package/dist/fetch-pkg.mjs +193 -0
- package/dist/fetch-pkgs.d.ts +14 -0
- package/dist/fetch-pkgs.mjs +102 -0
- package/dist/fetch.test.d.ts +1 -0
- package/dist/fetch.test.mjs +21 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.mjs +3 -0
- package/dist/types.d.ts +139 -0
- package/dist/types.mjs +0 -0
- package/dist/utils.d.ts +16 -0
- package/dist/utils.mjs +58 -0
- package/package.json +47 -0
- package/src/fetch-pkg.ts +219 -0
- package/src/fetch-pkgs.ts +139 -0
- package/src/fetch.test.ts +22 -0
- package/src/index.ts +31 -0
- package/src/types.ts +153 -0
- package/src/utils.ts +83 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { FetchPkgOptions, FetchResult } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* 获取文件,并缓存到本地。如果有缓存则使用缓存
|
|
4
|
+
*/
|
|
5
|
+
export declare function fetchPkg<Level extends number>({ cacheDir, outputDir, noCache, returnLevel, logger, axiosReqCfg, name, url, onFinally }: FetchPkgOptions & {
|
|
6
|
+
returnLevel?: Level;
|
|
7
|
+
}): Promise<FetchResult<Level>>;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import genSysCacheDir from "cachedir";
|
|
4
|
+
import { downloadFile, getHashText } from "./utils.mjs";
|
|
5
|
+
const sysCacheDir = path.join(genSysCacheDir("npm-esmx"), "packages");
|
|
6
|
+
export async function fetchPkg({
|
|
7
|
+
cacheDir = sysCacheDir,
|
|
8
|
+
outputDir,
|
|
9
|
+
noCache,
|
|
10
|
+
returnLevel = 2,
|
|
11
|
+
logger = console.log,
|
|
12
|
+
axiosReqCfg = {},
|
|
13
|
+
name,
|
|
14
|
+
url = "",
|
|
15
|
+
onFinally = () => {
|
|
16
|
+
}
|
|
17
|
+
}) {
|
|
18
|
+
const returnWrapper = (ans) => {
|
|
19
|
+
onFinally(ans);
|
|
20
|
+
return returnLevel === 0 ? ans.hasError : ans;
|
|
21
|
+
};
|
|
22
|
+
const paramsChecker = () => {
|
|
23
|
+
url = axiosReqCfg.url || url;
|
|
24
|
+
if (axiosReqCfg.baseURL) {
|
|
25
|
+
url = new URL(url, axiosReqCfg.baseURL).href;
|
|
26
|
+
}
|
|
27
|
+
if (!name) {
|
|
28
|
+
return returnWrapper({
|
|
29
|
+
hasError: true,
|
|
30
|
+
name,
|
|
31
|
+
url,
|
|
32
|
+
error: new Error("name is empty")
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
if (!url) {
|
|
36
|
+
return returnWrapper({
|
|
37
|
+
hasError: true,
|
|
38
|
+
name,
|
|
39
|
+
url,
|
|
40
|
+
error: new Error("url is empty")
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const urlInfo2 = new URL(url);
|
|
44
|
+
urlInfo2.searchParams.append(Date.now() + "_", Date.now() + "");
|
|
45
|
+
axiosReqCfg.headers = axiosReqCfg.headers || {};
|
|
46
|
+
axiosReqCfg.headers["Cache-Control"] = axiosReqCfg.headers["Cache-Control"] || "no-cache";
|
|
47
|
+
axiosReqCfg.headers.Pragma = axiosReqCfg.headers.Pragma || "no-cache";
|
|
48
|
+
axiosReqCfg.headers.Expires = axiosReqCfg.headers.Expires || "0";
|
|
49
|
+
url = urlInfo2.href;
|
|
50
|
+
if (noCache && !outputDir) {
|
|
51
|
+
return returnWrapper({
|
|
52
|
+
hasError: true,
|
|
53
|
+
name,
|
|
54
|
+
url,
|
|
55
|
+
error: new Error("outputFilePath is empty")
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (!noCache && cacheDir === "") {
|
|
59
|
+
return returnWrapper({
|
|
60
|
+
hasError: true,
|
|
61
|
+
name,
|
|
62
|
+
url,
|
|
63
|
+
error: new Error("cacheDir is empty")
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const initDirs = async () => {
|
|
68
|
+
if (outputDir && !fs.existsSync(outputDir)) {
|
|
69
|
+
await fs.promises.mkdir(outputDir, { recursive: true });
|
|
70
|
+
}
|
|
71
|
+
if (!noCache) {
|
|
72
|
+
if (!fs.existsSync(cacheDir)) {
|
|
73
|
+
await fs.promises.mkdir(cacheDir, { recursive: true });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const t = paramsChecker();
|
|
78
|
+
logger?.(`[fetch] [${name}] From ${url}`);
|
|
79
|
+
if (t) {
|
|
80
|
+
return t;
|
|
81
|
+
}
|
|
82
|
+
await initDirs();
|
|
83
|
+
const urlInfo = new URL(url);
|
|
84
|
+
const fileExt = path.parse(urlInfo.pathname).ext;
|
|
85
|
+
urlInfo.pathname = urlInfo.pathname.replace(
|
|
86
|
+
new RegExp(fileExt + "$"),
|
|
87
|
+
".txt"
|
|
88
|
+
);
|
|
89
|
+
const hashUrl = urlInfo.href;
|
|
90
|
+
let hash = "";
|
|
91
|
+
let hashAlg = "";
|
|
92
|
+
if (!noCache) {
|
|
93
|
+
const hashInfo = await getHashText(hashUrl, {
|
|
94
|
+
...axiosReqCfg,
|
|
95
|
+
onDownloadProgress: void 0
|
|
96
|
+
});
|
|
97
|
+
if (hashInfo.error) {
|
|
98
|
+
logger?.(`[fetch] [${name}] Error: ${hashInfo.error}`);
|
|
99
|
+
return returnWrapper({
|
|
100
|
+
hasError: true,
|
|
101
|
+
name,
|
|
102
|
+
url,
|
|
103
|
+
error: hashInfo.error
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
({ hash, hashAlg } = hashInfo);
|
|
107
|
+
}
|
|
108
|
+
const download2output = noCache || !hash;
|
|
109
|
+
if (download2output && !outputDir) {
|
|
110
|
+
logger?.(`[fetch] [${name}] Error: outputDir is empty`);
|
|
111
|
+
return returnWrapper({
|
|
112
|
+
hasError: true,
|
|
113
|
+
name,
|
|
114
|
+
url,
|
|
115
|
+
error: new Error("outputDir is empty")
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const outputFilePath = outputDir ? path.join(outputDir, name + fileExt) : "";
|
|
119
|
+
const cacheFilePath = path.join(cacheDir, hash + fileExt);
|
|
120
|
+
const tmpFilePath = (download2output ? outputFilePath : path.join(cacheDir, hash + fileExt)) + ".tmp";
|
|
121
|
+
if (hash && fs.existsSync(cacheFilePath)) {
|
|
122
|
+
logger?.(`[fetch] [${name}] Hit cache`);
|
|
123
|
+
if (outputFilePath) {
|
|
124
|
+
await fs.promises.cp(cacheFilePath, outputFilePath, {
|
|
125
|
+
force: true
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return returnWrapper({
|
|
129
|
+
hasError: false,
|
|
130
|
+
name,
|
|
131
|
+
url,
|
|
132
|
+
filePath: outputFilePath || cacheFilePath,
|
|
133
|
+
cacheFilePath,
|
|
134
|
+
hash,
|
|
135
|
+
hitCache: true
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
const error = await downloadFile(
|
|
139
|
+
url,
|
|
140
|
+
tmpFilePath,
|
|
141
|
+
hash,
|
|
142
|
+
hashAlg,
|
|
143
|
+
axiosReqCfg
|
|
144
|
+
);
|
|
145
|
+
if (error) {
|
|
146
|
+
const err = error.error;
|
|
147
|
+
switch (error.errType) {
|
|
148
|
+
case "axios":
|
|
149
|
+
logger?.(`[fetch] [${name}] Error: ${err.message}`);
|
|
150
|
+
break;
|
|
151
|
+
case "file":
|
|
152
|
+
logger?.(`[fetch] [${name}] Write file error: ${err.message}`);
|
|
153
|
+
break;
|
|
154
|
+
case "hash":
|
|
155
|
+
logger?.(`[fetch] [${name}] Hash not match`);
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
return returnWrapper({ hasError: true, name, url, error: err });
|
|
159
|
+
}
|
|
160
|
+
if (download2output) {
|
|
161
|
+
await fs.promises.rename(tmpFilePath, outputFilePath);
|
|
162
|
+
logger?.(
|
|
163
|
+
`[fetch] [${name}] Downloaded without cache: ${outputFilePath}`
|
|
164
|
+
);
|
|
165
|
+
return returnWrapper({
|
|
166
|
+
hasError: false,
|
|
167
|
+
name,
|
|
168
|
+
url,
|
|
169
|
+
filePath: outputFilePath,
|
|
170
|
+
cacheFilePath: outputFilePath,
|
|
171
|
+
hash,
|
|
172
|
+
hitCache: true
|
|
173
|
+
});
|
|
174
|
+
} else {
|
|
175
|
+
await fs.promises.rename(tmpFilePath, cacheFilePath);
|
|
176
|
+
if (outputFilePath) {
|
|
177
|
+
await fs.promises.cp(cacheFilePath, outputFilePath, {
|
|
178
|
+
force: true
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const filePath = outputFilePath || cacheFilePath;
|
|
182
|
+
logger?.(`[fetch] [${name}] Downloaded: ${filePath}`);
|
|
183
|
+
return returnWrapper({
|
|
184
|
+
hasError: false,
|
|
185
|
+
name,
|
|
186
|
+
url,
|
|
187
|
+
filePath,
|
|
188
|
+
cacheFilePath,
|
|
189
|
+
hash,
|
|
190
|
+
hitCache: false
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FetchPkgsOptions, FetchPkgsWithBarOptions, FetchResults } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* 获取多个文件,并缓存到本地。如果有缓存则使用缓存。
|
|
4
|
+
*/
|
|
5
|
+
export declare function fetchPkgs<Level extends number>({ packs, returnLevel, onEachFinally, ...options }: FetchPkgsOptions & {
|
|
6
|
+
returnLevel?: Level;
|
|
7
|
+
}): Promise<FetchResults<Level>>;
|
|
8
|
+
/**
|
|
9
|
+
* 获取多个文件,并缓存到本地。如果有缓存则使用缓存。带有进度条
|
|
10
|
+
*/
|
|
11
|
+
export declare function fetchPkgsWithBar<Level extends number>({ packs, axiosReqCfg, multiBarCfg, logger, onEachFinally, ...options }: FetchPkgsWithBarOptions & {
|
|
12
|
+
returnLevel?: Level;
|
|
13
|
+
}): Promise<FetchResults<Level>>;
|
|
14
|
+
export { fetchPkgsWithBar as fetchPkgsWithProgress };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { styleText } from "node:util";
|
|
2
|
+
import { MultiBar } from "cli-progress";
|
|
3
|
+
import { fetchPkg } from "./fetch-pkg.mjs";
|
|
4
|
+
export async function fetchPkgs({
|
|
5
|
+
packs,
|
|
6
|
+
returnLevel = 2,
|
|
7
|
+
onEachFinally = () => {
|
|
8
|
+
},
|
|
9
|
+
...options
|
|
10
|
+
}) {
|
|
11
|
+
if (new Set(packs.map((pack) => pack.name)).size !== packs.length) {
|
|
12
|
+
return returnLevel === 0 ? false : returnLevel === 1 ? packs.map(() => false) : packs.map(() => ({
|
|
13
|
+
hasError: true,
|
|
14
|
+
name: "",
|
|
15
|
+
url: "",
|
|
16
|
+
error: new Error("Duplicate name")
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
19
|
+
const axiosReqCfg = options.axiosReqCfg || {};
|
|
20
|
+
const results = await Promise.all(
|
|
21
|
+
packs.map(
|
|
22
|
+
(pack) => fetchPkg({
|
|
23
|
+
...options,
|
|
24
|
+
returnLevel,
|
|
25
|
+
...pack,
|
|
26
|
+
axiosReqCfg: {
|
|
27
|
+
...axiosReqCfg,
|
|
28
|
+
...pack.axiosReqCfg || {}
|
|
29
|
+
},
|
|
30
|
+
onFinally: (result) => onEachFinally(result)
|
|
31
|
+
})
|
|
32
|
+
)
|
|
33
|
+
);
|
|
34
|
+
return returnLevel === 0 ? results.every((r) => !r) : returnLevel === 1 ? results.map((r) => !r.hasError) : results;
|
|
35
|
+
}
|
|
36
|
+
export async function fetchPkgsWithBar({
|
|
37
|
+
packs,
|
|
38
|
+
axiosReqCfg = {},
|
|
39
|
+
multiBarCfg = {},
|
|
40
|
+
logger = (barLogger, str) => barLogger(str),
|
|
41
|
+
onEachFinally = () => {
|
|
42
|
+
},
|
|
43
|
+
...options
|
|
44
|
+
}) {
|
|
45
|
+
const multiBar = new MultiBar({
|
|
46
|
+
stopOnComplete: true,
|
|
47
|
+
format: " [{bar}] {percentage}% | {status} | {name}",
|
|
48
|
+
forceRedraw: true,
|
|
49
|
+
barCompleteChar: "#",
|
|
50
|
+
barIncompleteChar: "_",
|
|
51
|
+
autopadding: true,
|
|
52
|
+
...multiBarCfg
|
|
53
|
+
});
|
|
54
|
+
const multiBarLogger = (str = "") => {
|
|
55
|
+
multiBar.log(str.trimEnd() + "\n");
|
|
56
|
+
multiBar.update();
|
|
57
|
+
};
|
|
58
|
+
const bars = packs.reduce((obj, { name }) => {
|
|
59
|
+
obj[name] = multiBar.create(1, 0, { status: "WAT", name });
|
|
60
|
+
return obj;
|
|
61
|
+
}, {});
|
|
62
|
+
const timer = setInterval(() => multiBar.update(), 1e3);
|
|
63
|
+
const fetchPkgsPacks = packs.map((pack) => {
|
|
64
|
+
const ans = pack;
|
|
65
|
+
ans.axiosReqCfg = ans.axiosReqCfg || {};
|
|
66
|
+
const onDownloadProgress = ans.axiosReqCfg.onDownloadProgress || axiosReqCfg.onDownloadProgress;
|
|
67
|
+
ans.axiosReqCfg.onDownloadProgress = (progressEvent) => {
|
|
68
|
+
bars[ans.name].setTotal(
|
|
69
|
+
progressEvent?.total ?? progressEvent.loaded + 1
|
|
70
|
+
);
|
|
71
|
+
bars[ans.name].update(progressEvent.loaded, {
|
|
72
|
+
status: styleText("yellow", "DLD"),
|
|
73
|
+
name: ans.name
|
|
74
|
+
});
|
|
75
|
+
onDownloadProgress?.(progressEvent);
|
|
76
|
+
};
|
|
77
|
+
if (ans.logger) {
|
|
78
|
+
const orgLogger = ans.logger;
|
|
79
|
+
ans.logger = (str = "") => orgLogger(multiBarLogger, str);
|
|
80
|
+
}
|
|
81
|
+
return ans;
|
|
82
|
+
});
|
|
83
|
+
const results = await fetchPkgs({
|
|
84
|
+
packs: fetchPkgsPacks,
|
|
85
|
+
...options,
|
|
86
|
+
axiosReqCfg,
|
|
87
|
+
logger: logger && ((str) => logger(multiBarLogger, str)),
|
|
88
|
+
onEachFinally: (result) => {
|
|
89
|
+
bars[result.name].update(Number.POSITIVE_INFINITY, {
|
|
90
|
+
status: result.hasError ? styleText("red", "ERR") : styleText("green", result.hitCache ? "HIT" : "SUC"),
|
|
91
|
+
name: result.name
|
|
92
|
+
});
|
|
93
|
+
multiBar.update();
|
|
94
|
+
onEachFinally(result);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
clearInterval(timer);
|
|
98
|
+
multiBar.update();
|
|
99
|
+
multiBar.stop();
|
|
100
|
+
return results;
|
|
101
|
+
}
|
|
102
|
+
export { fetchPkgsWithBar as fetchPkgsWithProgress };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { test } from "vitest";
|
|
4
|
+
import { fetchPkgsWithProgress } from "./index.mjs";
|
|
5
|
+
test("base", async () => {
|
|
6
|
+
const urls = ["ssr-html/versions/latest.tgz", "ssr-html/versions/1.0.tgz"];
|
|
7
|
+
const outputDir = path.join(
|
|
8
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
9
|
+
"output"
|
|
10
|
+
);
|
|
11
|
+
await fetchPkgsWithProgress({
|
|
12
|
+
outputDir,
|
|
13
|
+
axiosReqCfg: {
|
|
14
|
+
baseURL: "https://www.esmnext.com",
|
|
15
|
+
timeout: 4500
|
|
16
|
+
},
|
|
17
|
+
packs: urls.map((url) => ({ url, name: url.split("/")[0] }))
|
|
18
|
+
}).then((...args) => {
|
|
19
|
+
console.log(...args);
|
|
20
|
+
});
|
|
21
|
+
}, 5e3);
|
package/dist/index.d.ts
ADDED
package/dist/index.mjs
ADDED
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { AxiosRequestConfig } from 'axios';
|
|
2
|
+
import type { Options as MultiBarOptions } from 'cli-progress';
|
|
3
|
+
export interface FetchBaseOptions {
|
|
4
|
+
/**
|
|
5
|
+
* 是否不使用缓存 默认为 `false` (即默认使用缓存)
|
|
6
|
+
* 现在的逻辑是不使用缓存时,不校验 hash 值。
|
|
7
|
+
*/
|
|
8
|
+
noCache?: boolean;
|
|
9
|
+
/**
|
|
10
|
+
* 缓存文件夹路径 默认为 `{system cache dir}/npm-esmx/packages`。
|
|
11
|
+
* 当 `noCache` 为 `true` 时,该参数无效。
|
|
12
|
+
* 当 `noCache` 为 `false` 时,省略该参数会导致报错 `cacheDir is empty`。
|
|
13
|
+
*/
|
|
14
|
+
cacheDir?: string;
|
|
15
|
+
/**
|
|
16
|
+
* 输出的文件夹路径。
|
|
17
|
+
* 省略时不输出文件,如果使用缓存会下载到缓存。
|
|
18
|
+
* 不使用缓存时或无hash文件时,省略则会导致报错 `outputDir is empty`。
|
|
19
|
+
* 目前,如果不使用缓存,会直接下载到输出,且不进行 hash 校验。
|
|
20
|
+
*/
|
|
21
|
+
outputDir?: string;
|
|
22
|
+
/**
|
|
23
|
+
* 返回结果的级别 默认为 `2` (即默认返回详细结果)
|
|
24
|
+
* - `0`: 如果没有发生任何错误,返回 `true`,否则返回 `false`;
|
|
25
|
+
* - `1`: 根据入参顺序,返回 `boolean[]`,表示是否成功;
|
|
26
|
+
* - `2`: 返回详细结果。
|
|
27
|
+
*/
|
|
28
|
+
returnLevel?: number;
|
|
29
|
+
/**
|
|
30
|
+
* 日志输出函数 默认为 `console.log`
|
|
31
|
+
* @param str string 输出的字符串
|
|
32
|
+
* @returns void
|
|
33
|
+
*/
|
|
34
|
+
logger?: (str?: string) => void;
|
|
35
|
+
/**
|
|
36
|
+
* axios 请求配置
|
|
37
|
+
*/
|
|
38
|
+
axiosReqCfg?: AxiosRequestConfig;
|
|
39
|
+
/**
|
|
40
|
+
* 包下载结束时的回调函数
|
|
41
|
+
* @param pack 包信息
|
|
42
|
+
* @param result 结果
|
|
43
|
+
*/
|
|
44
|
+
onFinally?: (result: FetchResultSuccess | FetchResultError) => void;
|
|
45
|
+
}
|
|
46
|
+
export interface FetchPkgOptions extends FetchBaseOptions {
|
|
47
|
+
/**
|
|
48
|
+
* 包名。用于输出和保存时的文件名。
|
|
49
|
+
*/
|
|
50
|
+
name: string;
|
|
51
|
+
/**
|
|
52
|
+
* 下载地址。优先级低于 `axiosReqCfg?.url`
|
|
53
|
+
*/
|
|
54
|
+
url?: string;
|
|
55
|
+
}
|
|
56
|
+
export interface FetchPkgsOptions extends FetchBaseOptions {
|
|
57
|
+
/**
|
|
58
|
+
* 请确保每个包的 `name` 不同,否则会报错 `Duplicate name`。文件将会被下载到 `{outputDir}/{name}.ext`。
|
|
59
|
+
*/
|
|
60
|
+
packs: FetchPkgOptions[];
|
|
61
|
+
/**
|
|
62
|
+
* 每个包下载结束时的回调函数
|
|
63
|
+
* @param pack 包信息
|
|
64
|
+
* @param result 结果
|
|
65
|
+
*/
|
|
66
|
+
onEachFinally?: (result: FetchResultSuccess | FetchResultError) => void;
|
|
67
|
+
}
|
|
68
|
+
export type FetchPkgWithBarLogger = (multiBarLogger: (data?: string) => void, str?: string) => void;
|
|
69
|
+
export interface FetchPkgWithBarOptions extends Omit<FetchPkgOptions, 'logger'> {
|
|
70
|
+
/**
|
|
71
|
+
* 进度条日志输出函数,默认为空(不输出东西)。可以使用 `(barLogger, str) => barLogger(str)` 来输出日志。
|
|
72
|
+
* @param multiBarLogger `(data: string) => void` 进度条自带的日志输出函数,请通过该函数进行日志输出
|
|
73
|
+
* @param str string 输出的字符串
|
|
74
|
+
* @returns void
|
|
75
|
+
*/
|
|
76
|
+
logger?: FetchPkgWithBarLogger;
|
|
77
|
+
}
|
|
78
|
+
export interface FetchPkgsWithBarOptions extends Omit<FetchBaseOptions, 'logger'> {
|
|
79
|
+
/**
|
|
80
|
+
* 进度条日志输出函数,默认为 `(barLogger, str) => barLogger(str)`
|
|
81
|
+
* @param multiBarLogger `(data: string) => void` 进度条自带的日志输出函数,请通过该函数进行日志输出
|
|
82
|
+
* @param str string 输出的字符串
|
|
83
|
+
* @returns void
|
|
84
|
+
*/
|
|
85
|
+
logger?: FetchPkgWithBarLogger;
|
|
86
|
+
/**
|
|
87
|
+
* 进度条配置
|
|
88
|
+
*/
|
|
89
|
+
multiBarCfg?: MultiBarOptions;
|
|
90
|
+
/**
|
|
91
|
+
* 每个包下载结束时的回调函数
|
|
92
|
+
* @param pack 包信息
|
|
93
|
+
* @param result 结果
|
|
94
|
+
*/
|
|
95
|
+
onEachFinally?: (result: FetchResultSuccess | FetchResultError) => void;
|
|
96
|
+
packs: FetchPkgWithBarOptions[];
|
|
97
|
+
}
|
|
98
|
+
export interface FetchResultBase {
|
|
99
|
+
/**
|
|
100
|
+
* 资源是从哪个 URL 获取的
|
|
101
|
+
*/
|
|
102
|
+
url: string;
|
|
103
|
+
/**
|
|
104
|
+
* 包名。用于输出和保存时的文件名。
|
|
105
|
+
*/
|
|
106
|
+
name: string;
|
|
107
|
+
}
|
|
108
|
+
export interface FetchResultSuccess extends FetchResultBase {
|
|
109
|
+
hasError: false;
|
|
110
|
+
/**
|
|
111
|
+
* 输出的文件路径
|
|
112
|
+
* 如果无 outputPath,且使用缓存,则为缓存文件路径。
|
|
113
|
+
*/
|
|
114
|
+
filePath: string;
|
|
115
|
+
/**
|
|
116
|
+
* 缓存文件路径 默认为 `{system cache dir}/npm-esmx/packages/hash.ext`
|
|
117
|
+
* 如果不使用缓存,则为空字符串。
|
|
118
|
+
*/
|
|
119
|
+
cacheFilePath: string;
|
|
120
|
+
/**
|
|
121
|
+
* 文件 hash 值
|
|
122
|
+
* 如果不使用缓存/无校验文件时,则为空字符串。
|
|
123
|
+
*/
|
|
124
|
+
hash: string;
|
|
125
|
+
/**
|
|
126
|
+
* 是否命中缓存
|
|
127
|
+
* 如果不使用缓存,则必定为 `false`。
|
|
128
|
+
*/
|
|
129
|
+
hitCache: boolean;
|
|
130
|
+
}
|
|
131
|
+
export interface FetchResultError extends FetchResultBase {
|
|
132
|
+
hasError: true;
|
|
133
|
+
/**
|
|
134
|
+
* 错误信息
|
|
135
|
+
*/
|
|
136
|
+
error: Error;
|
|
137
|
+
}
|
|
138
|
+
export type FetchResult<Level extends number> = Level extends 0 ? boolean : FetchResultSuccess | FetchResultError;
|
|
139
|
+
export type FetchResults<Level extends number> = Level extends 0 ? FetchResult<0> : Level extends 1 ? boolean[] : FetchResult<2>[];
|
package/dist/types.mjs
ADDED
|
File without changes
|
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type AxiosRequestConfig } from 'axios';
|
|
2
|
+
/**
|
|
3
|
+
* 获取 hash 文件的值
|
|
4
|
+
*/
|
|
5
|
+
export declare function getHashText(hashUrl: string, axiosOptions?: AxiosRequestConfig): Promise<{
|
|
6
|
+
hash: string;
|
|
7
|
+
hashAlg: string;
|
|
8
|
+
error?: Error;
|
|
9
|
+
}>;
|
|
10
|
+
/**
|
|
11
|
+
* 获取文件。如果有hash则校验hash
|
|
12
|
+
*/
|
|
13
|
+
export declare function downloadFile(url: string, filePath: string, hash: string, hashAlg: string, axiosOptions?: AxiosRequestConfig): Promise<null | {
|
|
14
|
+
errType?: 'axios' | 'file' | 'hash';
|
|
15
|
+
error?: Error;
|
|
16
|
+
}>;
|
package/dist/utils.mjs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import axios from "axios";
|
|
4
|
+
export async function getHashText(hashUrl, axiosOptions = {}) {
|
|
5
|
+
try {
|
|
6
|
+
let hash = (await axios.get(hashUrl, {
|
|
7
|
+
...axiosOptions,
|
|
8
|
+
responseType: "text"
|
|
9
|
+
})).data;
|
|
10
|
+
let hashAlg = "sha256";
|
|
11
|
+
if (hash.includes("-")) {
|
|
12
|
+
const t = hash.split("-");
|
|
13
|
+
hash = t.pop();
|
|
14
|
+
hashAlg = t.join("-");
|
|
15
|
+
}
|
|
16
|
+
if (!crypto.getHashes().includes(hashAlg)) {
|
|
17
|
+
return {
|
|
18
|
+
hash: "",
|
|
19
|
+
hashAlg: "",
|
|
20
|
+
error: new Error(`Unsupported hash algorithm ${hashAlg}`)
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return { hash, hashAlg };
|
|
24
|
+
} catch (error) {
|
|
25
|
+
return { hash: "", hashAlg: "", error };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export async function downloadFile(url, filePath, hash, hashAlg, axiosOptions = {}) {
|
|
29
|
+
let result;
|
|
30
|
+
try {
|
|
31
|
+
result = await axios.get(url, {
|
|
32
|
+
...axiosOptions,
|
|
33
|
+
responseType: "stream"
|
|
34
|
+
});
|
|
35
|
+
} catch (error) {
|
|
36
|
+
return { errType: "axios", error };
|
|
37
|
+
}
|
|
38
|
+
const hashStream = hash ? crypto.createHash(hashAlg) : null;
|
|
39
|
+
const fileStream = fs.createWriteStream(filePath);
|
|
40
|
+
const streamPromise = new Promise((resolve, reject) => {
|
|
41
|
+
fileStream.on("finish", resolve);
|
|
42
|
+
fileStream.on("error", reject);
|
|
43
|
+
});
|
|
44
|
+
result.data.on("data", (chunk) => {
|
|
45
|
+
hashStream?.update(chunk);
|
|
46
|
+
fileStream.write(chunk);
|
|
47
|
+
});
|
|
48
|
+
result.data.on("end", () => fileStream.end());
|
|
49
|
+
try {
|
|
50
|
+
await streamPromise;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return { errType: "file", error };
|
|
53
|
+
}
|
|
54
|
+
if (hash && hashStream?.digest("hex") !== hash) {
|
|
55
|
+
return { errType: "hash", error: new Error("Hash not match") };
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@esmx/fetch",
|
|
3
|
+
"template": "library-node",
|
|
4
|
+
"scripts": {
|
|
5
|
+
"lint:css": "stylelint '**/*.{css,vue}' --fix --aei",
|
|
6
|
+
"lint:type": "tsc --noEmit",
|
|
7
|
+
"test": "vitest --pass-with-no-tests",
|
|
8
|
+
"coverage": "vitest run --coverage --pass-with-no-tests",
|
|
9
|
+
"lint:js": "biome check --write --no-errors-on-unmatched",
|
|
10
|
+
"build": "unbuild"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"axios": "^1.7.7",
|
|
14
|
+
"cachedir": "^2.4.0",
|
|
15
|
+
"cli-progress": "^3.12.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@biomejs/biome": "1.9.4",
|
|
19
|
+
"@esmx/lint": "3.0.0-rc.10",
|
|
20
|
+
"@types/cli-progress": "^3.11.6",
|
|
21
|
+
"@types/node": "22.9.0",
|
|
22
|
+
"@vitest/coverage-v8": "2.1.5",
|
|
23
|
+
"stylelint": "16.10.0",
|
|
24
|
+
"typescript": "5.6.3",
|
|
25
|
+
"unbuild": "2.0.0",
|
|
26
|
+
"vitest": "2.1.5"
|
|
27
|
+
},
|
|
28
|
+
"version": "3.0.0-rc.10",
|
|
29
|
+
"type": "module",
|
|
30
|
+
"private": false,
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"import": "./dist/index.mjs",
|
|
34
|
+
"types": "./dist/index.d.ts"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"module": "dist/index.mjs",
|
|
38
|
+
"types": "./dist/index.d.ts",
|
|
39
|
+
"files": [
|
|
40
|
+
"src",
|
|
41
|
+
"dist",
|
|
42
|
+
"*.mjs",
|
|
43
|
+
"template",
|
|
44
|
+
"public"
|
|
45
|
+
],
|
|
46
|
+
"gitHead": "4a528ffecfdc6f2c6e7d97bc952427745f467691"
|
|
47
|
+
}
|
package/src/fetch-pkg.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import genSysCacheDir from 'cachedir';
|
|
4
|
+
|
|
5
|
+
import type { FetchPkgOptions, FetchResult } from './types';
|
|
6
|
+
import { downloadFile, getHashText } from './utils';
|
|
7
|
+
|
|
8
|
+
const sysCacheDir = path.join(genSysCacheDir('npm-esmx'), 'packages');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 获取文件,并缓存到本地。如果有缓存则使用缓存
|
|
12
|
+
*/
|
|
13
|
+
export async function fetchPkg<Level extends number>({
|
|
14
|
+
cacheDir = sysCacheDir,
|
|
15
|
+
outputDir,
|
|
16
|
+
noCache,
|
|
17
|
+
returnLevel = 2 as Level,
|
|
18
|
+
logger = console.log,
|
|
19
|
+
axiosReqCfg = {},
|
|
20
|
+
name,
|
|
21
|
+
url = '',
|
|
22
|
+
onFinally = () => {}
|
|
23
|
+
}: FetchPkgOptions & { returnLevel?: Level }): Promise<FetchResult<Level>> {
|
|
24
|
+
const returnWrapper = (ans: FetchResult<2>) => {
|
|
25
|
+
onFinally(ans);
|
|
26
|
+
return (returnLevel === 0 ? ans.hasError : ans) as FetchResult<Level>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const paramsChecker = () => {
|
|
30
|
+
url = axiosReqCfg.url || url;
|
|
31
|
+
if (axiosReqCfg.baseURL) {
|
|
32
|
+
url = new URL(url, axiosReqCfg.baseURL).href;
|
|
33
|
+
}
|
|
34
|
+
if (!name) {
|
|
35
|
+
return returnWrapper({
|
|
36
|
+
hasError: true,
|
|
37
|
+
name,
|
|
38
|
+
url,
|
|
39
|
+
error: new Error('name is empty')
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
if (!url) {
|
|
43
|
+
return returnWrapper({
|
|
44
|
+
hasError: true,
|
|
45
|
+
name,
|
|
46
|
+
url,
|
|
47
|
+
error: new Error('url is empty')
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const urlInfo = new URL(url);
|
|
51
|
+
urlInfo.searchParams.append(Date.now() + '_', Date.now() + '');
|
|
52
|
+
axiosReqCfg.headers = axiosReqCfg.headers || {};
|
|
53
|
+
axiosReqCfg.headers['Cache-Control'] =
|
|
54
|
+
axiosReqCfg.headers['Cache-Control'] || 'no-cache';
|
|
55
|
+
axiosReqCfg.headers.Pragma = axiosReqCfg.headers.Pragma || 'no-cache';
|
|
56
|
+
axiosReqCfg.headers.Expires = axiosReqCfg.headers.Expires || '0';
|
|
57
|
+
url = urlInfo.href;
|
|
58
|
+
if (noCache && !outputDir) {
|
|
59
|
+
return returnWrapper({
|
|
60
|
+
hasError: true,
|
|
61
|
+
name,
|
|
62
|
+
url,
|
|
63
|
+
error: new Error('outputFilePath is empty')
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (!noCache && cacheDir === '') {
|
|
67
|
+
return returnWrapper({
|
|
68
|
+
hasError: true,
|
|
69
|
+
name,
|
|
70
|
+
url,
|
|
71
|
+
error: new Error('cacheDir is empty')
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const initDirs = async () => {
|
|
77
|
+
if (outputDir && !fs.existsSync(outputDir)) {
|
|
78
|
+
await fs.promises.mkdir(outputDir, { recursive: true });
|
|
79
|
+
}
|
|
80
|
+
if (!noCache) {
|
|
81
|
+
if (!fs.existsSync(cacheDir)) {
|
|
82
|
+
await fs.promises.mkdir(cacheDir, { recursive: true });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const t = paramsChecker();
|
|
88
|
+
logger?.(`[fetch] [${name}] From ${url}`);
|
|
89
|
+
if (t) {
|
|
90
|
+
return t;
|
|
91
|
+
}
|
|
92
|
+
await initDirs();
|
|
93
|
+
|
|
94
|
+
const urlInfo = new URL(url);
|
|
95
|
+
const fileExt = path.parse(urlInfo.pathname).ext;
|
|
96
|
+
urlInfo.pathname = urlInfo.pathname.replace(
|
|
97
|
+
new RegExp(fileExt + '$'),
|
|
98
|
+
'.txt'
|
|
99
|
+
);
|
|
100
|
+
const hashUrl = urlInfo.href;
|
|
101
|
+
|
|
102
|
+
/*
|
|
103
|
+
获取 hash。使用缓存时,一定要有 hash 值。
|
|
104
|
+
现在的逻辑是不使用缓存时,不校验 hash 值。
|
|
105
|
+
TODO: 理论上应该将《是否使用缓存》和《是否校验》分开,以后再说吧。
|
|
106
|
+
*/
|
|
107
|
+
let hash = '';
|
|
108
|
+
let hashAlg = '';
|
|
109
|
+
if (!noCache) {
|
|
110
|
+
const hashInfo = await getHashText(hashUrl, {
|
|
111
|
+
...axiosReqCfg,
|
|
112
|
+
onDownloadProgress: undefined
|
|
113
|
+
});
|
|
114
|
+
if (hashInfo.error) {
|
|
115
|
+
logger?.(`[fetch] [${name}] Error: ${hashInfo.error}`);
|
|
116
|
+
return returnWrapper({
|
|
117
|
+
hasError: true,
|
|
118
|
+
name,
|
|
119
|
+
url,
|
|
120
|
+
error: hashInfo.error
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
({ hash, hashAlg } = hashInfo);
|
|
124
|
+
}
|
|
125
|
+
// 不使用缓存时 || 无hash文件时,直接下载到 outputPath,且不进行校验
|
|
126
|
+
const download2output = noCache || !hash;
|
|
127
|
+
if (download2output && !outputDir) {
|
|
128
|
+
logger?.(`[fetch] [${name}] Error: outputDir is empty`);
|
|
129
|
+
return returnWrapper({
|
|
130
|
+
hasError: true,
|
|
131
|
+
name,
|
|
132
|
+
url,
|
|
133
|
+
error: new Error('outputDir is empty')
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const outputFilePath = outputDir
|
|
137
|
+
? path.join(outputDir, name + fileExt)
|
|
138
|
+
: '';
|
|
139
|
+
|
|
140
|
+
const cacheFilePath = path.join(cacheDir, hash + fileExt);
|
|
141
|
+
const tmpFilePath =
|
|
142
|
+
(download2output
|
|
143
|
+
? outputFilePath
|
|
144
|
+
: path.join(cacheDir, hash + fileExt)) + '.tmp';
|
|
145
|
+
|
|
146
|
+
if (hash && fs.existsSync(cacheFilePath)) {
|
|
147
|
+
logger?.(`[fetch] [${name}] Hit cache`);
|
|
148
|
+
if (outputFilePath) {
|
|
149
|
+
await fs.promises.cp(cacheFilePath, outputFilePath, {
|
|
150
|
+
force: true
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return returnWrapper({
|
|
154
|
+
hasError: false,
|
|
155
|
+
name,
|
|
156
|
+
url,
|
|
157
|
+
filePath: outputFilePath || cacheFilePath,
|
|
158
|
+
cacheFilePath,
|
|
159
|
+
hash,
|
|
160
|
+
hitCache: true
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const error = await downloadFile(
|
|
165
|
+
url,
|
|
166
|
+
tmpFilePath,
|
|
167
|
+
hash,
|
|
168
|
+
hashAlg,
|
|
169
|
+
axiosReqCfg
|
|
170
|
+
);
|
|
171
|
+
if (error) {
|
|
172
|
+
const err = error.error as Error;
|
|
173
|
+
switch (error.errType) {
|
|
174
|
+
case 'axios':
|
|
175
|
+
logger?.(`[fetch] [${name}] Error: ${err.message}`);
|
|
176
|
+
break;
|
|
177
|
+
case 'file':
|
|
178
|
+
logger?.(`[fetch] [${name}] Write file error: ${err.message}`);
|
|
179
|
+
break;
|
|
180
|
+
case 'hash':
|
|
181
|
+
logger?.(`[fetch] [${name}] Hash not match`);
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
return returnWrapper({ hasError: true, name, url, error: err });
|
|
185
|
+
}
|
|
186
|
+
if (download2output) {
|
|
187
|
+
await fs.promises.rename(tmpFilePath, outputFilePath);
|
|
188
|
+
logger?.(
|
|
189
|
+
`[fetch] [${name}] Downloaded without cache: ${outputFilePath}`
|
|
190
|
+
);
|
|
191
|
+
return returnWrapper({
|
|
192
|
+
hasError: false,
|
|
193
|
+
name,
|
|
194
|
+
url,
|
|
195
|
+
filePath: outputFilePath,
|
|
196
|
+
cacheFilePath: outputFilePath,
|
|
197
|
+
hash,
|
|
198
|
+
hitCache: true
|
|
199
|
+
});
|
|
200
|
+
} else {
|
|
201
|
+
await fs.promises.rename(tmpFilePath, cacheFilePath);
|
|
202
|
+
if (outputFilePath) {
|
|
203
|
+
await fs.promises.cp(cacheFilePath, outputFilePath, {
|
|
204
|
+
force: true
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
const filePath = outputFilePath || cacheFilePath;
|
|
208
|
+
logger?.(`[fetch] [${name}] Downloaded: ${filePath}`);
|
|
209
|
+
return returnWrapper({
|
|
210
|
+
hasError: false,
|
|
211
|
+
name,
|
|
212
|
+
url,
|
|
213
|
+
filePath,
|
|
214
|
+
cacheFilePath,
|
|
215
|
+
hash,
|
|
216
|
+
hitCache: false
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { styleText } from 'node:util';
|
|
2
|
+
import { MultiBar } from 'cli-progress';
|
|
3
|
+
|
|
4
|
+
import { fetchPkg } from './fetch-pkg';
|
|
5
|
+
import type {
|
|
6
|
+
FetchPkgOptions,
|
|
7
|
+
FetchPkgWithBarLogger,
|
|
8
|
+
FetchPkgWithBarOptions,
|
|
9
|
+
FetchPkgsOptions,
|
|
10
|
+
FetchPkgsWithBarOptions,
|
|
11
|
+
FetchResult,
|
|
12
|
+
FetchResults
|
|
13
|
+
} from './types';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 获取多个文件,并缓存到本地。如果有缓存则使用缓存。
|
|
17
|
+
*/
|
|
18
|
+
export async function fetchPkgs<Level extends number>({
|
|
19
|
+
packs,
|
|
20
|
+
returnLevel = 2 as Level,
|
|
21
|
+
onEachFinally = () => {},
|
|
22
|
+
...options
|
|
23
|
+
}: FetchPkgsOptions & { returnLevel?: Level }): Promise<FetchResults<Level>> {
|
|
24
|
+
if (new Set(packs.map((pack) => pack.name)).size !== packs.length) {
|
|
25
|
+
return (
|
|
26
|
+
returnLevel === 0
|
|
27
|
+
? false
|
|
28
|
+
: returnLevel === 1
|
|
29
|
+
? packs.map(() => false)
|
|
30
|
+
: packs.map(() => ({
|
|
31
|
+
hasError: true,
|
|
32
|
+
name: '',
|
|
33
|
+
url: '',
|
|
34
|
+
error: new Error('Duplicate name')
|
|
35
|
+
}))
|
|
36
|
+
) as FetchResults<Level>;
|
|
37
|
+
}
|
|
38
|
+
const axiosReqCfg = options.axiosReqCfg || {};
|
|
39
|
+
const results = await Promise.all(
|
|
40
|
+
packs.map((pack) =>
|
|
41
|
+
fetchPkg({
|
|
42
|
+
...options,
|
|
43
|
+
returnLevel,
|
|
44
|
+
...pack,
|
|
45
|
+
axiosReqCfg: {
|
|
46
|
+
...axiosReqCfg,
|
|
47
|
+
...(pack.axiosReqCfg || {})
|
|
48
|
+
},
|
|
49
|
+
onFinally: (result) => onEachFinally(result)
|
|
50
|
+
})
|
|
51
|
+
)
|
|
52
|
+
);
|
|
53
|
+
return (
|
|
54
|
+
returnLevel === 0
|
|
55
|
+
? results.every((r) => !r)
|
|
56
|
+
: returnLevel === 1
|
|
57
|
+
? (results as FetchResult<1>[]).map((r) => !r.hasError)
|
|
58
|
+
: results
|
|
59
|
+
) as FetchResults<Level>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 获取多个文件,并缓存到本地。如果有缓存则使用缓存。带有进度条
|
|
64
|
+
*/
|
|
65
|
+
export async function fetchPkgsWithBar<Level extends number>({
|
|
66
|
+
packs,
|
|
67
|
+
axiosReqCfg = {},
|
|
68
|
+
multiBarCfg = {},
|
|
69
|
+
logger = (barLogger, str) => barLogger(str),
|
|
70
|
+
onEachFinally = () => {},
|
|
71
|
+
...options
|
|
72
|
+
}: FetchPkgsWithBarOptions & { returnLevel?: Level }): Promise<
|
|
73
|
+
FetchResults<Level>
|
|
74
|
+
> {
|
|
75
|
+
const multiBar = new MultiBar({
|
|
76
|
+
stopOnComplete: true,
|
|
77
|
+
format: ' [{bar}] {percentage}% | {status} | {name}',
|
|
78
|
+
forceRedraw: true,
|
|
79
|
+
barCompleteChar: '#',
|
|
80
|
+
barIncompleteChar: '_',
|
|
81
|
+
autopadding: true,
|
|
82
|
+
...multiBarCfg
|
|
83
|
+
});
|
|
84
|
+
const multiBarLogger = (str = '') => {
|
|
85
|
+
// multiBar.log 最后一个字符需要是换行符
|
|
86
|
+
multiBar.log(str.trimEnd() + '\n');
|
|
87
|
+
multiBar.update(); // force redraw
|
|
88
|
+
};
|
|
89
|
+
const bars = packs.reduce((obj, { name }) => {
|
|
90
|
+
obj[name] = multiBar.create(1, 0, { status: 'WAT', name });
|
|
91
|
+
return obj;
|
|
92
|
+
}, {});
|
|
93
|
+
// 不知为何,有的时候不会更新,强制每秒重绘一次
|
|
94
|
+
const timer = setInterval(() => multiBar.update(), 1000);
|
|
95
|
+
const fetchPkgsPacks = packs.map((pack) => {
|
|
96
|
+
const ans = pack as FetchPkgWithBarOptions | FetchPkgOptions;
|
|
97
|
+
ans.axiosReqCfg = ans.axiosReqCfg || {};
|
|
98
|
+
const onDownloadProgress =
|
|
99
|
+
ans.axiosReqCfg.onDownloadProgress ||
|
|
100
|
+
axiosReqCfg.onDownloadProgress;
|
|
101
|
+
ans.axiosReqCfg.onDownloadProgress = (progressEvent) => {
|
|
102
|
+
bars[ans.name].setTotal(
|
|
103
|
+
progressEvent?.total ?? progressEvent.loaded + 1
|
|
104
|
+
);
|
|
105
|
+
bars[ans.name].update(progressEvent.loaded, {
|
|
106
|
+
status: styleText('yellow', 'DLD'),
|
|
107
|
+
name: ans.name
|
|
108
|
+
});
|
|
109
|
+
onDownloadProgress?.(progressEvent);
|
|
110
|
+
};
|
|
111
|
+
if (ans.logger) {
|
|
112
|
+
const orgLogger = ans.logger as FetchPkgWithBarLogger;
|
|
113
|
+
ans.logger = (str = '') => orgLogger(multiBarLogger, str);
|
|
114
|
+
}
|
|
115
|
+
return ans as FetchPkgOptions;
|
|
116
|
+
});
|
|
117
|
+
const results = await fetchPkgs({
|
|
118
|
+
packs: fetchPkgsPacks,
|
|
119
|
+
...options,
|
|
120
|
+
axiosReqCfg,
|
|
121
|
+
logger: logger && ((str) => logger(multiBarLogger, str)),
|
|
122
|
+
onEachFinally: (result) => {
|
|
123
|
+
bars[result.name].update(Number.POSITIVE_INFINITY, {
|
|
124
|
+
status: result.hasError
|
|
125
|
+
? styleText('red', 'ERR')
|
|
126
|
+
: styleText('green', result.hitCache ? 'HIT' : 'SUC'),
|
|
127
|
+
name: result.name
|
|
128
|
+
});
|
|
129
|
+
multiBar.update(); // force redraw
|
|
130
|
+
onEachFinally(result);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
clearInterval(timer);
|
|
134
|
+
multiBar.update(); // force redraw
|
|
135
|
+
multiBar.stop();
|
|
136
|
+
return results;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export { fetchPkgsWithBar as fetchPkgsWithProgress };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { test } from 'vitest';
|
|
4
|
+
import { fetchPkgsWithProgress } from '.';
|
|
5
|
+
|
|
6
|
+
test('base', async () => {
|
|
7
|
+
const urls = ['ssr-html/versions/latest.tgz', 'ssr-html/versions/1.0.tgz'];
|
|
8
|
+
const outputDir = path.join(
|
|
9
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
10
|
+
'output'
|
|
11
|
+
);
|
|
12
|
+
await fetchPkgsWithProgress({
|
|
13
|
+
outputDir,
|
|
14
|
+
axiosReqCfg: {
|
|
15
|
+
baseURL: 'https://www.esmnext.com',
|
|
16
|
+
timeout: 4500
|
|
17
|
+
},
|
|
18
|
+
packs: urls.map((url) => ({ url, name: url.split('/')[0] }))
|
|
19
|
+
}).then((...args) => {
|
|
20
|
+
console.log(...args);
|
|
21
|
+
});
|
|
22
|
+
}, 5000);
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/*
|
|
2
|
+
|
|
3
|
+
专门为 esmx 框架提供远程的下载库,具有缓存功能。
|
|
4
|
+
|
|
5
|
+
例子:
|
|
6
|
+
|
|
7
|
+
const urls = [
|
|
8
|
+
'ssr-html/versions/latest.tgz',
|
|
9
|
+
'ssr-html/versions/1.0.tgz',
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const outputDir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'output');
|
|
13
|
+
|
|
14
|
+
fetchPkgsWithProgress({
|
|
15
|
+
outputDir,
|
|
16
|
+
axiosReqCfg: {
|
|
17
|
+
baseURL: 'https://js-esm.github.io/esmx/',
|
|
18
|
+
timeout: 10000,
|
|
19
|
+
},
|
|
20
|
+
packs: urls.map(url => ({ url, name: url.split('/')[0] })),
|
|
21
|
+
logger: (barLogger, str) => barLogger(str),
|
|
22
|
+
returnLevel: 0,
|
|
23
|
+
}).then((...args) => {
|
|
24
|
+
console.log(...args);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export * from './types';
|
|
30
|
+
export * from './fetch-pkg';
|
|
31
|
+
export * from './fetch-pkgs';
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { AxiosRequestConfig } from 'axios';
|
|
2
|
+
import type { Options as MultiBarOptions } from 'cli-progress';
|
|
3
|
+
|
|
4
|
+
export interface FetchBaseOptions {
|
|
5
|
+
/**
|
|
6
|
+
* 是否不使用缓存 默认为 `false` (即默认使用缓存)
|
|
7
|
+
* 现在的逻辑是不使用缓存时,不校验 hash 值。
|
|
8
|
+
*/
|
|
9
|
+
noCache?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* 缓存文件夹路径 默认为 `{system cache dir}/npm-esmx/packages`。
|
|
12
|
+
* 当 `noCache` 为 `true` 时,该参数无效。
|
|
13
|
+
* 当 `noCache` 为 `false` 时,省略该参数会导致报错 `cacheDir is empty`。
|
|
14
|
+
*/
|
|
15
|
+
cacheDir?: string;
|
|
16
|
+
/**
|
|
17
|
+
* 输出的文件夹路径。
|
|
18
|
+
* 省略时不输出文件,如果使用缓存会下载到缓存。
|
|
19
|
+
* 不使用缓存时或无hash文件时,省略则会导致报错 `outputDir is empty`。
|
|
20
|
+
* 目前,如果不使用缓存,会直接下载到输出,且不进行 hash 校验。
|
|
21
|
+
*/
|
|
22
|
+
outputDir?: string;
|
|
23
|
+
/**
|
|
24
|
+
* 返回结果的级别 默认为 `2` (即默认返回详细结果)
|
|
25
|
+
* - `0`: 如果没有发生任何错误,返回 `true`,否则返回 `false`;
|
|
26
|
+
* - `1`: 根据入参顺序,返回 `boolean[]`,表示是否成功;
|
|
27
|
+
* - `2`: 返回详细结果。
|
|
28
|
+
*/
|
|
29
|
+
returnLevel?: number;
|
|
30
|
+
/**
|
|
31
|
+
* 日志输出函数 默认为 `console.log`
|
|
32
|
+
* @param str string 输出的字符串
|
|
33
|
+
* @returns void
|
|
34
|
+
*/
|
|
35
|
+
logger?: (str?: string) => void;
|
|
36
|
+
/**
|
|
37
|
+
* axios 请求配置
|
|
38
|
+
*/
|
|
39
|
+
axiosReqCfg?: AxiosRequestConfig;
|
|
40
|
+
/**
|
|
41
|
+
* 包下载结束时的回调函数
|
|
42
|
+
* @param pack 包信息
|
|
43
|
+
* @param result 结果
|
|
44
|
+
*/
|
|
45
|
+
onFinally?: (result: FetchResultSuccess | FetchResultError) => void;
|
|
46
|
+
}
|
|
47
|
+
export interface FetchPkgOptions extends FetchBaseOptions {
|
|
48
|
+
/**
|
|
49
|
+
* 包名。用于输出和保存时的文件名。
|
|
50
|
+
*/
|
|
51
|
+
name: string;
|
|
52
|
+
/**
|
|
53
|
+
* 下载地址。优先级低于 `axiosReqCfg?.url`
|
|
54
|
+
*/
|
|
55
|
+
url?: string;
|
|
56
|
+
}
|
|
57
|
+
export interface FetchPkgsOptions extends FetchBaseOptions {
|
|
58
|
+
/**
|
|
59
|
+
* 请确保每个包的 `name` 不同,否则会报错 `Duplicate name`。文件将会被下载到 `{outputDir}/{name}.ext`。
|
|
60
|
+
*/
|
|
61
|
+
packs: FetchPkgOptions[];
|
|
62
|
+
/**
|
|
63
|
+
* 每个包下载结束时的回调函数
|
|
64
|
+
* @param pack 包信息
|
|
65
|
+
* @param result 结果
|
|
66
|
+
*/
|
|
67
|
+
onEachFinally?: (result: FetchResultSuccess | FetchResultError) => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type FetchPkgWithBarLogger = (
|
|
71
|
+
multiBarLogger: (data?: string) => void,
|
|
72
|
+
str?: string
|
|
73
|
+
) => void;
|
|
74
|
+
export interface FetchPkgWithBarOptions
|
|
75
|
+
extends Omit<FetchPkgOptions, 'logger'> {
|
|
76
|
+
/**
|
|
77
|
+
* 进度条日志输出函数,默认为空(不输出东西)。可以使用 `(barLogger, str) => barLogger(str)` 来输出日志。
|
|
78
|
+
* @param multiBarLogger `(data: string) => void` 进度条自带的日志输出函数,请通过该函数进行日志输出
|
|
79
|
+
* @param str string 输出的字符串
|
|
80
|
+
* @returns void
|
|
81
|
+
*/
|
|
82
|
+
logger?: FetchPkgWithBarLogger;
|
|
83
|
+
}
|
|
84
|
+
export interface FetchPkgsWithBarOptions
|
|
85
|
+
extends Omit<FetchBaseOptions, 'logger'> {
|
|
86
|
+
/**
|
|
87
|
+
* 进度条日志输出函数,默认为 `(barLogger, str) => barLogger(str)`
|
|
88
|
+
* @param multiBarLogger `(data: string) => void` 进度条自带的日志输出函数,请通过该函数进行日志输出
|
|
89
|
+
* @param str string 输出的字符串
|
|
90
|
+
* @returns void
|
|
91
|
+
*/
|
|
92
|
+
logger?: FetchPkgWithBarLogger;
|
|
93
|
+
/**
|
|
94
|
+
* 进度条配置
|
|
95
|
+
*/
|
|
96
|
+
multiBarCfg?: MultiBarOptions;
|
|
97
|
+
/**
|
|
98
|
+
* 每个包下载结束时的回调函数
|
|
99
|
+
* @param pack 包信息
|
|
100
|
+
* @param result 结果
|
|
101
|
+
*/
|
|
102
|
+
onEachFinally?: (result: FetchResultSuccess | FetchResultError) => void;
|
|
103
|
+
packs: FetchPkgWithBarOptions[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface FetchResultBase {
|
|
107
|
+
/**
|
|
108
|
+
* 资源是从哪个 URL 获取的
|
|
109
|
+
*/
|
|
110
|
+
url: string;
|
|
111
|
+
/**
|
|
112
|
+
* 包名。用于输出和保存时的文件名。
|
|
113
|
+
*/
|
|
114
|
+
name: string;
|
|
115
|
+
}
|
|
116
|
+
export interface FetchResultSuccess extends FetchResultBase {
|
|
117
|
+
hasError: false;
|
|
118
|
+
/**
|
|
119
|
+
* 输出的文件路径
|
|
120
|
+
* 如果无 outputPath,且使用缓存,则为缓存文件路径。
|
|
121
|
+
*/
|
|
122
|
+
filePath: string;
|
|
123
|
+
/**
|
|
124
|
+
* 缓存文件路径 默认为 `{system cache dir}/npm-esmx/packages/hash.ext`
|
|
125
|
+
* 如果不使用缓存,则为空字符串。
|
|
126
|
+
*/
|
|
127
|
+
cacheFilePath: string;
|
|
128
|
+
/**
|
|
129
|
+
* 文件 hash 值
|
|
130
|
+
* 如果不使用缓存/无校验文件时,则为空字符串。
|
|
131
|
+
*/
|
|
132
|
+
hash: string;
|
|
133
|
+
/**
|
|
134
|
+
* 是否命中缓存
|
|
135
|
+
* 如果不使用缓存,则必定为 `false`。
|
|
136
|
+
*/
|
|
137
|
+
hitCache: boolean;
|
|
138
|
+
}
|
|
139
|
+
export interface FetchResultError extends FetchResultBase {
|
|
140
|
+
hasError: true;
|
|
141
|
+
/**
|
|
142
|
+
* 错误信息
|
|
143
|
+
*/
|
|
144
|
+
error: Error;
|
|
145
|
+
}
|
|
146
|
+
export type FetchResult<Level extends number> = Level extends 0
|
|
147
|
+
? boolean
|
|
148
|
+
: FetchResultSuccess | FetchResultError;
|
|
149
|
+
export type FetchResults<Level extends number> = Level extends 0
|
|
150
|
+
? FetchResult<0>
|
|
151
|
+
: Level extends 1
|
|
152
|
+
? boolean[]
|
|
153
|
+
: FetchResult<2>[];
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import axios, { type AxiosResponse, type AxiosRequestConfig } from 'axios';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 获取 hash 文件的值
|
|
7
|
+
*/
|
|
8
|
+
export async function getHashText(
|
|
9
|
+
hashUrl: string,
|
|
10
|
+
axiosOptions: AxiosRequestConfig = {}
|
|
11
|
+
): Promise<{ hash: string; hashAlg: string; error?: Error }> {
|
|
12
|
+
try {
|
|
13
|
+
let hash = (
|
|
14
|
+
await axios.get(hashUrl, {
|
|
15
|
+
...axiosOptions,
|
|
16
|
+
responseType: 'text'
|
|
17
|
+
})
|
|
18
|
+
).data;
|
|
19
|
+
let hashAlg = 'sha256';
|
|
20
|
+
if (hash.includes('-')) {
|
|
21
|
+
const t = hash.split('-');
|
|
22
|
+
hash = t.pop() as string;
|
|
23
|
+
hashAlg = t.join('-');
|
|
24
|
+
}
|
|
25
|
+
if (!crypto.getHashes().includes(hashAlg)) {
|
|
26
|
+
return {
|
|
27
|
+
hash: '',
|
|
28
|
+
hashAlg: '',
|
|
29
|
+
error: new Error(`Unsupported hash algorithm ${hashAlg}`)
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return { hash, hashAlg };
|
|
33
|
+
} catch (error: any) {
|
|
34
|
+
return { hash: '', hashAlg: '', error };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 获取文件。如果有hash则校验hash
|
|
40
|
+
*/
|
|
41
|
+
export async function downloadFile(
|
|
42
|
+
url: string,
|
|
43
|
+
filePath: string,
|
|
44
|
+
hash: string,
|
|
45
|
+
hashAlg: string,
|
|
46
|
+
axiosOptions: AxiosRequestConfig = {}
|
|
47
|
+
): Promise<null | { errType?: 'axios' | 'file' | 'hash'; error?: Error }> {
|
|
48
|
+
// TODO: 断点续传
|
|
49
|
+
let result: AxiosResponse;
|
|
50
|
+
try {
|
|
51
|
+
result = await axios.get(url, {
|
|
52
|
+
...axiosOptions,
|
|
53
|
+
responseType: 'stream'
|
|
54
|
+
});
|
|
55
|
+
} catch (error: any) {
|
|
56
|
+
return { errType: 'axios', error };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const hashStream = hash ? crypto.createHash(hashAlg) : null;
|
|
60
|
+
const fileStream = fs.createWriteStream(filePath);
|
|
61
|
+
|
|
62
|
+
const streamPromise = new Promise((resolve, reject) => {
|
|
63
|
+
fileStream.on('finish', resolve);
|
|
64
|
+
fileStream.on('error', reject);
|
|
65
|
+
});
|
|
66
|
+
result.data.on('data', (chunk: crypto.BinaryLike) => {
|
|
67
|
+
hashStream?.update(chunk);
|
|
68
|
+
fileStream.write(chunk);
|
|
69
|
+
});
|
|
70
|
+
result.data.on('end', () => fileStream.end());
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
await streamPromise;
|
|
74
|
+
} catch (error: any) {
|
|
75
|
+
return { errType: 'file', error };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (hash && hashStream?.digest('hex') !== hash) {
|
|
79
|
+
return { errType: 'hash', error: new Error('Hash not match') };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return null;
|
|
83
|
+
}
|