@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.
@@ -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);
@@ -0,0 +1,3 @@
1
+ export * from './types';
2
+ export * from './fetch-pkg';
3
+ export * from './fetch-pkgs';
package/dist/index.mjs ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./types.mjs";
2
+ export * from "./fetch-pkg.mjs";
3
+ export * from "./fetch-pkgs.mjs";
@@ -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
@@ -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
+ }
@@ -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
+ }