@jiseer/qiniu 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jiseer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/index.cjs ADDED
@@ -0,0 +1,429 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ JiseerQiniuError: () => JiseerQiniuError,
34
+ QiniuClient: () => QiniuClient
35
+ });
36
+ module.exports = __toCommonJS(index_exports);
37
+
38
+ // src/client.ts
39
+ var import_promises2 = require("fs/promises");
40
+ var import_node_path2 = require("path");
41
+
42
+ // src/errors.ts
43
+ var JiseerQiniuError = class extends Error {
44
+ code;
45
+ status;
46
+ path;
47
+ failures;
48
+ constructor(message, options = {}) {
49
+ super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
50
+ this.name = "JiseerQiniuError";
51
+ this.code = options.code ?? "UNKNOWN";
52
+ this.status = options.status;
53
+ this.path = options.path;
54
+ this.failures = options.failures;
55
+ }
56
+ };
57
+
58
+ // src/http.ts
59
+ var MAX_RETRY_WAIT_SECONDS = 5;
60
+ function joinUrl(endpoint, path) {
61
+ return `${endpoint.replace(/\/+$/, "")}${path}`;
62
+ }
63
+ async function postJson(url, body, headers, context) {
64
+ let retried = false;
65
+ for (; ; ) {
66
+ const response = await send(url, body, headers, context);
67
+ if (response.status === 429 && !retried) {
68
+ const wait = retryAfterSeconds(response);
69
+ if (wait <= MAX_RETRY_WAIT_SECONDS) {
70
+ retried = true;
71
+ await sleep(wait * 1e3);
72
+ continue;
73
+ }
74
+ throw new JiseerQiniuError(`\u670D\u52A1\u7AEF\u9650\u6D41\uFF0C\u9700\u8981\u7B49\u5F85 ${wait} \u79D2`, {
75
+ code: 42901,
76
+ status: 429,
77
+ path: url
78
+ });
79
+ }
80
+ const text = await response.text();
81
+ return parseEnvelope(text, url, response.status);
82
+ }
83
+ }
84
+ async function send(url, body, headers, context) {
85
+ try {
86
+ return await context.fetch(url, {
87
+ method: "POST",
88
+ headers: { "content-type": "application/json", ...headers },
89
+ body: JSON.stringify(body ?? {}),
90
+ signal: withTimeout(context.timeoutMs, context.signal)
91
+ });
92
+ } catch (error) {
93
+ throw new JiseerQiniuError(`\u8BF7\u6C42 ${url} \u5931\u8D25\uFF1A${messageOf(error)}`, {
94
+ code: "NETWORK",
95
+ path: url,
96
+ cause: error
97
+ });
98
+ }
99
+ }
100
+ function parseEnvelope(text, url, status) {
101
+ let payload;
102
+ try {
103
+ payload = JSON.parse(text);
104
+ } catch {
105
+ throw new JiseerQiniuError(`${url} \u8FD4\u56DE\u975E JSON\uFF08HTTP ${status}\uFF09\uFF1A${snippet(text)}`, {
106
+ code: "BAD_RESPONSE",
107
+ status,
108
+ path: url
109
+ });
110
+ }
111
+ if (typeof payload.code !== "number") {
112
+ throw new JiseerQiniuError(`${url} \u8FD4\u56DE\u4E86\u975E\u9884\u671F\u7ED3\u6784\uFF08HTTP ${status}\uFF09\uFF1A${snippet(text)}`, {
113
+ code: "BAD_RESPONSE",
114
+ status,
115
+ path: url
116
+ });
117
+ }
118
+ if (payload.code !== 0) {
119
+ throw new JiseerQiniuError(`${url} \u8C03\u7528\u5931\u8D25 [${payload.code}] ${payload.message}`, {
120
+ code: payload.code,
121
+ status,
122
+ path: url
123
+ });
124
+ }
125
+ if (payload.data === void 0 || payload.data === null) {
126
+ throw new JiseerQiniuError(`${url} \u54CD\u5E94\u7F3A\u5C11 data`, {
127
+ code: "MISSING_DATA",
128
+ status,
129
+ path: url
130
+ });
131
+ }
132
+ return payload.data;
133
+ }
134
+ function snippet(text) {
135
+ const trimmed = text.trim();
136
+ if (trimmed.length === 0) return "(\u7A7A\u54CD\u5E94)";
137
+ return trimmed.length <= 512 ? trimmed : `${trimmed.slice(0, 512)}\u2026`;
138
+ }
139
+ function messageOf(error) {
140
+ return error instanceof Error ? error.message : String(error);
141
+ }
142
+ function withTimeout(timeoutMs, signal) {
143
+ const timeout = AbortSignal.timeout(timeoutMs);
144
+ if (!signal) return timeout;
145
+ if (typeof AbortSignal.any === "function") return AbortSignal.any([signal, timeout]);
146
+ const controller = new AbortController();
147
+ const abort = () => {
148
+ signal.removeEventListener("abort", abort);
149
+ timeout.removeEventListener("abort", abort);
150
+ controller.abort(signal.reason ?? timeout.reason);
151
+ };
152
+ if (signal.aborted || timeout.aborted) {
153
+ abort();
154
+ return controller.signal;
155
+ }
156
+ signal.addEventListener("abort", abort, { once: true });
157
+ timeout.addEventListener("abort", abort, { once: true });
158
+ return controller.signal;
159
+ }
160
+ function retryAfterSeconds(response) {
161
+ const raw = response.headers.get("retry-after");
162
+ const parsed = raw === null ? Number.NaN : Number.parseInt(raw, 10);
163
+ return Number.isFinite(parsed) ? parsed : 1;
164
+ }
165
+ function sleep(ms) {
166
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
167
+ }
168
+
169
+ // src/scan.ts
170
+ var import_promises = require("fs/promises");
171
+ var import_node_path = require("path");
172
+ var import_fast_glob = __toESM(require("fast-glob"), 1);
173
+ var DEFAULT_IGNORE = [".git/**", "node_modules/**", "target/**", "__pycache__/**"];
174
+ async function scanPaths(inputs, options = {}) {
175
+ const files = [];
176
+ for (const input of inputs) {
177
+ const absolute = (0, import_node_path.resolve)(input);
178
+ const info = await (0, import_promises.stat)(absolute).catch(() => null);
179
+ if (!info) {
180
+ throw new JiseerQiniuError(`\u65E0\u6CD5\u8BFB\u53D6 ${input}`, { code: "NOT_FOUND", path: input });
181
+ }
182
+ if (info.isFile()) {
183
+ files.push({ absolute, relative: (0, import_node_path.basename)(absolute), size: info.size });
184
+ continue;
185
+ }
186
+ if (!info.isDirectory()) continue;
187
+ const entries = await (0, import_fast_glob.default)("**/*", {
188
+ cwd: absolute,
189
+ dot: false,
190
+ onlyFiles: true,
191
+ followSymbolicLinks: false,
192
+ ignore: [...DEFAULT_IGNORE, ...options.exclude ?? []]
193
+ });
194
+ for (const entry of entries.sort()) {
195
+ const child = (0, import_node_path.resolve)(absolute, entry);
196
+ const childInfo = await (0, import_promises.stat)(child).catch(() => null);
197
+ if (!childInfo || !childInfo.isFile()) continue;
198
+ files.push({
199
+ absolute: child,
200
+ relative: entry.split("\\").join("/"),
201
+ size: childInfo.size
202
+ });
203
+ }
204
+ }
205
+ files.sort((a, b) => a.relative.localeCompare(b.relative));
206
+ return files;
207
+ }
208
+
209
+ // src/client.ts
210
+ var DEFAULT_CONCURRENCY = 4;
211
+ var DEFAULT_TIMEOUT_MS = 5 * 6e4;
212
+ var UPLOAD_KEY_HEADER = "x-qiniu-upload-key";
213
+ var STATUS_HINTS = {
214
+ 401: "\u4E0A\u4F20\u5BC6\u94A5\u7F3A\u5931\u6216\u65E0\u6548",
215
+ 403: "\u8DEF\u5F84\u4E0D\u5728\u8BE5\u5BA2\u6237\u7AEF\u7684\u53EF\u5199\u8303\u56F4\u5185",
216
+ 406: "\u5185\u5BB9\u7C7B\u578B\u88AB\u4E0A\u4F20\u7B56\u7565\u7684 mimeLimit \u62D2\u7EDD",
217
+ 413: "\u6587\u4EF6\u8D85\u8FC7\u4E0A\u4F20\u7B56\u7565\u7684 fsizeLimit",
218
+ 579: "\u4E03\u725B\u56DE\u8C03\u4E1A\u52A1\u670D\u52A1\u5668\u5931\u8D25",
219
+ 614: "\u4E0A\u4F20\u7A7A\u95F4\u4E0D\u5B58\u5728",
220
+ 631: "\u4E0A\u4F20\u7A7A\u95F4\u5DF2\u88AB\u7981\u7528",
221
+ 701: "\u5BF9\u8C61\u5DF2\u5B58\u5728\uFF08\u7B56\u7565\u4E3A insertOnly\uFF09\uFF0C\u8BBE\u7F6E overwrite: true \u53EF\u8986\u76D6"
222
+ };
223
+ var QiniuClient = class {
224
+ options;
225
+ constructor(options) {
226
+ if (typeof globalThis === "object" && "window" in globalThis && "document" in globalThis) {
227
+ throw new JiseerQiniuError("@jiseer/qiniu \u4EC5\u652F\u6301 Node \u73AF\u5883\uFF0C\u8BF7\u52FF\u5728\u6D4F\u89C8\u5668\u4E2D\u4F7F\u7528", {
228
+ code: "BROWSER_NOT_SUPPORTED"
229
+ });
230
+ }
231
+ if (!options || typeof options.endpoint !== "string" || options.endpoint.length === 0) {
232
+ throw new JiseerQiniuError("\u7F3A\u5C11 endpoint\uFF08\u51ED\u8BC1\u670D\u52A1\u5730\u5740\uFF09", {
233
+ code: "INVALID_OPTIONS"
234
+ });
235
+ }
236
+ if (typeof options.uploadKey !== "string" || options.uploadKey.length === 0) {
237
+ throw new JiseerQiniuError("\u7F3A\u5C11 uploadKey\uFF08\u4E0A\u4F20\u5BC6\u94A5\uFF09", { code: "INVALID_OPTIONS" });
238
+ }
239
+ const fetchImpl = options.fetch ?? globalThis.fetch;
240
+ if (typeof fetchImpl !== "function") {
241
+ throw new JiseerQiniuError("\u5F53\u524D\u73AF\u5883\u6CA1\u6709 fetch\uFF0C\u9700\u8981 Node 18+", { code: "NO_FETCH" });
242
+ }
243
+ this.options = {
244
+ endpoint: options.endpoint.replace(/\/+$/, ""),
245
+ uploadKey: options.uploadKey,
246
+ fetch: fetchImpl,
247
+ concurrency: options.concurrency ?? DEFAULT_CONCURRENCY,
248
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
249
+ publicDomain: options.publicDomain
250
+ };
251
+ }
252
+ /** 只取凭证,不实际上传 */
253
+ token(options = {}) {
254
+ return this.requestToken(options);
255
+ }
256
+ /** 上传单个文件 */
257
+ async uploadFile(file, options = {}) {
258
+ const result = await this.uploadPaths([file], options);
259
+ const uploaded = result.files[0];
260
+ if (uploaded) return uploaded;
261
+ const failure = result.failures[0];
262
+ throw failure ? failure.error : new JiseerQiniuError(`\u4E0A\u4F20\u5931\u8D25\uFF1A${file}`, { code: "UPLOAD_FAILED", path: file });
263
+ }
264
+ /** 上传整个目录 */
265
+ uploadDir(dir, options = {}) {
266
+ return this.uploadPaths([dir], options);
267
+ }
268
+ /** 上传任意文件/目录组成的路径列表 */
269
+ async uploadPaths(paths, options = {}) {
270
+ const scanned = await scanPaths(paths, { exclude: options.exclude });
271
+ if (scanned.length === 0) {
272
+ throw new JiseerQiniuError("\u6CA1\u6709\u627E\u5230\u53EF\u4E0A\u4F20\u7684\u6587\u4EF6", { code: "NO_FILES" });
273
+ }
274
+ if (options.dryRun) {
275
+ return {
276
+ files: [],
277
+ failures: [],
278
+ planned: scanned.map((file) => ({ file: file.relative, size: file.size }))
279
+ };
280
+ }
281
+ const concurrency = Math.max(1, options.concurrency ?? this.options.concurrency);
282
+ const publicDomain = options.publicDomain ?? this.options.publicDomain;
283
+ const total = scanned.length;
284
+ const files = [];
285
+ const failures = [];
286
+ let done = 0;
287
+ let cursor = 0;
288
+ let stopped = false;
289
+ const worker = async () => {
290
+ for (; ; ) {
291
+ if (stopped) return;
292
+ if (options.signal?.aborted) return;
293
+ const file = scanned[cursor];
294
+ cursor += 1;
295
+ if (!file) return;
296
+ try {
297
+ const uploaded = await this.uploadOne(file, options, publicDomain);
298
+ files.push(uploaded);
299
+ done += 1;
300
+ options.onProgress?.({ file: uploaded.file, done, total, size: uploaded.size });
301
+ } catch (error) {
302
+ const normalized = error instanceof Error ? error : new Error(messageOf(error));
303
+ failures.push({ file: file.relative, error: normalized });
304
+ if (!options.continueOnError) stopped = true;
305
+ }
306
+ }
307
+ };
308
+ const workers = Array.from({ length: Math.min(concurrency, total) }, () => worker());
309
+ await Promise.all(workers);
310
+ files.sort((a, b) => a.file.localeCompare(b.file));
311
+ if (failures.length > 0 && !options.continueOnError) {
312
+ const first = failures[0];
313
+ throw new JiseerQiniuError(
314
+ `${failures.length} \u4E2A\u6587\u4EF6\u4E0A\u4F20\u5931\u8D25${first ? `\uFF0C\u4F8B\u5982 ${first.file}\uFF1A${first.error.message}` : ""}`,
315
+ { code: "UPLOAD_FAILED", failures }
316
+ );
317
+ }
318
+ return { files, failures };
319
+ }
320
+ async uploadOne(file, options, publicDomain) {
321
+ const fileName = (0, import_node_path2.basename)(file.relative);
322
+ const token = await this.requestToken({
323
+ fileName,
324
+ path: joinBasePath(options.basePath, file.relative),
325
+ overwrite: options.overwrite ?? true
326
+ });
327
+ const content = await (0, import_promises2.readFile)(file.absolute);
328
+ const form = new FormData();
329
+ form.append("token", token.token);
330
+ form.append("key", token.key);
331
+ form.append("fname", fileName);
332
+ form.append("file", new Blob([content], { type: "application/octet-stream" }), fileName);
333
+ const response = await this.options.fetch(token.uploadUrl, {
334
+ method: "POST",
335
+ body: form,
336
+ signal: withTimeout(this.options.timeoutMs, options.signal)
337
+ }).catch((error) => {
338
+ throw new JiseerQiniuError(`\u4E0A\u4F20 ${file.relative} \u5931\u8D25\uFF1A${messageOf(error)}`, {
339
+ code: "NETWORK",
340
+ path: file.relative,
341
+ cause: error
342
+ });
343
+ });
344
+ const text = await response.text();
345
+ if (!response.ok) {
346
+ throw uploadError(response.status, text, file.relative);
347
+ }
348
+ const parsed = normalizeUploadResponse(text, file.relative);
349
+ if (parsed.fsize !== void 0 && parsed.fsize !== file.size) {
350
+ throw new JiseerQiniuError(
351
+ `\u670D\u52A1\u7AEF\u8FD4\u56DE\u5927\u5C0F ${parsed.fsize} \u4E0E\u672C\u5730 ${file.size} \u4E0D\u4E00\u81F4`,
352
+ { code: "SIZE_MISMATCH", path: file.relative }
353
+ );
354
+ }
355
+ const url = publicDomain === void 0 ? token.publicUrl ?? void 0 : `${publicDomain.replace(/\/+$/, "")}/${token.key}`;
356
+ return { file: file.relative, key: parsed.key, size: file.size, hash: parsed.hash, url };
357
+ }
358
+ requestToken(options) {
359
+ return postJson(
360
+ joinUrl(this.options.endpoint, "/api/upload/token"),
361
+ {
362
+ fileName: options.fileName,
363
+ path: options.path,
364
+ overwrite: options.overwrite ?? false
365
+ },
366
+ { [UPLOAD_KEY_HEADER]: this.options.uploadKey },
367
+ { fetch: this.options.fetch, timeoutMs: this.options.timeoutMs }
368
+ );
369
+ }
370
+ };
371
+ function joinBasePath(basePath, relative) {
372
+ const base = basePath?.trim().replace(/^\/+|\/+$/g, "");
373
+ return base ? `${base}/${relative}` : relative;
374
+ }
375
+ function uploadError(status, text, path) {
376
+ const detail = extractError(text);
377
+ const hint = STATUS_HINTS[status];
378
+ const message = hint === void 0 ? `\u4E0A\u4F20\u5931\u8D25\uFF08HTTP ${status}\uFF09\uFF1A${detail}` : `\u4E0A\u4F20\u5931\u8D25\uFF08HTTP ${status}\uFF09\uFF1A${hint}\uFF1B\u4E03\u725B\u8FD4\u56DE\uFF1A${detail}`;
379
+ return new JiseerQiniuError(message, {
380
+ code: status === 701 ? 701 : "UPLOAD_FAILED",
381
+ status,
382
+ path
383
+ });
384
+ }
385
+ function extractError(text) {
386
+ try {
387
+ const parsed = JSON.parse(text);
388
+ if (typeof parsed.error === "string") return parsed.error;
389
+ } catch {
390
+ }
391
+ return snippet(text);
392
+ }
393
+ function normalizeUploadResponse(text, path) {
394
+ let payload;
395
+ try {
396
+ payload = JSON.parse(text);
397
+ } catch {
398
+ throw new JiseerQiniuError(`\u4E0A\u4F20\u54CD\u5E94\u975E JSON\uFF1A${snippet(text)}`, {
399
+ code: "BAD_RESPONSE",
400
+ path
401
+ });
402
+ }
403
+ let data = payload;
404
+ if (typeof data.code === "number") {
405
+ if (data.code !== 0) {
406
+ throw new JiseerQiniuError(
407
+ `\u56DE\u8C03\u8FD4\u56DE\u9519\u8BEF [${data.code}] ${String(data.message ?? "")}`,
408
+ { code: data.code, path }
409
+ );
410
+ }
411
+ data = data.data ?? {};
412
+ }
413
+ const key = data.key;
414
+ if (typeof key !== "string") {
415
+ throw new JiseerQiniuError(`\u4E0A\u4F20\u54CD\u5E94\u7F3A\u5C11 key\uFF1A${snippet(text)}`, {
416
+ code: "BAD_RESPONSE",
417
+ path
418
+ });
419
+ }
420
+ const hash = typeof data.hash === "string" ? data.hash : void 0;
421
+ const rawSize = data.fsize ?? data.size;
422
+ const fsize = typeof rawSize === "number" ? rawSize : void 0;
423
+ return { key, hash, fsize };
424
+ }
425
+ // Annotate the CommonJS export names for ESM import in node:
426
+ 0 && (module.exports = {
427
+ JiseerQiniuError,
428
+ QiniuClient
429
+ });
@@ -0,0 +1,118 @@
1
+ /** 创建一个客户端所需的参数 */
2
+ interface ClientOptions {
3
+ /** 凭证服务地址,例如 https://api.example.com */
4
+ endpoint: string;
5
+ /** 上传密钥(只在 Node 侧使用,不要打进浏览器) */
6
+ uploadKey: string;
7
+ /** 注入自定义 fetch(测试、代理场景) */
8
+ fetch?: typeof fetch;
9
+ /** 覆盖访问域名,用于拼接最终 URL */
10
+ publicDomain?: string;
11
+ /** 单次 uploadPaths 的并发数,默认 4 */
12
+ concurrency?: number;
13
+ /** 单次 HTTP 请求(取凭证与上传)的总时长上限(毫秒),默认 300000(5 分钟) */
14
+ timeoutMs?: number;
15
+ }
16
+ /** `/api/upload/token` 的返回 */
17
+ interface TokenInfo {
18
+ token: string;
19
+ key: string;
20
+ uploadUrl: string;
21
+ expiresIn: number;
22
+ publicUrl?: string | null;
23
+ callback: boolean;
24
+ }
25
+ /** 只取凭证时的可选参数 */
26
+ interface TokenOptions {
27
+ /** 原始文件名,仅用于决定扩展名 */
28
+ fileName?: string;
29
+ /** 相对路径,决定对象 key(保留目录结构) */
30
+ path?: string;
31
+ /** 允许覆盖同名对象,默认 false */
32
+ overwrite?: boolean;
33
+ }
34
+ /** 单个上传成功的文件 */
35
+ interface UploadedFile {
36
+ /** 本地相对路径 */
37
+ file: string;
38
+ key: string;
39
+ size: number;
40
+ hash?: string;
41
+ url?: string;
42
+ }
43
+ interface UploadFailure {
44
+ file: string;
45
+ error: Error;
46
+ }
47
+ interface PlannedFile {
48
+ file: string;
49
+ size: number;
50
+ }
51
+ interface UploadResult {
52
+ files: UploadedFile[];
53
+ failures: UploadFailure[];
54
+ /** 只在 dryRun 时返回 */
55
+ planned?: PlannedFile[];
56
+ }
57
+ interface ProgressEvent {
58
+ file: string;
59
+ done: number;
60
+ total: number;
61
+ size: number;
62
+ }
63
+ interface UploadOptions {
64
+ /** 远程路径前缀(需与服务端 key 的可写前缀一致),会拼在相对路径前面 */
65
+ basePath?: string;
66
+ /** 允许覆盖同名对象,默认 true(构建产物同步语义) */
67
+ overwrite?: boolean;
68
+ /** 排除规则(glob,相对上传目录) */
69
+ exclude?: string[];
70
+ /** 覆盖客户端的并发数 */
71
+ concurrency?: number;
72
+ /** 只扫描并返回清单,不发任何请求 */
73
+ dryRun?: boolean;
74
+ /** 覆盖访问域名 */
75
+ publicDomain?: string;
76
+ /** 单个文件失败后继续上传其余文件 */
77
+ continueOnError?: boolean;
78
+ /** 中断信号 */
79
+ signal?: AbortSignal;
80
+ /** 每完成一个文件回调一次 */
81
+ onProgress?: (event: ProgressEvent) => void;
82
+ }
83
+
84
+ declare class QiniuClient {
85
+ private readonly options;
86
+ constructor(options: ClientOptions);
87
+ /** 只取凭证,不实际上传 */
88
+ token(options?: TokenOptions): Promise<TokenInfo>;
89
+ /** 上传单个文件 */
90
+ uploadFile(file: string, options?: UploadOptions): Promise<UploadedFile>;
91
+ /** 上传整个目录 */
92
+ uploadDir(dir: string, options?: UploadOptions): Promise<UploadResult>;
93
+ /** 上传任意文件/目录组成的路径列表 */
94
+ uploadPaths(paths: string[], options?: UploadOptions): Promise<UploadResult>;
95
+ private uploadOne;
96
+ private requestToken;
97
+ }
98
+
99
+ interface JiseerQiniuErrorOptions {
100
+ /** 业务码(服务端返回的 code)或内部错误标识 */
101
+ code?: string | number;
102
+ /** HTTP 状态码 */
103
+ status?: number;
104
+ /** 出错的接口路径或文件 */
105
+ path?: string;
106
+ cause?: unknown;
107
+ /** `continueOnError: false` 时附带全部失败项 */
108
+ failures?: UploadFailure[];
109
+ }
110
+ declare class JiseerQiniuError extends Error {
111
+ readonly code: string | number;
112
+ readonly status?: number;
113
+ readonly path?: string;
114
+ readonly failures?: UploadFailure[];
115
+ constructor(message: string, options?: JiseerQiniuErrorOptions);
116
+ }
117
+
118
+ export { type ClientOptions, JiseerQiniuError, type JiseerQiniuErrorOptions, type PlannedFile, type ProgressEvent, QiniuClient, type TokenInfo, type TokenOptions, type UploadFailure, type UploadOptions, type UploadResult, type UploadedFile };
@@ -0,0 +1,118 @@
1
+ /** 创建一个客户端所需的参数 */
2
+ interface ClientOptions {
3
+ /** 凭证服务地址,例如 https://api.example.com */
4
+ endpoint: string;
5
+ /** 上传密钥(只在 Node 侧使用,不要打进浏览器) */
6
+ uploadKey: string;
7
+ /** 注入自定义 fetch(测试、代理场景) */
8
+ fetch?: typeof fetch;
9
+ /** 覆盖访问域名,用于拼接最终 URL */
10
+ publicDomain?: string;
11
+ /** 单次 uploadPaths 的并发数,默认 4 */
12
+ concurrency?: number;
13
+ /** 单次 HTTP 请求(取凭证与上传)的总时长上限(毫秒),默认 300000(5 分钟) */
14
+ timeoutMs?: number;
15
+ }
16
+ /** `/api/upload/token` 的返回 */
17
+ interface TokenInfo {
18
+ token: string;
19
+ key: string;
20
+ uploadUrl: string;
21
+ expiresIn: number;
22
+ publicUrl?: string | null;
23
+ callback: boolean;
24
+ }
25
+ /** 只取凭证时的可选参数 */
26
+ interface TokenOptions {
27
+ /** 原始文件名,仅用于决定扩展名 */
28
+ fileName?: string;
29
+ /** 相对路径,决定对象 key(保留目录结构) */
30
+ path?: string;
31
+ /** 允许覆盖同名对象,默认 false */
32
+ overwrite?: boolean;
33
+ }
34
+ /** 单个上传成功的文件 */
35
+ interface UploadedFile {
36
+ /** 本地相对路径 */
37
+ file: string;
38
+ key: string;
39
+ size: number;
40
+ hash?: string;
41
+ url?: string;
42
+ }
43
+ interface UploadFailure {
44
+ file: string;
45
+ error: Error;
46
+ }
47
+ interface PlannedFile {
48
+ file: string;
49
+ size: number;
50
+ }
51
+ interface UploadResult {
52
+ files: UploadedFile[];
53
+ failures: UploadFailure[];
54
+ /** 只在 dryRun 时返回 */
55
+ planned?: PlannedFile[];
56
+ }
57
+ interface ProgressEvent {
58
+ file: string;
59
+ done: number;
60
+ total: number;
61
+ size: number;
62
+ }
63
+ interface UploadOptions {
64
+ /** 远程路径前缀(需与服务端 key 的可写前缀一致),会拼在相对路径前面 */
65
+ basePath?: string;
66
+ /** 允许覆盖同名对象,默认 true(构建产物同步语义) */
67
+ overwrite?: boolean;
68
+ /** 排除规则(glob,相对上传目录) */
69
+ exclude?: string[];
70
+ /** 覆盖客户端的并发数 */
71
+ concurrency?: number;
72
+ /** 只扫描并返回清单,不发任何请求 */
73
+ dryRun?: boolean;
74
+ /** 覆盖访问域名 */
75
+ publicDomain?: string;
76
+ /** 单个文件失败后继续上传其余文件 */
77
+ continueOnError?: boolean;
78
+ /** 中断信号 */
79
+ signal?: AbortSignal;
80
+ /** 每完成一个文件回调一次 */
81
+ onProgress?: (event: ProgressEvent) => void;
82
+ }
83
+
84
+ declare class QiniuClient {
85
+ private readonly options;
86
+ constructor(options: ClientOptions);
87
+ /** 只取凭证,不实际上传 */
88
+ token(options?: TokenOptions): Promise<TokenInfo>;
89
+ /** 上传单个文件 */
90
+ uploadFile(file: string, options?: UploadOptions): Promise<UploadedFile>;
91
+ /** 上传整个目录 */
92
+ uploadDir(dir: string, options?: UploadOptions): Promise<UploadResult>;
93
+ /** 上传任意文件/目录组成的路径列表 */
94
+ uploadPaths(paths: string[], options?: UploadOptions): Promise<UploadResult>;
95
+ private uploadOne;
96
+ private requestToken;
97
+ }
98
+
99
+ interface JiseerQiniuErrorOptions {
100
+ /** 业务码(服务端返回的 code)或内部错误标识 */
101
+ code?: string | number;
102
+ /** HTTP 状态码 */
103
+ status?: number;
104
+ /** 出错的接口路径或文件 */
105
+ path?: string;
106
+ cause?: unknown;
107
+ /** `continueOnError: false` 时附带全部失败项 */
108
+ failures?: UploadFailure[];
109
+ }
110
+ declare class JiseerQiniuError extends Error {
111
+ readonly code: string | number;
112
+ readonly status?: number;
113
+ readonly path?: string;
114
+ readonly failures?: UploadFailure[];
115
+ constructor(message: string, options?: JiseerQiniuErrorOptions);
116
+ }
117
+
118
+ export { type ClientOptions, JiseerQiniuError, type JiseerQiniuErrorOptions, type PlannedFile, type ProgressEvent, QiniuClient, type TokenInfo, type TokenOptions, type UploadFailure, type UploadOptions, type UploadResult, type UploadedFile };
package/dist/index.js ADDED
@@ -0,0 +1,391 @@
1
+ // src/client.ts
2
+ import { readFile } from "fs/promises";
3
+ import { basename as basename2 } from "path";
4
+
5
+ // src/errors.ts
6
+ var JiseerQiniuError = class extends Error {
7
+ code;
8
+ status;
9
+ path;
10
+ failures;
11
+ constructor(message, options = {}) {
12
+ super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
13
+ this.name = "JiseerQiniuError";
14
+ this.code = options.code ?? "UNKNOWN";
15
+ this.status = options.status;
16
+ this.path = options.path;
17
+ this.failures = options.failures;
18
+ }
19
+ };
20
+
21
+ // src/http.ts
22
+ var MAX_RETRY_WAIT_SECONDS = 5;
23
+ function joinUrl(endpoint, path) {
24
+ return `${endpoint.replace(/\/+$/, "")}${path}`;
25
+ }
26
+ async function postJson(url, body, headers, context) {
27
+ let retried = false;
28
+ for (; ; ) {
29
+ const response = await send(url, body, headers, context);
30
+ if (response.status === 429 && !retried) {
31
+ const wait = retryAfterSeconds(response);
32
+ if (wait <= MAX_RETRY_WAIT_SECONDS) {
33
+ retried = true;
34
+ await sleep(wait * 1e3);
35
+ continue;
36
+ }
37
+ throw new JiseerQiniuError(`\u670D\u52A1\u7AEF\u9650\u6D41\uFF0C\u9700\u8981\u7B49\u5F85 ${wait} \u79D2`, {
38
+ code: 42901,
39
+ status: 429,
40
+ path: url
41
+ });
42
+ }
43
+ const text = await response.text();
44
+ return parseEnvelope(text, url, response.status);
45
+ }
46
+ }
47
+ async function send(url, body, headers, context) {
48
+ try {
49
+ return await context.fetch(url, {
50
+ method: "POST",
51
+ headers: { "content-type": "application/json", ...headers },
52
+ body: JSON.stringify(body ?? {}),
53
+ signal: withTimeout(context.timeoutMs, context.signal)
54
+ });
55
+ } catch (error) {
56
+ throw new JiseerQiniuError(`\u8BF7\u6C42 ${url} \u5931\u8D25\uFF1A${messageOf(error)}`, {
57
+ code: "NETWORK",
58
+ path: url,
59
+ cause: error
60
+ });
61
+ }
62
+ }
63
+ function parseEnvelope(text, url, status) {
64
+ let payload;
65
+ try {
66
+ payload = JSON.parse(text);
67
+ } catch {
68
+ throw new JiseerQiniuError(`${url} \u8FD4\u56DE\u975E JSON\uFF08HTTP ${status}\uFF09\uFF1A${snippet(text)}`, {
69
+ code: "BAD_RESPONSE",
70
+ status,
71
+ path: url
72
+ });
73
+ }
74
+ if (typeof payload.code !== "number") {
75
+ throw new JiseerQiniuError(`${url} \u8FD4\u56DE\u4E86\u975E\u9884\u671F\u7ED3\u6784\uFF08HTTP ${status}\uFF09\uFF1A${snippet(text)}`, {
76
+ code: "BAD_RESPONSE",
77
+ status,
78
+ path: url
79
+ });
80
+ }
81
+ if (payload.code !== 0) {
82
+ throw new JiseerQiniuError(`${url} \u8C03\u7528\u5931\u8D25 [${payload.code}] ${payload.message}`, {
83
+ code: payload.code,
84
+ status,
85
+ path: url
86
+ });
87
+ }
88
+ if (payload.data === void 0 || payload.data === null) {
89
+ throw new JiseerQiniuError(`${url} \u54CD\u5E94\u7F3A\u5C11 data`, {
90
+ code: "MISSING_DATA",
91
+ status,
92
+ path: url
93
+ });
94
+ }
95
+ return payload.data;
96
+ }
97
+ function snippet(text) {
98
+ const trimmed = text.trim();
99
+ if (trimmed.length === 0) return "(\u7A7A\u54CD\u5E94)";
100
+ return trimmed.length <= 512 ? trimmed : `${trimmed.slice(0, 512)}\u2026`;
101
+ }
102
+ function messageOf(error) {
103
+ return error instanceof Error ? error.message : String(error);
104
+ }
105
+ function withTimeout(timeoutMs, signal) {
106
+ const timeout = AbortSignal.timeout(timeoutMs);
107
+ if (!signal) return timeout;
108
+ if (typeof AbortSignal.any === "function") return AbortSignal.any([signal, timeout]);
109
+ const controller = new AbortController();
110
+ const abort = () => {
111
+ signal.removeEventListener("abort", abort);
112
+ timeout.removeEventListener("abort", abort);
113
+ controller.abort(signal.reason ?? timeout.reason);
114
+ };
115
+ if (signal.aborted || timeout.aborted) {
116
+ abort();
117
+ return controller.signal;
118
+ }
119
+ signal.addEventListener("abort", abort, { once: true });
120
+ timeout.addEventListener("abort", abort, { once: true });
121
+ return controller.signal;
122
+ }
123
+ function retryAfterSeconds(response) {
124
+ const raw = response.headers.get("retry-after");
125
+ const parsed = raw === null ? Number.NaN : Number.parseInt(raw, 10);
126
+ return Number.isFinite(parsed) ? parsed : 1;
127
+ }
128
+ function sleep(ms) {
129
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
130
+ }
131
+
132
+ // src/scan.ts
133
+ import { stat } from "fs/promises";
134
+ import { basename, resolve } from "path";
135
+ import fg from "fast-glob";
136
+ var DEFAULT_IGNORE = [".git/**", "node_modules/**", "target/**", "__pycache__/**"];
137
+ async function scanPaths(inputs, options = {}) {
138
+ const files = [];
139
+ for (const input of inputs) {
140
+ const absolute = resolve(input);
141
+ const info = await stat(absolute).catch(() => null);
142
+ if (!info) {
143
+ throw new JiseerQiniuError(`\u65E0\u6CD5\u8BFB\u53D6 ${input}`, { code: "NOT_FOUND", path: input });
144
+ }
145
+ if (info.isFile()) {
146
+ files.push({ absolute, relative: basename(absolute), size: info.size });
147
+ continue;
148
+ }
149
+ if (!info.isDirectory()) continue;
150
+ const entries = await fg("**/*", {
151
+ cwd: absolute,
152
+ dot: false,
153
+ onlyFiles: true,
154
+ followSymbolicLinks: false,
155
+ ignore: [...DEFAULT_IGNORE, ...options.exclude ?? []]
156
+ });
157
+ for (const entry of entries.sort()) {
158
+ const child = resolve(absolute, entry);
159
+ const childInfo = await stat(child).catch(() => null);
160
+ if (!childInfo || !childInfo.isFile()) continue;
161
+ files.push({
162
+ absolute: child,
163
+ relative: entry.split("\\").join("/"),
164
+ size: childInfo.size
165
+ });
166
+ }
167
+ }
168
+ files.sort((a, b) => a.relative.localeCompare(b.relative));
169
+ return files;
170
+ }
171
+
172
+ // src/client.ts
173
+ var DEFAULT_CONCURRENCY = 4;
174
+ var DEFAULT_TIMEOUT_MS = 5 * 6e4;
175
+ var UPLOAD_KEY_HEADER = "x-qiniu-upload-key";
176
+ var STATUS_HINTS = {
177
+ 401: "\u4E0A\u4F20\u5BC6\u94A5\u7F3A\u5931\u6216\u65E0\u6548",
178
+ 403: "\u8DEF\u5F84\u4E0D\u5728\u8BE5\u5BA2\u6237\u7AEF\u7684\u53EF\u5199\u8303\u56F4\u5185",
179
+ 406: "\u5185\u5BB9\u7C7B\u578B\u88AB\u4E0A\u4F20\u7B56\u7565\u7684 mimeLimit \u62D2\u7EDD",
180
+ 413: "\u6587\u4EF6\u8D85\u8FC7\u4E0A\u4F20\u7B56\u7565\u7684 fsizeLimit",
181
+ 579: "\u4E03\u725B\u56DE\u8C03\u4E1A\u52A1\u670D\u52A1\u5668\u5931\u8D25",
182
+ 614: "\u4E0A\u4F20\u7A7A\u95F4\u4E0D\u5B58\u5728",
183
+ 631: "\u4E0A\u4F20\u7A7A\u95F4\u5DF2\u88AB\u7981\u7528",
184
+ 701: "\u5BF9\u8C61\u5DF2\u5B58\u5728\uFF08\u7B56\u7565\u4E3A insertOnly\uFF09\uFF0C\u8BBE\u7F6E overwrite: true \u53EF\u8986\u76D6"
185
+ };
186
+ var QiniuClient = class {
187
+ options;
188
+ constructor(options) {
189
+ if (typeof globalThis === "object" && "window" in globalThis && "document" in globalThis) {
190
+ throw new JiseerQiniuError("@jiseer/qiniu \u4EC5\u652F\u6301 Node \u73AF\u5883\uFF0C\u8BF7\u52FF\u5728\u6D4F\u89C8\u5668\u4E2D\u4F7F\u7528", {
191
+ code: "BROWSER_NOT_SUPPORTED"
192
+ });
193
+ }
194
+ if (!options || typeof options.endpoint !== "string" || options.endpoint.length === 0) {
195
+ throw new JiseerQiniuError("\u7F3A\u5C11 endpoint\uFF08\u51ED\u8BC1\u670D\u52A1\u5730\u5740\uFF09", {
196
+ code: "INVALID_OPTIONS"
197
+ });
198
+ }
199
+ if (typeof options.uploadKey !== "string" || options.uploadKey.length === 0) {
200
+ throw new JiseerQiniuError("\u7F3A\u5C11 uploadKey\uFF08\u4E0A\u4F20\u5BC6\u94A5\uFF09", { code: "INVALID_OPTIONS" });
201
+ }
202
+ const fetchImpl = options.fetch ?? globalThis.fetch;
203
+ if (typeof fetchImpl !== "function") {
204
+ throw new JiseerQiniuError("\u5F53\u524D\u73AF\u5883\u6CA1\u6709 fetch\uFF0C\u9700\u8981 Node 18+", { code: "NO_FETCH" });
205
+ }
206
+ this.options = {
207
+ endpoint: options.endpoint.replace(/\/+$/, ""),
208
+ uploadKey: options.uploadKey,
209
+ fetch: fetchImpl,
210
+ concurrency: options.concurrency ?? DEFAULT_CONCURRENCY,
211
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
212
+ publicDomain: options.publicDomain
213
+ };
214
+ }
215
+ /** 只取凭证,不实际上传 */
216
+ token(options = {}) {
217
+ return this.requestToken(options);
218
+ }
219
+ /** 上传单个文件 */
220
+ async uploadFile(file, options = {}) {
221
+ const result = await this.uploadPaths([file], options);
222
+ const uploaded = result.files[0];
223
+ if (uploaded) return uploaded;
224
+ const failure = result.failures[0];
225
+ throw failure ? failure.error : new JiseerQiniuError(`\u4E0A\u4F20\u5931\u8D25\uFF1A${file}`, { code: "UPLOAD_FAILED", path: file });
226
+ }
227
+ /** 上传整个目录 */
228
+ uploadDir(dir, options = {}) {
229
+ return this.uploadPaths([dir], options);
230
+ }
231
+ /** 上传任意文件/目录组成的路径列表 */
232
+ async uploadPaths(paths, options = {}) {
233
+ const scanned = await scanPaths(paths, { exclude: options.exclude });
234
+ if (scanned.length === 0) {
235
+ throw new JiseerQiniuError("\u6CA1\u6709\u627E\u5230\u53EF\u4E0A\u4F20\u7684\u6587\u4EF6", { code: "NO_FILES" });
236
+ }
237
+ if (options.dryRun) {
238
+ return {
239
+ files: [],
240
+ failures: [],
241
+ planned: scanned.map((file) => ({ file: file.relative, size: file.size }))
242
+ };
243
+ }
244
+ const concurrency = Math.max(1, options.concurrency ?? this.options.concurrency);
245
+ const publicDomain = options.publicDomain ?? this.options.publicDomain;
246
+ const total = scanned.length;
247
+ const files = [];
248
+ const failures = [];
249
+ let done = 0;
250
+ let cursor = 0;
251
+ let stopped = false;
252
+ const worker = async () => {
253
+ for (; ; ) {
254
+ if (stopped) return;
255
+ if (options.signal?.aborted) return;
256
+ const file = scanned[cursor];
257
+ cursor += 1;
258
+ if (!file) return;
259
+ try {
260
+ const uploaded = await this.uploadOne(file, options, publicDomain);
261
+ files.push(uploaded);
262
+ done += 1;
263
+ options.onProgress?.({ file: uploaded.file, done, total, size: uploaded.size });
264
+ } catch (error) {
265
+ const normalized = error instanceof Error ? error : new Error(messageOf(error));
266
+ failures.push({ file: file.relative, error: normalized });
267
+ if (!options.continueOnError) stopped = true;
268
+ }
269
+ }
270
+ };
271
+ const workers = Array.from({ length: Math.min(concurrency, total) }, () => worker());
272
+ await Promise.all(workers);
273
+ files.sort((a, b) => a.file.localeCompare(b.file));
274
+ if (failures.length > 0 && !options.continueOnError) {
275
+ const first = failures[0];
276
+ throw new JiseerQiniuError(
277
+ `${failures.length} \u4E2A\u6587\u4EF6\u4E0A\u4F20\u5931\u8D25${first ? `\uFF0C\u4F8B\u5982 ${first.file}\uFF1A${first.error.message}` : ""}`,
278
+ { code: "UPLOAD_FAILED", failures }
279
+ );
280
+ }
281
+ return { files, failures };
282
+ }
283
+ async uploadOne(file, options, publicDomain) {
284
+ const fileName = basename2(file.relative);
285
+ const token = await this.requestToken({
286
+ fileName,
287
+ path: joinBasePath(options.basePath, file.relative),
288
+ overwrite: options.overwrite ?? true
289
+ });
290
+ const content = await readFile(file.absolute);
291
+ const form = new FormData();
292
+ form.append("token", token.token);
293
+ form.append("key", token.key);
294
+ form.append("fname", fileName);
295
+ form.append("file", new Blob([content], { type: "application/octet-stream" }), fileName);
296
+ const response = await this.options.fetch(token.uploadUrl, {
297
+ method: "POST",
298
+ body: form,
299
+ signal: withTimeout(this.options.timeoutMs, options.signal)
300
+ }).catch((error) => {
301
+ throw new JiseerQiniuError(`\u4E0A\u4F20 ${file.relative} \u5931\u8D25\uFF1A${messageOf(error)}`, {
302
+ code: "NETWORK",
303
+ path: file.relative,
304
+ cause: error
305
+ });
306
+ });
307
+ const text = await response.text();
308
+ if (!response.ok) {
309
+ throw uploadError(response.status, text, file.relative);
310
+ }
311
+ const parsed = normalizeUploadResponse(text, file.relative);
312
+ if (parsed.fsize !== void 0 && parsed.fsize !== file.size) {
313
+ throw new JiseerQiniuError(
314
+ `\u670D\u52A1\u7AEF\u8FD4\u56DE\u5927\u5C0F ${parsed.fsize} \u4E0E\u672C\u5730 ${file.size} \u4E0D\u4E00\u81F4`,
315
+ { code: "SIZE_MISMATCH", path: file.relative }
316
+ );
317
+ }
318
+ const url = publicDomain === void 0 ? token.publicUrl ?? void 0 : `${publicDomain.replace(/\/+$/, "")}/${token.key}`;
319
+ return { file: file.relative, key: parsed.key, size: file.size, hash: parsed.hash, url };
320
+ }
321
+ requestToken(options) {
322
+ return postJson(
323
+ joinUrl(this.options.endpoint, "/api/upload/token"),
324
+ {
325
+ fileName: options.fileName,
326
+ path: options.path,
327
+ overwrite: options.overwrite ?? false
328
+ },
329
+ { [UPLOAD_KEY_HEADER]: this.options.uploadKey },
330
+ { fetch: this.options.fetch, timeoutMs: this.options.timeoutMs }
331
+ );
332
+ }
333
+ };
334
+ function joinBasePath(basePath, relative) {
335
+ const base = basePath?.trim().replace(/^\/+|\/+$/g, "");
336
+ return base ? `${base}/${relative}` : relative;
337
+ }
338
+ function uploadError(status, text, path) {
339
+ const detail = extractError(text);
340
+ const hint = STATUS_HINTS[status];
341
+ const message = hint === void 0 ? `\u4E0A\u4F20\u5931\u8D25\uFF08HTTP ${status}\uFF09\uFF1A${detail}` : `\u4E0A\u4F20\u5931\u8D25\uFF08HTTP ${status}\uFF09\uFF1A${hint}\uFF1B\u4E03\u725B\u8FD4\u56DE\uFF1A${detail}`;
342
+ return new JiseerQiniuError(message, {
343
+ code: status === 701 ? 701 : "UPLOAD_FAILED",
344
+ status,
345
+ path
346
+ });
347
+ }
348
+ function extractError(text) {
349
+ try {
350
+ const parsed = JSON.parse(text);
351
+ if (typeof parsed.error === "string") return parsed.error;
352
+ } catch {
353
+ }
354
+ return snippet(text);
355
+ }
356
+ function normalizeUploadResponse(text, path) {
357
+ let payload;
358
+ try {
359
+ payload = JSON.parse(text);
360
+ } catch {
361
+ throw new JiseerQiniuError(`\u4E0A\u4F20\u54CD\u5E94\u975E JSON\uFF1A${snippet(text)}`, {
362
+ code: "BAD_RESPONSE",
363
+ path
364
+ });
365
+ }
366
+ let data = payload;
367
+ if (typeof data.code === "number") {
368
+ if (data.code !== 0) {
369
+ throw new JiseerQiniuError(
370
+ `\u56DE\u8C03\u8FD4\u56DE\u9519\u8BEF [${data.code}] ${String(data.message ?? "")}`,
371
+ { code: data.code, path }
372
+ );
373
+ }
374
+ data = data.data ?? {};
375
+ }
376
+ const key = data.key;
377
+ if (typeof key !== "string") {
378
+ throw new JiseerQiniuError(`\u4E0A\u4F20\u54CD\u5E94\u7F3A\u5C11 key\uFF1A${snippet(text)}`, {
379
+ code: "BAD_RESPONSE",
380
+ path
381
+ });
382
+ }
383
+ const hash = typeof data.hash === "string" ? data.hash : void 0;
384
+ const rawSize = data.fsize ?? data.size;
385
+ const fsize = typeof rawSize === "number" ? rawSize : void 0;
386
+ return { key, hash, fsize };
387
+ }
388
+ export {
389
+ JiseerQiniuError,
390
+ QiniuClient
391
+ };
package/docs/api.md ADDED
@@ -0,0 +1,96 @@
1
+ # API
2
+
3
+ ## `new QiniuClient(options)`
4
+
5
+ | 参数 | 类型 | 必填 | 默认 | 说明 |
6
+ | --- | --- | --- | --- | --- |
7
+ | `endpoint` | `string` | 是 | — | 凭证服务地址,末尾斜杠会被忽略 |
8
+ | `uploadKey` | `string` | 是 | — | 上传密钥(只用于调 `/api/upload/token`) |
9
+ | `fetch` | `typeof fetch` | 否 | 全局 `fetch` | 注入自定义实现(测试/代理) |
10
+ | `publicDomain` | `string` | 否 | — | 访问域名,用于拼返回的 URL |
11
+ | `concurrency` | `number` | 否 | `4` | 单次上传的并发数 |
12
+ | `timeoutMs` | `number` | 否 | `300000` | 单次 HTTP 请求(取凭证与上传)的总时长上限;大文件按需调大 |
13
+
14
+ 构造时会检查运行环境:检测到 `window` 与 `document`(浏览器)直接抛 `BROWSER_NOT_SUPPORTED`,避免上传密钥被打进前端包。
15
+
16
+ ## `uploadDir(dir, options?)` / `uploadPaths(paths, options?)`
17
+
18
+ 返回 `Promise<UploadResult>`:
19
+
20
+ ```ts
21
+ interface UploadResult {
22
+ files: UploadedFile[]; // 按相对路径排序
23
+ failures: UploadFailure[];
24
+ planned?: PlannedFile[]; // 仅 dryRun
25
+ }
26
+ ```
27
+
28
+ | 选项 | 类型 | 默认 | 说明 |
29
+ | --- | --- | --- | --- |
30
+ | `basePath` | `string` | — | 远程路径前缀,需与服务端该 key 的可写前缀一致,会拼在相对路径前 |
31
+ | `overwrite` | `boolean` | `true` | 允许覆盖同名对象 |
32
+ | `exclude` | `string[]` | `[]` | 排除规则(glob,相对上传根) |
33
+ | `concurrency` | `number` | 客户端配置 | 覆盖并发数 |
34
+ | `dryRun` | `boolean` | `false` | 只扫描,不发请求 |
35
+ | `publicDomain` | `string` | 客户端配置 | 覆盖访问域名 |
36
+ | `continueOnError` | `boolean` | `false` | 单文件失败后继续 |
37
+ | `signal` | `AbortSignal` | — | 中断上传 |
38
+ | `onProgress` | `(event) => void` | — | 每完成一个文件回调一次 |
39
+
40
+ `ProgressEvent`:
41
+
42
+ ```ts
43
+ interface ProgressEvent { file: string; done: number; total: number; size: number }
44
+ ```
45
+
46
+ ## `uploadFile(file, options?)`
47
+
48
+ 返回 `Promise<UploadedFile>`:
49
+
50
+ ```ts
51
+ interface UploadedFile { file: string; key: string; size: number; hash?: string; url?: string }
52
+ ```
53
+
54
+ 传入的单个文件不会被 `exclude` 过滤。
55
+
56
+ ## `token(options?)`
57
+
58
+ 只取凭证,不上传:
59
+
60
+ ```ts
61
+ interface TokenOptions { fileName?: string; path?: string; overwrite?: boolean }
62
+
63
+ interface TokenInfo {
64
+ token: string; // 七牛上传凭证
65
+ key: string; // 服务端锁定的对象 key
66
+ uploadUrl: string; // 七牛上传入口
67
+ expiresIn: number;
68
+ publicUrl?: string | null;
69
+ callback: boolean;
70
+ }
71
+ ```
72
+
73
+ - `path` 决定对象 key(保留目录结构),指定后 `fileName` 被忽略;
74
+ - `fileName` 只用于决定扩展名(uuid key 场景)。
75
+
76
+ ## 目录扫描规则
77
+
78
+ - 显式传入的文件原样上传(不过滤);
79
+ - 目录递归遍历,`dot: false`,跳过 `.git`、`node_modules`、`target`、`__pycache__` 与符号链接;
80
+ - 结果按相对路径排序,保证输出稳定。
81
+
82
+ ## 错误码
83
+
84
+ | code | 场景 |
85
+ | --- | --- |
86
+ | `BROWSER_NOT_SUPPORTED` | 在浏览器环境构造客户端 |
87
+ | `INVALID_OPTIONS` | 构造参数缺失 |
88
+ | `NO_FETCH` | 环境没有 `fetch`(Node < 18) |
89
+ | `NOT_FOUND` | 路径不存在 |
90
+ | `NO_FILES` | 没找到可上传的文件 |
91
+ | `NETWORK` | 网络错误 / 超时 |
92
+ | `BAD_RESPONSE` | 响应不是预期 JSON |
93
+ | `MISSING_DATA` | 统一信封缺少 `data` |
94
+ | `SIZE_MISMATCH` | 服务端 `fsize` 与本地不一致 |
95
+ | `UPLOAD_FAILED` | 一个或多个文件上传失败 |
96
+ | `40001` / `40101` / `40301` / `413` / `42901` / `701` | 服务端与七牛的业务码 |
@@ -0,0 +1,106 @@
1
+ # 示例
2
+
3
+ > 更新日期:2026-10-30
4
+
5
+ ## 1. 多环境
6
+
7
+ staging 与 production 用不同的 `endpoint` / `uploadKey`(或同一服务不同空间):
8
+
9
+ ```js
10
+ import { QiniuClient } from '@jiseer/qiniu';
11
+
12
+ const env = process.env.DEPLOY_ENV ?? 'staging';
13
+ const config = {
14
+ staging: { endpoint: process.env.STAGING_ENDPOINT, uploadKey: process.env.STAGING_UPLOAD_KEY },
15
+ production: { endpoint: process.env.PROD_ENDPOINT, uploadKey: process.env.PROD_UPLOAD_KEY },
16
+ }[env];
17
+
18
+ if (!config?.endpoint || !config?.uploadKey) {
19
+ throw new Error(`缺少 ${env} 环境的 endpoint / uploadKey`);
20
+ }
21
+
22
+ const client = new QiniuClient({ ...config, publicDomain: process.env.PUBLIC_DOMAIN });
23
+ const result = await client.uploadDir('dist', { overwrite: true });
24
+ ```
25
+
26
+ 服务端前缀是按空间配置的,同一套服务无法把 staging 与 production 写到不同前缀——需要指向不同的凭证服务实例(或不同空间)。
27
+
28
+ ## 2. 路径前缀
29
+
30
+ 如果服务端给这把 key 配了可写前缀(例如 `ci/`),上传时要把 `basePath` 设成同样的值:
31
+
32
+ ```js
33
+ await client.uploadDir('dist', { basePath: 'ci', overwrite: true });
34
+ // path = ci/index.html → key = uploads/ci/index.html
35
+ ```
36
+
37
+ 不设置的话,相对路径(`index.html`)不在 `ci/` 下,服务端会返回 `403 40301`。
38
+
39
+ ## 3. 只看不传
40
+
41
+ ```js
42
+ const preview = await client.uploadDir('dist', { dryRun: true });
43
+ console.table(preview.planned);
44
+ // [{ file: 'index.html', size: 812 }, ...]
45
+ ```
46
+
47
+ `dryRun` 不发任何 HTTP 请求,适合在 CI 里先做一次「将要上传什么」的检查。
48
+
49
+ ## 4. 失败重试
50
+
51
+ ```js
52
+ const result = await client.uploadDir('dist', {
53
+ overwrite: true,
54
+ continueOnError: true,
55
+ });
56
+
57
+ if (result.failures.length > 0) {
58
+ console.error('失败文件:', result.failures.map((failure) => failure.file));
59
+ // 幂等:直接重跑整次上传即可,overwrite 会覆盖已成功的部分
60
+ process.exitCode = 1;
61
+ }
62
+ ```
63
+
64
+ 上传是幂等的(`overwrite: true` 时),重跑整批比精确重试单个文件更简单。
65
+
66
+ ## 5. 上传后刷新 CDN(示意)
67
+
68
+ 包本身不提供 CDN 刷新能力,下面只是把上传结果交给刷新任务的位置示意:
69
+
70
+ ```js
71
+ const result = await client.uploadDir('dist', { overwrite: true });
72
+
73
+ // 只刷入口页这类文件名固定的资源,带 hash 的不用刷
74
+ const refreshList = result.files
75
+ .filter((file) => file.file.endsWith('.html'))
76
+ .map((file) => file.url ?? file.key);
77
+
78
+ await fetch(`${process.env.CDN_REFRESH_ENDPOINT}`, {
79
+ method: 'POST',
80
+ headers: { 'content-type': 'application/json' },
81
+ body: JSON.stringify({ urls: refreshList }),
82
+ });
83
+ ```
84
+
85
+ ## 6. 回滚
86
+
87
+ 包只做上传,没有删除/版本切换能力。回滚 = 把上一版产物重新传一次(`overwrite: true`):
88
+
89
+ ```bash
90
+ # 从 CI artifact 或 git tag 取出旧版 dist
91
+ git checkout v1.2.0 -- dist
92
+ node scripts/deploy.mjs
93
+ ```
94
+
95
+ 前提是旧版产物还能拿到:建议在 CI 里把 `dist/` 作为 artifact 保存(见 [github-actions.md](../../../docs/guides/github-actions.md))。
96
+
97
+ ## 7. 只取凭证给别的工具用
98
+
99
+ ```js
100
+ const { token, key, uploadUrl } = await client.token({ path: 'dist/app.js', overwrite: true });
101
+
102
+ // 交给七牛官方 SDK 或自己的上传实现
103
+ console.log(uploadUrl, key, token);
104
+ ```
105
+
106
+ 注意:凭证里已经锁定了 key,上传时必须原样使用,不能改写。
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@jiseer/qiniu",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "把前端构建产物上传到七牛云存储(由服务端签发上传凭证)",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=18"
8
+ },
9
+ "keywords": [
10
+ "qiniu",
11
+ "upload",
12
+ "kodo",
13
+ "deploy",
14
+ "cdn"
15
+ ],
16
+ "license": "MIT",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js",
21
+ "require": "./dist/index.cjs"
22
+ }
23
+ },
24
+ "main": "./dist/index.cjs",
25
+ "module": "./dist/index.js",
26
+ "types": "./dist/index.d.ts",
27
+ "files": [
28
+ "dist",
29
+ "docs"
30
+ ],
31
+ "publishConfig": {
32
+ "access": "public",
33
+ "registry": "https://registry.npmjs.org/"
34
+ },
35
+ "dependencies": {
36
+ "fast-glob": "^3.3.2"
37
+ },
38
+ "scripts": {
39
+ "build": "tsup",
40
+ "test": "vitest run",
41
+ "typecheck": "tsc --noEmit"
42
+ }
6
43
  }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.