@xgspz/base-sdk 0.0.0-stage → 1.0.5

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,101 @@
1
+ import fs from "node:fs";
2
+ import type { StorageUploadTicket } from "@xgspz/contracts";
3
+
4
+ /**
5
+ * 拿到基础服务签发的上传令牌后,直接传到对象存储(七牛),文件不经过基础服务中转:
6
+ * - 16MB 以内:表单上传;
7
+ * - 更大:分片上传 v2(initiate → 逐片 PUT → complete),每片失败重试 3 次。
8
+ * 令牌只对一个确定的 key 有效且只能新建:同一个 key 已存在(614)视为成功(key 按内容命名)。
9
+ */
10
+ export class StorageUploadError extends Error {
11
+ constructor(message: string, readonly status: number) {
12
+ super(message);
13
+ this.name = "StorageUploadError";
14
+ }
15
+ }
16
+
17
+ const FORM_LIMIT = 16 * 1024 * 1024;
18
+ const DEFAULT_PART = 8 * 1024 * 1024;
19
+ const b64url = (s: string) => Buffer.from(s).toString("base64").replace(/\+/g, "-").replace(/\//g, "_");
20
+
21
+ /** 上传令牌里带着 putPolicy(scope = bucket:key),分片上传的地址要用到 bucket。 */
22
+ function bucketOf(token: string) {
23
+ const encoded = token.split(":")[2] ?? "";
24
+ const policy = JSON.parse(Buffer.from(encoded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")) as { scope?: string };
25
+ const bucket = String(policy.scope ?? "").split(":")[0];
26
+ if (!bucket) throw new StorageUploadError("上传令牌里没有 bucket", 400);
27
+ return bucket;
28
+ }
29
+
30
+ async function ok(res: Response, what: string) {
31
+ if (res.ok) return res.json().catch(() => ({})) as Promise<Record<string, unknown>>;
32
+ const text = await res.text().catch(() => "");
33
+ throw new StorageUploadError(`${what}失败(HTTP ${res.status}):${text.slice(0, 200)}`, res.status);
34
+ }
35
+
36
+ export async function uploadToStorage(
37
+ ticket: Pick<StorageUploadTicket, "key" | "token" | "uploadUrl">,
38
+ source: { file: string } | { bytes: Buffer },
39
+ opts: { contentType: string; signal?: AbortSignal; partSize?: number },
40
+ ): Promise<{ key: string; existed: boolean; size: number }> {
41
+ const size = "bytes" in source ? source.bytes.length : fs.statSync(source.file).size;
42
+ if (size <= FORM_LIMIT) {
43
+ const bytes = "bytes" in source ? source.bytes : await fs.promises.readFile(source.file);
44
+ const form = new FormData();
45
+ form.set("token", ticket.token);
46
+ form.set("key", ticket.key);
47
+ form.set("file", new Blob([new Uint8Array(bytes)], { type: opts.contentType }), ticket.key.split("/").pop() || "file");
48
+ const res = await fetch(ticket.uploadUrl, { method: "POST", body: form, signal: opts.signal });
49
+ if (res.status === 614) return { key: ticket.key, existed: true, size };
50
+ await ok(res, "上传");
51
+ return { key: ticket.key, existed: false, size };
52
+ }
53
+
54
+ const partSize = Math.max(1024 * 1024, opts.partSize ?? DEFAULT_PART);
55
+ const base = `${ticket.uploadUrl}/buckets/${encodeURIComponent(bucketOf(ticket.token))}/objects/${b64url(ticket.key)}/uploads`;
56
+ const auth = { Authorization: `UpToken ${ticket.token}` };
57
+ const init = await fetch(base, { method: "POST", headers: auth, signal: opts.signal });
58
+ if (init.status === 614) return { key: ticket.key, existed: true, size };
59
+ const { uploadId } = await ok(init, "创建分片上传") as { uploadId?: string };
60
+ if (!uploadId) throw new StorageUploadError("创建分片上传没有返回 uploadId", 502);
61
+
62
+ const handle = "file" in source ? await fs.promises.open(source.file, "r") : null;
63
+ const parts: Array<{ partNumber: number; etag: string }> = [];
64
+ try {
65
+ for (let offset = 0, partNumber = 1; offset < size; offset += partSize, partNumber += 1) {
66
+ const length = Math.min(partSize, size - offset);
67
+ let chunk: Buffer;
68
+ if (handle) {
69
+ chunk = Buffer.alloc(length);
70
+ await handle.read(chunk, 0, length, offset);
71
+ } else {
72
+ chunk = (source as { bytes: Buffer }).bytes.subarray(offset, offset + length);
73
+ }
74
+ let etag = "";
75
+ for (let attempt = 1; attempt <= 3 && !etag; attempt += 1) {
76
+ try {
77
+ const res = await fetch(`${base}/${uploadId}/${partNumber}`, {
78
+ method: "PUT", headers: { ...auth, "Content-Type": "application/octet-stream" }, body: new Uint8Array(chunk), signal: opts.signal,
79
+ });
80
+ etag = String((await ok(res, `上传第 ${partNumber} 片`)).etag ?? "");
81
+ if (!etag) throw new StorageUploadError(`第 ${partNumber} 片没有返回 etag`, 502);
82
+ } catch (error) {
83
+ if (attempt === 3 || opts.signal?.aborted) throw error;
84
+ await new Promise((r) => setTimeout(r, 500 * attempt));
85
+ }
86
+ }
87
+ parts.push({ partNumber, etag });
88
+ }
89
+ } finally {
90
+ await handle?.close();
91
+ }
92
+ const done = await fetch(`${base}/${uploadId}`, {
93
+ method: "POST",
94
+ headers: { ...auth, "Content-Type": "application/json" },
95
+ body: JSON.stringify({ parts, mimeType: opts.contentType }),
96
+ signal: opts.signal,
97
+ });
98
+ if (done.status === 614) return { key: ticket.key, existed: true, size };
99
+ await ok(done, "完成分片上传");
100
+ return { key: ticket.key, existed: false, size };
101
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": ["ES2023"],
7
+ "strict": true,
8
+ "noUnusedLocals": true,
9
+ "declaration": true,
10
+ "declarationMap": true,
11
+ "outDir": "dist",
12
+ "rootDir": "src",
13
+ "skipLibCheck": true
14
+ },
15
+ "include": ["src"]
16
+ }
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.