@suphark/files-client 0.0.0-stage → 0.3.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/README.md +97 -2
- package/dist/chunk-5VLU7KXC.js +169 -0
- package/dist/chunk-YZSJOMM6.js +225 -0
- package/dist/index.d.ts +1297 -0
- package/dist/index.js +16 -0
- package/dist/next/index.d.ts +746 -0
- package/dist/next/index.js +186 -0
- package/dist/react/index.d.ts +140 -0
- package/dist/react/index.js +357 -0
- package/dist/upload-D79TA4qU.d.ts +76 -0
- package/package.json +75 -4
package/README.md
CHANGED
|
@@ -1,3 +1,98 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @suphark/files-client
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
SDK ของ Pi Files (`files.suphark.com`) สำหรับ Next.js/React — ไม่มี runtime dependency
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm add @suphark/files-client
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## 1. server client (`@suphark/files-client`)
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { createPiFiles } from "@suphark/files-client";
|
|
13
|
+
|
|
14
|
+
export const piFiles = createPiFiles({
|
|
15
|
+
baseUrl: process.env.PI_FILES_URL!, // https://files.suphark.com
|
|
16
|
+
appKey: process.env.PI_FILES_APP_KEY!, // pfk_… (ownerType APP)
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// อัปโหลดจาก server (Buffer/Blob) ครบวงจร
|
|
20
|
+
const file = await piFiles.upload(buffer, {
|
|
21
|
+
filename: "รายงาน.pdf",
|
|
22
|
+
mimeType: "application/pdf",
|
|
23
|
+
sizeBytes: buffer.byteLength,
|
|
24
|
+
featureKey: "REPORT",
|
|
25
|
+
reference: { featureKey: "REPORT", entityType: "Project", entityId: "p1", role: "main" },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
await piFiles.references.attach(file.id, {
|
|
29
|
+
featureKey: "REPORT",
|
|
30
|
+
entityType: "Project",
|
|
31
|
+
entityId: "p2",
|
|
32
|
+
role: "copy",
|
|
33
|
+
});
|
|
34
|
+
const mine = await piFiles.references.byEntity("Project", "p1");
|
|
35
|
+
const { url } = await piFiles.files.contentUrl(file.id, { download: true }); // presigned อายุสั้น
|
|
36
|
+
await piFiles.files.delete(file.id); // 409 FILE_REFERENCED ถ้ายังมี reference
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`piFiles.withUser(accessToken)` → client เดียวกันแต่ใช้ Pi Auth JWT ของผู้ใช้ (ไฟล์ `ownerType USER`)
|
|
40
|
+
|
|
41
|
+
### linked source (โฟลเดอร์ Google Drive เดิม) — 0.3.0
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
const src = await piFiles.sources.link({ folder: "https://drive.google.com/drive/folders/<id>" });
|
|
45
|
+
const tree = await piFiles.folders.contents(src.folderId, { recursive: true }); // relativePath, md5, modifiedTime, providerFileId
|
|
46
|
+
const set = await piFiles.folders.ensurePath(src.folderId, ["01_Docs", "ชุด A"]); // สร้างใน Drive จริง
|
|
47
|
+
const access = await piFiles.files.access(fileId); // ใครเปิดได้ใน Drive + ทำลิงก์สาธารณะได้ไหม
|
|
48
|
+
```
|
|
49
|
+
webhook `file.created|updated|moved|deleted|missing`, `folder.*` มี `source.ancestorFolderIds` — ดู `docs/guides/connect-app.md` §6.1
|
|
50
|
+
|
|
51
|
+
## 2. Next.js route handlers (`@suphark/files-client/next`)
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
// src/lib/pi-files.ts
|
|
55
|
+
import { createPiFiles, createPiFilesNext } from "@suphark/files-client/next";
|
|
56
|
+
export const piFiles = createPiFiles({ baseUrl, appKey });
|
|
57
|
+
export const piFilesNext = createPiFilesNext({ client: piFiles });
|
|
58
|
+
|
|
59
|
+
// app/api/files/uploads/[[...path]]/route.ts — proxy อัปโหลด (browser ไม่ถือ key)
|
|
60
|
+
export const { POST, DELETE } = piFilesNext.handlers.uploads({
|
|
61
|
+
authorize: async (request, action) => {
|
|
62
|
+
const user = await piAuth.getCurrentUser();
|
|
63
|
+
if (!user) return null; // 401
|
|
64
|
+
if (action.kind !== "create") return {};
|
|
65
|
+
return { input: { featureKey: "REPORT", onBehalfOfUserId: user.id, reference: {...} } };
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// app/api/files/[id]/content/route.ts — URL same-origin ที่ browser ใช้
|
|
70
|
+
export const GET = piFilesNext.handlers.content({
|
|
71
|
+
strategy: "redirect", // หรือ "stream" (Range passthrough)
|
|
72
|
+
resolve: async (request, params) => {
|
|
73
|
+
const user = await piAuth.getCurrentUser();
|
|
74
|
+
if (!user) return new Response(null, { status: 401 });
|
|
75
|
+
if (!(await canView(user, params.id))) return null; // 404
|
|
76
|
+
return { fileId: String(params.id), download: request.url.includes("download=1") };
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## 3. React (`@suphark/files-client/react`)
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
"use client";
|
|
85
|
+
import { useUpload, useUploadQueue } from "@suphark/files-client/react";
|
|
86
|
+
|
|
87
|
+
const { upload, cancel, status, progress, error } = useUpload({ endpoint: "/api/files/uploads" });
|
|
88
|
+
await upload(file); // single PUT หรือ multipart (8 MiB/part, 3 ขนาน, resume หลัง refresh)
|
|
89
|
+
|
|
90
|
+
const { items, enqueue, cancel } = useUploadQueue({ endpoint: "/api/files/uploads", parallel: 2 });
|
|
91
|
+
enqueue(files);
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`endpoint` มีรูปแบบเดียวกับ `/api/v1/uploads` ของ Pi Files: `POST` (ticket), `POST /:id/parts`, `POST /:id/complete`, `DELETE /:id`
|
|
95
|
+
|
|
96
|
+
## Errors
|
|
97
|
+
|
|
98
|
+
ทุก method โยน `PiFilesError { status, code, message, problem }` — code เช่น `UNAUTHENTICATED`, `PERMISSION_DENIED`, `NOT_FOUND`, `QUOTA_EXCEEDED`, `UNSUPPORTED_MEDIA_TYPE`, `PAYLOAD_TOO_LARGE`, `FILE_REFERENCED`, `SIZE_MISMATCH`, `UPLOAD_INCOMPLETE`, `ABORTED`
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import {
|
|
2
|
+
performUpload,
|
|
3
|
+
request
|
|
4
|
+
} from "./chunk-YZSJOMM6.js";
|
|
5
|
+
|
|
6
|
+
// src/core/client.ts
|
|
7
|
+
var q = (o) => o ?? {};
|
|
8
|
+
function createPiFiles(options) {
|
|
9
|
+
const o = options;
|
|
10
|
+
const call = (path, init) => request(o, path, init);
|
|
11
|
+
const uploads = {
|
|
12
|
+
create: (input) => call("/api/v1/uploads", { method: "POST", body: input }),
|
|
13
|
+
partUrls: (uploadId, partNumbers) => call(`/api/v1/uploads/${uploadId}/parts`, {
|
|
14
|
+
method: "POST",
|
|
15
|
+
body: { partNumbers }
|
|
16
|
+
}),
|
|
17
|
+
complete: (uploadId, parts) => call(`/api/v1/uploads/${uploadId}/complete`, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
body: { parts }
|
|
20
|
+
}),
|
|
21
|
+
abort: (uploadId) => call(`/api/v1/uploads/${uploadId}`, { method: "DELETE" })
|
|
22
|
+
};
|
|
23
|
+
const uploadApi = {
|
|
24
|
+
partUrls: uploads.partUrls,
|
|
25
|
+
complete: uploads.complete,
|
|
26
|
+
abort: uploads.abort
|
|
27
|
+
};
|
|
28
|
+
const files = {
|
|
29
|
+
get: (id) => call(`/api/v1/files/${id}`),
|
|
30
|
+
/** ตำแหน่งจริง + สิทธิ์ที่ต้นทาง (Google Drive) + แชร์สาธารณะได้ไหม — ใช้ตอบ "ไฟล์นี้ส่งให้ใครได้บ้าง" */
|
|
31
|
+
access: (id) => call(`/api/v1/files/${id}/access`),
|
|
32
|
+
list: (query) => call("/api/v1/files", {
|
|
33
|
+
query: q(query)
|
|
34
|
+
}),
|
|
35
|
+
search: (query) => call("/api/v1/search", {
|
|
36
|
+
query: q(query)
|
|
37
|
+
}),
|
|
38
|
+
update: (id, patch) => call(`/api/v1/files/${id}`, { method: "PATCH", body: patch }),
|
|
39
|
+
trash: (id) => call(`/api/v1/files/${id}`, { method: "DELETE" }),
|
|
40
|
+
delete: (id) => call(`/api/v1/files/${id}`, { method: "DELETE", query: { permanent: 1 } }),
|
|
41
|
+
restore: (id) => call(`/api/v1/files/${id}/restore`, { method: "POST" }),
|
|
42
|
+
batch: (input) => call("/api/v1/files/batch", { method: "POST", body: input }),
|
|
43
|
+
/** URL ที่ browser ใช้ได้ทันที (อายุสั้น) — server ของ consumer เรียกแล้วส่งต่อ/redirect */
|
|
44
|
+
contentUrl: (id, opts = {}) => call(`/api/v1/files/${id}/content`, {
|
|
45
|
+
query: { mode: "url", download: opts.download ? 1 : void 0, ttl: opts.ttl }
|
|
46
|
+
}),
|
|
47
|
+
/** stream ผ่าน Pi Files (รองรับ Range) — คืน Response ดิบให้ proxy ต่อ */
|
|
48
|
+
stream: (id, opts = {}) => call(`/api/v1/files/${id}/content`, {
|
|
49
|
+
raw: true,
|
|
50
|
+
query: { mode: "stream", download: opts.download ? 1 : void 0 },
|
|
51
|
+
headers: opts.range ? { range: opts.range } : void 0,
|
|
52
|
+
signal: opts.signal
|
|
53
|
+
}),
|
|
54
|
+
/** path relative ต่อ baseUrl สำหรับ mode=redirect (ใช้ในลิงก์ที่ browser มี token/cookie ของ Pi Files) */
|
|
55
|
+
contentPath: (id, opts = {}) => `/api/v1/files/${id}/content${opts.download ? "?download=1" : ""}`,
|
|
56
|
+
/**
|
|
57
|
+
* ภาพตัวอย่าง webp (sm 256px / lg 1024px) แบบ stream — ใช้ฝั่ง server ของ consumer
|
|
58
|
+
* ฝั่ง browser ของ consumer ใช้ `handlers.preview` ของ `/next`; ไฟล์ที่ไม่มีตัวอย่าง (`previewPath` null) ตอบ 404
|
|
59
|
+
*/
|
|
60
|
+
preview: (id, opts = {}) => call(`/api/v1/files/${id}/preview`, {
|
|
61
|
+
raw: true,
|
|
62
|
+
query: { size: opts.size },
|
|
63
|
+
signal: opts.signal
|
|
64
|
+
}),
|
|
65
|
+
/** ticket สำหรับอัปโหลดเนื้อหาใหม่ทับไฟล์เดิม (id/ชื่อ/แชร์/reference คงเดิม) — ใช้ `replace()` ครบวงจรแทนได้ */
|
|
66
|
+
replace: (id, input) => call(`/api/v1/files/${id}/replace`, { method: "POST", body: input }),
|
|
67
|
+
/** ฉบับปัจจุบัน + เวอร์ชันเก่า (ใหม่ → เก่า) */
|
|
68
|
+
versions: (id) => call(`/api/v1/files/${id}/versions`),
|
|
69
|
+
/** เวอร์ชันเก่ากลับมาเป็นฉบับปัจจุบัน (ฉบับปัจจุบันเดิมเก็บเป็นประวัติ) */
|
|
70
|
+
restoreVersion: (id, versionNo) => call(`/api/v1/files/${id}/versions/${versionNo}/restore`, { method: "POST" }),
|
|
71
|
+
deleteVersion: (id, versionNo) => call(`/api/v1/files/${id}/versions/${versionNo}`, { method: "DELETE" }),
|
|
72
|
+
versionContentUrl: (id, versionNo, opts = {}) => call(`/api/v1/files/${id}/versions/${versionNo}/content`, {
|
|
73
|
+
query: { mode: "url", download: opts.download ? 1 : void 0, ttl: opts.ttl }
|
|
74
|
+
})
|
|
75
|
+
};
|
|
76
|
+
const references = {
|
|
77
|
+
attach: (fileId, ref) => call(`/api/v1/files/${fileId}/references`, { method: "POST", body: ref }),
|
|
78
|
+
detach: (fileId, ref) => call(`/api/v1/files/${fileId}/references`, {
|
|
79
|
+
method: "DELETE",
|
|
80
|
+
body: ref
|
|
81
|
+
}),
|
|
82
|
+
listOf: (fileId) => call(`/api/v1/files/${fileId}/references`),
|
|
83
|
+
/** ย้าย reference แบบ atomic เช่น draft → ระเบียนจริง (ไฟล์ไม่กลายเป็นกำพร้าระหว่างทาง) */
|
|
84
|
+
move: (fileId, from, to) => call(`/api/v1/files/${fileId}/references/move`, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
body: { from, to }
|
|
87
|
+
}),
|
|
88
|
+
byEntity: (entityType, entityId, featureKey) => call("/api/v1/references", {
|
|
89
|
+
query: { entityType, entityId, featureKey }
|
|
90
|
+
})
|
|
91
|
+
};
|
|
92
|
+
const grant = (fileId, input = {}) => call(`/api/v1/files/${fileId}/grants`, { method: "POST", body: input });
|
|
93
|
+
const manifest = {
|
|
94
|
+
sync: (input) => call("/api/v1/app/manifest", { method: "PUT", body: input })
|
|
95
|
+
};
|
|
96
|
+
const migration = {
|
|
97
|
+
/** รับ object ที่มีอยู่แล้วใน bucket เข้าทะเบียน ณ ที่เดิม (ไม่คัดลอก) — idempotent ด้วย externalSource+externalId */
|
|
98
|
+
adopt: (input) => call("/api/v1/migration/adopt", { method: "POST", body: input })
|
|
99
|
+
};
|
|
100
|
+
const folders = {
|
|
101
|
+
get: (id = "root") => call(`/api/v1/folders/${id}`),
|
|
102
|
+
tree: (root, depth) => call("/api/v1/folders/tree", { query: { root, depth } }),
|
|
103
|
+
create: (input) => call("/api/v1/folders", { method: "POST", body: input }),
|
|
104
|
+
update: (id, patch) => call(`/api/v1/folders/${id}`, { method: "PATCH", body: patch }),
|
|
105
|
+
trash: (id) => call(`/api/v1/folders/${id}`, { method: "DELETE" }),
|
|
106
|
+
restore: (id) => call(`/api/v1/folders/${id}/restore`, { method: "POST" }),
|
|
107
|
+
/** ไฟล์ + โฟลเดอร์ใต้โฟลเดอร์ (recursive) พร้อม relativePath/md5/modifiedTime/providerFileId — ใช้ทำ fingerprint */
|
|
108
|
+
contents: (id, opts = {}) => call(`/api/v1/folders/${id}/contents`, {
|
|
109
|
+
query: { recursive: opts.recursive ? "1" : void 0 }
|
|
110
|
+
}),
|
|
111
|
+
/** หา/สร้างโฟลเดอร์ตามชื่อ (ใต้ linked source = สร้างใน Google Drive จริง) */
|
|
112
|
+
ensurePath: (id, names) => call(`/api/v1/folders/${id}/ensure-path`, { method: "POST", body: { names } })
|
|
113
|
+
};
|
|
114
|
+
const sources = {
|
|
115
|
+
/** ผูก (idempotent — ผูกซ้ำ = source เดิม) แล้ว sync ทั้งต้นทันที */
|
|
116
|
+
link: (input) => call("/api/v1/sources", { method: "POST", body: input }),
|
|
117
|
+
list: () => call("/api/v1/sources"),
|
|
118
|
+
get: (id) => call(`/api/v1/sources/${id}`),
|
|
119
|
+
/** sync ทั้งต้นตอนนี้ (ปกติ Pi Files ตามการเปลี่ยนแปลงใน Drive ให้ทุก 5 นาที + webhook) */
|
|
120
|
+
sync: (id) => call(`/api/v1/sources/${id}/sync`, { method: "POST" }),
|
|
121
|
+
/** เลิกผูก — ไม่แตะ Google Drive; ไฟล์ที่ยังมี reference = 409 SOURCE_REFERENCED */
|
|
122
|
+
unlink: (id) => call(`/api/v1/sources/${id}`, {
|
|
123
|
+
method: "DELETE"
|
|
124
|
+
})
|
|
125
|
+
};
|
|
126
|
+
const quota = {
|
|
127
|
+
get: (scope = "user") => call("/api/v1/quota", { query: { scope } })
|
|
128
|
+
};
|
|
129
|
+
async function upload(source, input, opts = {}) {
|
|
130
|
+
const ticket = await uploads.create(input);
|
|
131
|
+
try {
|
|
132
|
+
return await performUpload(uploadApi, ticket, source, input.mimeType, opts);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
await uploads.abort(ticket.uploadId).catch(() => {
|
|
135
|
+
});
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async function replace(fileId, source, input, opts = {}) {
|
|
140
|
+
const ticket = await files.replace(fileId, input);
|
|
141
|
+
try {
|
|
142
|
+
return await performUpload(uploadApi, ticket, source, input.mimeType, opts);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
await uploads.abort(ticket.uploadId).catch(() => {
|
|
145
|
+
});
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
options: o,
|
|
151
|
+
uploads,
|
|
152
|
+
uploadApi,
|
|
153
|
+
upload,
|
|
154
|
+
replace,
|
|
155
|
+
files: { ...files, grant },
|
|
156
|
+
references,
|
|
157
|
+
folders,
|
|
158
|
+
sources,
|
|
159
|
+
quota,
|
|
160
|
+
manifest,
|
|
161
|
+
migration,
|
|
162
|
+
/** client เดิมแต่ใช้ token ของผู้ใช้แทน appKey (user-context ต่อ request) */
|
|
163
|
+
withUser: (token) => createPiFiles({ ...o, getUserToken: () => token })
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export {
|
|
168
|
+
createPiFiles
|
|
169
|
+
};
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// src/core/http.ts
|
|
2
|
+
var PiFilesError = class extends Error {
|
|
3
|
+
constructor(status, code, message, problem) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.status = status;
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.problem = problem;
|
|
8
|
+
this.name = "PiFilesError";
|
|
9
|
+
}
|
|
10
|
+
status;
|
|
11
|
+
code;
|
|
12
|
+
problem;
|
|
13
|
+
};
|
|
14
|
+
async function resolveAuthHeader(opts) {
|
|
15
|
+
const token = opts.getUserToken ? await opts.getUserToken() : null;
|
|
16
|
+
if (token) return { authorization: `Bearer ${token}` };
|
|
17
|
+
if (opts.appKey) return { authorization: `Bearer ${opts.appKey}` };
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
function buildUrl(baseUrl, path, query) {
|
|
21
|
+
const url = new URL(path.replace(/^\//, ""), `${baseUrl.replace(/\/?$/, "/")}`);
|
|
22
|
+
for (const [k, v] of Object.entries(query ?? {})) {
|
|
23
|
+
if (v !== void 0) url.searchParams.set(k, String(v));
|
|
24
|
+
}
|
|
25
|
+
return url.toString();
|
|
26
|
+
}
|
|
27
|
+
var MAX_RATE_LIMIT_RETRIES = 3;
|
|
28
|
+
var MAX_RETRY_WAIT_MS = 3e4;
|
|
29
|
+
async function fetchWithRateLimitRetry(doFetch, url, init) {
|
|
30
|
+
for (let attempt = 0; ; attempt++) {
|
|
31
|
+
const res = await doFetch(url, init);
|
|
32
|
+
if (res.status !== 429 || attempt >= MAX_RATE_LIMIT_RETRIES) return res;
|
|
33
|
+
const waitMs = Math.min(MAX_RETRY_WAIT_MS, await retryAfterSeconds(res) * 1e3);
|
|
34
|
+
await sleep(waitMs, init.signal ?? void 0);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async function retryAfterSeconds(res) {
|
|
38
|
+
const header = Number(res.headers.get("retry-after"));
|
|
39
|
+
if (header > 0) return header;
|
|
40
|
+
const body = await res.json().catch(() => null);
|
|
41
|
+
return body?.retryAfterSeconds && body.retryAfterSeconds > 0 ? body.retryAfterSeconds : 2;
|
|
42
|
+
}
|
|
43
|
+
function sleep(ms, signal) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
if (signal?.aborted) return reject(signal.reason);
|
|
46
|
+
const timer = setTimeout(resolve, ms);
|
|
47
|
+
signal?.addEventListener(
|
|
48
|
+
"abort",
|
|
49
|
+
() => {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
reject(signal.reason);
|
|
52
|
+
},
|
|
53
|
+
{ once: true }
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
async function parseProblem(res) {
|
|
58
|
+
let problem;
|
|
59
|
+
try {
|
|
60
|
+
const json = await res.json();
|
|
61
|
+
if (json && typeof json === "object" && "status" in json) problem = json;
|
|
62
|
+
} catch {
|
|
63
|
+
}
|
|
64
|
+
return new PiFilesError(
|
|
65
|
+
res.status,
|
|
66
|
+
problem?.code ?? `HTTP_${res.status}`,
|
|
67
|
+
problem?.detail ?? problem?.title ?? `Pi Files \u0E15\u0E2D\u0E1A ${res.status}`,
|
|
68
|
+
problem
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
async function request(opts, path, init = {}) {
|
|
72
|
+
const doFetch = opts.fetch ?? fetch;
|
|
73
|
+
const headers = {
|
|
74
|
+
accept: "application/json",
|
|
75
|
+
...await resolveAuthHeader(opts),
|
|
76
|
+
...init.headers ?? {}
|
|
77
|
+
};
|
|
78
|
+
if (init.body !== void 0) headers["content-type"] = "application/json";
|
|
79
|
+
const res = await fetchWithRateLimitRetry(doFetch, buildUrl(opts.baseUrl, path, init.query), {
|
|
80
|
+
method: init.method ?? "GET",
|
|
81
|
+
headers,
|
|
82
|
+
body: init.body === void 0 ? void 0 : JSON.stringify(init.body),
|
|
83
|
+
signal: init.signal,
|
|
84
|
+
credentials: opts.credentials,
|
|
85
|
+
redirect: init.manualRedirect ? "manual" : "follow",
|
|
86
|
+
cache: "no-store"
|
|
87
|
+
});
|
|
88
|
+
if (init.raw) return res;
|
|
89
|
+
if (!res.ok) throw await parseProblem(res);
|
|
90
|
+
if (res.status === 204) return void 0;
|
|
91
|
+
const text = await res.text();
|
|
92
|
+
return text ? JSON.parse(text) : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// src/core/upload.ts
|
|
96
|
+
function toBlob(source, type) {
|
|
97
|
+
if (source instanceof Blob) return source;
|
|
98
|
+
const bytes = source instanceof ArrayBuffer ? new Uint8Array(source) : source;
|
|
99
|
+
return new Blob([bytes], { type });
|
|
100
|
+
}
|
|
101
|
+
function hasXhr() {
|
|
102
|
+
return typeof XMLHttpRequest !== "undefined";
|
|
103
|
+
}
|
|
104
|
+
function etagFromBody(text) {
|
|
105
|
+
try {
|
|
106
|
+
const parsed = JSON.parse(text);
|
|
107
|
+
return typeof parsed.etag === "string" ? parsed.etag : null;
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function putBytes(url, body, headers, onProgress, signal, method = "PUT") {
|
|
113
|
+
if (!hasXhr()) {
|
|
114
|
+
return fetch(url, { method, headers, body, signal }).then(async (res) => {
|
|
115
|
+
if (!res.ok)
|
|
116
|
+
throw new PiFilesError(res.status, "STORAGE_PUT_FAILED", `storage \u0E15\u0E2D\u0E1A ${res.status}`);
|
|
117
|
+
onProgress(body.size);
|
|
118
|
+
return { etag: res.headers.get("etag") ?? etagFromBody(await res.text()) };
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
const xhr = new XMLHttpRequest();
|
|
123
|
+
xhr.open(method, url);
|
|
124
|
+
for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v);
|
|
125
|
+
xhr.upload.onprogress = (e) => onProgress(e.loaded);
|
|
126
|
+
xhr.onload = () => {
|
|
127
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
128
|
+
resolve({ etag: xhr.getResponseHeader("ETag") ?? etagFromBody(xhr.responseText) });
|
|
129
|
+
} else
|
|
130
|
+
reject(new PiFilesError(xhr.status, "STORAGE_PUT_FAILED", `storage \u0E15\u0E2D\u0E1A ${xhr.status}`));
|
|
131
|
+
};
|
|
132
|
+
xhr.onerror = () => reject(new PiFilesError(0, "NETWORK", "\u0E2D\u0E31\u0E1B\u0E42\u0E2B\u0E25\u0E14\u0E44\u0E1B storage \u0E44\u0E21\u0E48\u0E2A\u0E33\u0E40\u0E23\u0E47\u0E08 (\u0E15\u0E23\u0E27\u0E08 CORS \u0E02\u0E2D\u0E07 bucket)"));
|
|
133
|
+
xhr.onabort = () => reject(new PiFilesError(0, "ABORTED", "\u0E22\u0E01\u0E40\u0E25\u0E34\u0E01"));
|
|
134
|
+
signal?.addEventListener("abort", () => xhr.abort(), { once: true });
|
|
135
|
+
xhr.send(body);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
async function performUpload(api, ticket, source, mimeType, options = {}) {
|
|
139
|
+
const blob = toBlob(source, mimeType);
|
|
140
|
+
const total = blob.size;
|
|
141
|
+
const report = (loaded) => options.onProgress?.({ loaded, total, fraction: total ? Math.min(loaded / total, 1) : 1 });
|
|
142
|
+
if (ticket.kind === "put") {
|
|
143
|
+
await putBytes(ticket.url, blob, ticket.headers, report, options.signal);
|
|
144
|
+
return api.complete(ticket.uploadId);
|
|
145
|
+
}
|
|
146
|
+
if (ticket.kind === "multipart") {
|
|
147
|
+
const done = new Map(
|
|
148
|
+
(options.completedParts ?? []).map((p) => [p.partNumber, p.etag])
|
|
149
|
+
);
|
|
150
|
+
const loadedByPart = /* @__PURE__ */ new Map();
|
|
151
|
+
for (const n of done.keys()) loadedByPart.set(n, partSize(n, ticket.partSizeBytes, total));
|
|
152
|
+
const sum = () => [...loadedByPart.values()].reduce((a, b) => a + b, 0);
|
|
153
|
+
report(sum());
|
|
154
|
+
const pending = Array.from({ length: ticket.partCount }, (_, i) => i + 1).filter(
|
|
155
|
+
(n) => !done.has(n)
|
|
156
|
+
);
|
|
157
|
+
let cursor = 0;
|
|
158
|
+
const worker = async () => {
|
|
159
|
+
while (cursor < pending.length) {
|
|
160
|
+
const partNumber = pending[cursor++];
|
|
161
|
+
const start = (partNumber - 1) * ticket.partSizeBytes;
|
|
162
|
+
const chunk = blob.slice(start, Math.min(start + ticket.partSizeBytes, total), mimeType);
|
|
163
|
+
const { parts } = await api.partUrls(ticket.uploadId, [partNumber]);
|
|
164
|
+
const part = parts[0];
|
|
165
|
+
if (!part?.url) throw new PiFilesError(500, "NO_PART_URL", "\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49 URL \u0E02\u0E2D\u0E07 part");
|
|
166
|
+
let attempt = 0;
|
|
167
|
+
for (; ; ) {
|
|
168
|
+
try {
|
|
169
|
+
const { etag } = await putBytes(
|
|
170
|
+
part.url,
|
|
171
|
+
chunk,
|
|
172
|
+
part.headers ?? {},
|
|
173
|
+
(loaded) => {
|
|
174
|
+
loadedByPart.set(partNumber, loaded);
|
|
175
|
+
report(sum());
|
|
176
|
+
},
|
|
177
|
+
options.signal,
|
|
178
|
+
part.method ?? "PUT"
|
|
179
|
+
);
|
|
180
|
+
if (!etag) {
|
|
181
|
+
throw new PiFilesError(
|
|
182
|
+
0,
|
|
183
|
+
"NO_ETAG",
|
|
184
|
+
"storage \u0E44\u0E21\u0E48\u0E2A\u0E48\u0E07 ETag (\u0E15\u0E31\u0E49\u0E07 CORS ExposeHeaders: ETag)"
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
const clean = etag.replaceAll('"', "");
|
|
188
|
+
done.set(partNumber, clean);
|
|
189
|
+
options.onPartDone?.({ partNumber, etag: clean });
|
|
190
|
+
break;
|
|
191
|
+
} catch (error) {
|
|
192
|
+
const code = error.code;
|
|
193
|
+
if (++attempt >= 3 || code === "ABORTED" || code === "NO_ETAG") throw error;
|
|
194
|
+
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
const concurrency = Math.min(options.concurrency ?? 3, Math.max(pending.length, 1));
|
|
200
|
+
await Promise.all(Array.from({ length: concurrency }, worker));
|
|
201
|
+
return api.complete(
|
|
202
|
+
ticket.uploadId,
|
|
203
|
+
[...done.entries()].map(([partNumber, etag]) => ({ partNumber, etag }))
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
throw new PiFilesError(500, "UNKNOWN_TICKET", `ticket \u0E0A\u0E19\u0E34\u0E14\u0E17\u0E35\u0E48\u0E44\u0E21\u0E48\u0E23\u0E39\u0E49\u0E08\u0E31\u0E01: ${String(ticket)}`);
|
|
207
|
+
}
|
|
208
|
+
function partSize(partNumber, size, total) {
|
|
209
|
+
const start = (partNumber - 1) * size;
|
|
210
|
+
return Math.max(0, Math.min(size, total - start));
|
|
211
|
+
}
|
|
212
|
+
function fileFingerprint(file) {
|
|
213
|
+
return `${file.name}|${file.size}|${file.lastModified ?? 0}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export {
|
|
217
|
+
PiFilesError,
|
|
218
|
+
resolveAuthHeader,
|
|
219
|
+
buildUrl,
|
|
220
|
+
fetchWithRateLimitRetry,
|
|
221
|
+
request,
|
|
222
|
+
putBytes,
|
|
223
|
+
performUpload,
|
|
224
|
+
fileFingerprint
|
|
225
|
+
};
|