@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.
@@ -0,0 +1,186 @@
1
+ import {
2
+ createPiFiles
3
+ } from "../chunk-5VLU7KXC.js";
4
+ import {
5
+ PiFilesError,
6
+ buildUrl,
7
+ resolveAuthHeader
8
+ } from "../chunk-YZSJOMM6.js";
9
+
10
+ // src/core/webhooks.ts
11
+ import { createHmac, timingSafeEqual } from "crypto";
12
+ function verifyWebhookSignature(body, header, secret, { toleranceSeconds = 300, now = Date.now() } = {}) {
13
+ if (!header) return false;
14
+ const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
15
+ const t = Number(parts.t);
16
+ if (!Number.isFinite(t) || !parts.v1) return false;
17
+ if (Math.abs(now / 1e3 - t) > toleranceSeconds) return false;
18
+ const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.${body}`).digest("hex"));
19
+ const actual = Buffer.from(parts.v1);
20
+ return expected.length === actual.length && timingSafeEqual(expected, actual);
21
+ }
22
+
23
+ // src/next/index.ts
24
+ function createPiFilesNext(config) {
25
+ const { client } = config;
26
+ function content(opts) {
27
+ return async (request, ctx) => {
28
+ const params = await ctx.params;
29
+ const target = await opts.resolve(request, params);
30
+ if (target === null) return new Response(null, { status: 404 });
31
+ if (target instanceof Response) return target;
32
+ const strategy = opts.strategy ?? "redirect";
33
+ try {
34
+ if (strategy === "redirect") {
35
+ const { url } = await client.files.contentUrl(target.fileId, {
36
+ download: target.download
37
+ });
38
+ return new Response(null, {
39
+ status: 302,
40
+ headers: { location: url, "cache-control": "private, no-store" }
41
+ });
42
+ }
43
+ const upstream = await client.files.stream(target.fileId, {
44
+ range: request.headers.get("range"),
45
+ download: target.download,
46
+ signal: request.signal
47
+ });
48
+ if (!upstream.ok && upstream.status !== 206) {
49
+ return new Response(null, { status: upstream.status === 404 ? 404 : 502 });
50
+ }
51
+ const headers = new Headers();
52
+ for (const name of [
53
+ "content-type",
54
+ "content-length",
55
+ "content-range",
56
+ "accept-ranges",
57
+ "etag",
58
+ "last-modified",
59
+ "content-disposition"
60
+ ]) {
61
+ const v = upstream.headers.get(name);
62
+ if (v) headers.set(name, v);
63
+ }
64
+ headers.set("cache-control", "private, max-age=3600");
65
+ return new Response(upstream.body, { status: upstream.status, headers });
66
+ } catch (error) {
67
+ if (error instanceof PiFilesError && error.status === 404)
68
+ return new Response(null, { status: 404 });
69
+ console.error("[pi-files] content proxy", error);
70
+ return new Response(null, { status: 502 });
71
+ }
72
+ };
73
+ }
74
+ function preview(opts) {
75
+ return async (request, ctx) => {
76
+ const params = await ctx.params;
77
+ const target = await opts.resolve(request, params);
78
+ if (target === null) return new Response(null, { status: 404 });
79
+ if (target instanceof Response) return target;
80
+ const size = new URL(request.url).searchParams.get("size") === "lg" ? "lg" : "sm";
81
+ try {
82
+ const upstream = await client.files.preview(target.fileId, {
83
+ size,
84
+ signal: request.signal
85
+ });
86
+ if (!upstream.ok)
87
+ return new Response(null, { status: upstream.status === 404 ? 404 : 502 });
88
+ const headers = new Headers({ "content-type": "image/webp" });
89
+ for (const name of ["content-length", "cache-control"]) {
90
+ const v = upstream.headers.get(name);
91
+ if (v) headers.set(name, v);
92
+ }
93
+ return new Response(upstream.body, { status: 200, headers });
94
+ } catch (error) {
95
+ if (error instanceof PiFilesError && error.status === 404)
96
+ return new Response(null, { status: 404 });
97
+ console.error("[pi-files] preview proxy", error);
98
+ return new Response(null, { status: 502 });
99
+ }
100
+ };
101
+ }
102
+ function uploads(opts) {
103
+ async function handle(request, ctx) {
104
+ const params = await ctx.params;
105
+ const raw = params.path;
106
+ const path = Array.isArray(raw) ? raw : raw ? [raw] : [];
107
+ const [uploadId, sub] = path;
108
+ const json = (body, status = 200) => new Response(JSON.stringify(body), {
109
+ status,
110
+ headers: { "content-type": "application/json", "cache-control": "no-store" }
111
+ });
112
+ const proxyError = (error) => {
113
+ if (error instanceof PiFilesError) {
114
+ const res = json(
115
+ error.problem ?? { code: error.code, detail: error.message },
116
+ error.status
117
+ );
118
+ const retryAfter = error.problem?.retryAfterSeconds;
119
+ if (error.status === 429 && retryAfter)
120
+ res.headers.set("retry-after", String(retryAfter));
121
+ return res;
122
+ }
123
+ console.error("[pi-files] uploads proxy", error);
124
+ return json({ code: "INTERNAL", detail: "Pi Files proxy error" }, 502);
125
+ };
126
+ try {
127
+ if (request.method === "POST" && path.length === 0) {
128
+ const body = await request.json().catch(() => ({}));
129
+ const auth = await opts.authorize(request, { kind: "create", body });
130
+ if (auth === null) return json({ code: "UNAUTHENTICATED" }, 401);
131
+ if (auth instanceof Response) return auth;
132
+ const uploadOrigin = request.headers.get("origin") ?? void 0;
133
+ const input = { uploadOrigin, ...body, ...auth.input };
134
+ return json(await client.uploads.create(input), 201);
135
+ }
136
+ if (uploadId === "replace" && sub && request.method === "POST" && path.length === 2) {
137
+ const body = await request.json().catch(() => ({}));
138
+ const auth = await opts.authorize(request, { kind: "replace", fileId: sub, body });
139
+ if (auth === null) return json({ code: "UNAUTHENTICATED" }, 401);
140
+ if (auth instanceof Response) return auth;
141
+ const uploadOrigin = request.headers.get("origin") ?? void 0;
142
+ const input = { uploadOrigin, ...body };
143
+ return json(await client.files.replace(sub, input), 201);
144
+ }
145
+ if (uploadId && request.method === "POST" && sub === "parts") {
146
+ const auth = await opts.authorize(request, { kind: "parts", uploadId });
147
+ if (auth === null) return json({ code: "UNAUTHENTICATED" }, 401);
148
+ if (auth instanceof Response) return auth;
149
+ const body = await request.json();
150
+ return json(await client.uploads.partUrls(uploadId, body.partNumbers));
151
+ }
152
+ if (uploadId && request.method === "POST" && sub === "complete") {
153
+ const auth = await opts.authorize(request, { kind: "complete", uploadId });
154
+ if (auth === null) return json({ code: "UNAUTHENTICATED" }, 401);
155
+ if (auth instanceof Response) return auth;
156
+ const body = await request.json().catch(() => ({}));
157
+ return json(await client.uploads.complete(uploadId, body.parts));
158
+ }
159
+ if (uploadId && request.method === "DELETE" && !sub) {
160
+ const auth = await opts.authorize(request, { kind: "abort", uploadId });
161
+ if (auth === null) return json({ code: "UNAUTHENTICATED" }, 401);
162
+ if (auth instanceof Response) return auth;
163
+ await client.uploads.abort(uploadId);
164
+ return json({ ok: true });
165
+ }
166
+ return json({ code: "NOT_FOUND" }, 404);
167
+ } catch (error) {
168
+ return proxyError(error);
169
+ }
170
+ }
171
+ return { POST: handle, DELETE: handle };
172
+ }
173
+ return {
174
+ client,
175
+ handlers: { content, preview, uploads },
176
+ /** header สำหรับเรียก Pi Files เองจาก server code ของ consumer */
177
+ authHeader: () => resolveAuthHeader(client.options),
178
+ url: (path) => buildUrl(client.options.baseUrl, path)
179
+ };
180
+ }
181
+ export {
182
+ PiFilesError,
183
+ createPiFiles,
184
+ createPiFilesNext,
185
+ verifyWebhookSignature
186
+ };
@@ -0,0 +1,140 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+ import { FileAssetDto, CreateUploadInput, UploadTicket } from '@pi/files-contracts';
4
+ import { U as UploadProgress, P as PiFilesError } from '../upload-D79TA4qU.js';
5
+ import { UploadApi } from '../index.js';
6
+
7
+ /**
8
+ * ฝั่ง browser คุยกับ "endpoint" ที่มีรูปแบบเดียวกับ Pi Files:
9
+ * POST {endpoint} → ticket
10
+ * POST {endpoint}/:id/parts → part URLs
11
+ * POST {endpoint}/:id/complete
12
+ * DELETE {endpoint}/:id
13
+ * endpoint = "/api/files/uploads" ของ consumer (proxy ด้วย app key ผ่าน handlers.uploads)
14
+ * หรือ "/api/v1/uploads" ของ Pi Files เอง (cookie session)
15
+ */
16
+ interface UploadEndpointOptions {
17
+ endpoint: string;
18
+ headers?: Record<string, string> | (() => Record<string, string>);
19
+ credentials?: RequestCredentials;
20
+ }
21
+ declare function PiFilesProvider({ children, ...opts }: UploadEndpointOptions & {
22
+ children: ReactNode;
23
+ }): react.JSX.Element;
24
+ declare function createEndpointApi(opts: UploadEndpointOptions): UploadApi & {
25
+ create: (input: CreateUploadInput) => Promise<UploadTicket>;
26
+ };
27
+ type UploadStatus = "idle" | "uploading" | "done" | "error" | "aborted";
28
+ interface UseUploadOptions extends Partial<UploadEndpointOptions> {
29
+ onProgress?: (p: UploadProgress) => void;
30
+ concurrency?: number;
31
+ }
32
+ /** อัปโหลดไฟล์เดียว พร้อม progress / cancel / resume (multipart ที่ค้างจากรอบก่อน) */
33
+ declare function useUpload(options?: UseUploadOptions): {
34
+ upload: (file: File, input?: Omit<CreateUploadInput, "filename" | "mimeType" | "sizeBytes">) => Promise<{
35
+ id: string;
36
+ filename: string;
37
+ mimeType: string;
38
+ sizeBytes: string;
39
+ status: "UPLOADING" | "ACTIVE" | "ORPHAN_CANDIDATE" | "TRASHED" | "PENDING_DELETE" | "MISSING" | "DELETED" | "ERROR";
40
+ visibility: "PRIVATE" | "TENANT" | "PUBLIC";
41
+ ownerType: "USER" | "APP";
42
+ ownerId: string;
43
+ appKey: string | null;
44
+ folderId: string | null;
45
+ etag: string | null;
46
+ checksumSha256: string | null;
47
+ versionCount: number;
48
+ refCount: number;
49
+ metadata: Record<string, unknown>;
50
+ createdById: string | null;
51
+ createdAt: string;
52
+ updatedAt: string;
53
+ trashedAt: string | null;
54
+ contentPath: string;
55
+ previewPath: string | null;
56
+ publicUrl: string | null;
57
+ storage: {
58
+ bucketKey: string;
59
+ bucketLabel: string | null;
60
+ kind: string;
61
+ providerName: string;
62
+ sourceUrl?: string | null | undefined;
63
+ linked?: boolean | undefined;
64
+ } | null;
65
+ }>;
66
+ cancel: () => void | undefined;
67
+ status: UploadStatus;
68
+ progress: UploadProgress;
69
+ error: PiFilesError | null;
70
+ result: {
71
+ id: string;
72
+ filename: string;
73
+ mimeType: string;
74
+ sizeBytes: string;
75
+ status: "UPLOADING" | "ACTIVE" | "ORPHAN_CANDIDATE" | "TRASHED" | "PENDING_DELETE" | "MISSING" | "DELETED" | "ERROR";
76
+ visibility: "PRIVATE" | "TENANT" | "PUBLIC";
77
+ ownerType: "USER" | "APP";
78
+ ownerId: string;
79
+ appKey: string | null;
80
+ folderId: string | null;
81
+ etag: string | null;
82
+ checksumSha256: string | null;
83
+ versionCount: number;
84
+ refCount: number;
85
+ metadata: Record<string, unknown>;
86
+ createdById: string | null;
87
+ createdAt: string;
88
+ updatedAt: string;
89
+ trashedAt: string | null;
90
+ contentPath: string;
91
+ previewPath: string | null;
92
+ publicUrl: string | null;
93
+ storage: {
94
+ bucketKey: string;
95
+ bucketLabel: string | null;
96
+ kind: string;
97
+ providerName: string;
98
+ sourceUrl?: string | null | undefined;
99
+ linked?: boolean | undefined;
100
+ } | null;
101
+ } | null;
102
+ };
103
+ interface QueueItem {
104
+ id: string;
105
+ file: File;
106
+ status: UploadStatus;
107
+ progress: UploadProgress;
108
+ error: PiFilesError | null;
109
+ result: FileAssetDto | null;
110
+ }
111
+ /** คิวอัปโหลดหลายไฟล์ (ทีละ N ไฟล์) — ใช้กับ upload panel */
112
+ declare function useUploadQueue(options?: UseUploadOptions & {
113
+ parallel?: number;
114
+ }): {
115
+ items: QueueItem[];
116
+ enqueue: (files: File[], input?: Partial<CreateUploadInput>) => string[];
117
+ cancel: (id: string) => void;
118
+ clearFinished: () => void;
119
+ };
120
+ interface UseReplaceOptions extends UseUploadOptions {
121
+ /**
122
+ * URL ที่คืน ticket ของ replace ต่อไฟล์ — ค่าเริ่มต้น: endpoint ของ Pi Files (`…/api/v1/uploads`) → `/api/v1/files/:id/replace`,
123
+ * endpoint อื่น (proxy `handlers.uploads` ของ consumer) → `<endpoint>/replace/:id`
124
+ */
125
+ replaceUrl?: (fileId: string) => string;
126
+ }
127
+ /**
128
+ * อัปโหลดเวอร์ชันใหม่ทับไฟล์เดิม (id/ชื่อ/แชร์/reference คงเดิม; ฉบับเดิมเป็นประวัติ) พร้อม progress / cancel
129
+ * bytes + complete ใช้ endpoint เดียวกับ `useUpload` (ค่าเริ่มต้นของ context)
130
+ */
131
+ declare function useReplace(options?: UseReplaceOptions): {
132
+ replace: (fileId: string, file: File) => Promise<FileAssetDto>;
133
+ cancel: () => void | undefined;
134
+ status: UploadStatus;
135
+ progress: UploadProgress;
136
+ error: PiFilesError | null;
137
+ };
138
+
139
+ export { PiFilesError, PiFilesProvider, UploadProgress, createEndpointApi, useReplace, useUpload, useUploadQueue };
140
+ export type { QueueItem, UploadEndpointOptions, UploadStatus, UseReplaceOptions, UseUploadOptions };
@@ -0,0 +1,357 @@
1
+ "use client";
2
+ import {
3
+ PiFilesError,
4
+ fetchWithRateLimitRetry,
5
+ fileFingerprint,
6
+ performUpload
7
+ } from "../chunk-YZSJOMM6.js";
8
+
9
+ // src/react/index.tsx
10
+ import {
11
+ createContext,
12
+ useCallback,
13
+ useContext,
14
+ useMemo,
15
+ useRef,
16
+ useState
17
+ } from "react";
18
+ import { jsx } from "react/jsx-runtime";
19
+ var Ctx = createContext(null);
20
+ function PiFilesProvider({
21
+ children,
22
+ ...opts
23
+ }) {
24
+ const value = useMemo(() => opts, [opts.endpoint, opts.headers, opts.credentials]);
25
+ return /* @__PURE__ */ jsx(Ctx.Provider, { value, children });
26
+ }
27
+ async function endpointFetch(opts, path, init) {
28
+ const extra = typeof opts.headers === "function" ? opts.headers() : opts.headers ?? {};
29
+ const res = await fetchWithRateLimitRetry(fetch, `${opts.endpoint}${path}`, {
30
+ ...init,
31
+ credentials: opts.credentials ?? "include",
32
+ headers: {
33
+ "content-type": "application/json",
34
+ ...extra,
35
+ ...init.headers
36
+ }
37
+ });
38
+ if (!res.ok) {
39
+ const problem = await res.json().catch(() => ({}));
40
+ throw new PiFilesError(
41
+ res.status,
42
+ problem.code ?? `HTTP_${res.status}`,
43
+ problem.detail ?? problem.title ?? res.statusText
44
+ );
45
+ }
46
+ return await res.json();
47
+ }
48
+ function createEndpointApi(opts) {
49
+ return {
50
+ create: (input) => endpointFetch(opts, "", { method: "POST", body: JSON.stringify(input) }),
51
+ partUrls: (uploadId, partNumbers) => endpointFetch(opts, `/${uploadId}/parts`, {
52
+ method: "POST",
53
+ body: JSON.stringify({ partNumbers })
54
+ }),
55
+ complete: (uploadId, parts) => endpointFetch(opts, `/${uploadId}/complete`, {
56
+ method: "POST",
57
+ body: JSON.stringify({ parts })
58
+ }),
59
+ abort: (uploadId) => endpointFetch(opts, `/${uploadId}`, { method: "DELETE" }).then(() => void 0)
60
+ };
61
+ }
62
+ var RESUME_KEY = "pi-files:resume";
63
+ function loadResume() {
64
+ try {
65
+ return JSON.parse(localStorage.getItem(RESUME_KEY) ?? "{}");
66
+ } catch {
67
+ return {};
68
+ }
69
+ }
70
+ function saveResume(all) {
71
+ try {
72
+ const cutoff = Date.now() - 24 * 60 * 60 * 1e3;
73
+ for (const [k, v] of Object.entries(all)) if (v.savedAt < cutoff) delete all[k];
74
+ localStorage.setItem(RESUME_KEY, JSON.stringify(all));
75
+ } catch {
76
+ }
77
+ }
78
+ function useUpload(options = {}) {
79
+ const ctx = useContext(Ctx);
80
+ const endpoint = options.endpoint ?? ctx?.endpoint;
81
+ if (!endpoint) throw new Error("useUpload \u0E15\u0E49\u0E2D\u0E07\u0E21\u0E35 endpoint (prop \u0E2B\u0E23\u0E37\u0E2D <PiFilesProvider>)");
82
+ const api = useMemo(
83
+ () => createEndpointApi({
84
+ endpoint,
85
+ headers: options.headers ?? ctx?.headers,
86
+ credentials: options.credentials ?? ctx?.credentials
87
+ }),
88
+ [endpoint, options.headers, ctx?.headers, options.credentials, ctx?.credentials]
89
+ );
90
+ const [status, setStatus] = useState("idle");
91
+ const [progress, setProgress] = useState({ loaded: 0, total: 0, fraction: 0 });
92
+ const [error, setError] = useState(null);
93
+ const [result, setResult] = useState(null);
94
+ const abortRef = useRef(null);
95
+ const upload = useCallback(
96
+ async (file, input = {}) => {
97
+ abortRef.current?.abort();
98
+ const controller = new AbortController();
99
+ abortRef.current = controller;
100
+ setStatus("uploading");
101
+ setError(null);
102
+ setResult(null);
103
+ const fp = fileFingerprint(file);
104
+ const resumeAll = loadResume();
105
+ let ticket;
106
+ let completedParts = [];
107
+ const cached = resumeAll[fp];
108
+ if (cached && cached.ticket.expiresAt > (/* @__PURE__ */ new Date()).toISOString()) {
109
+ ticket = cached.ticket;
110
+ completedParts = cached.parts;
111
+ } else {
112
+ ticket = await api.create({
113
+ ...input,
114
+ filename: file.name,
115
+ mimeType: file.type || "application/octet-stream",
116
+ sizeBytes: file.size
117
+ });
118
+ if (ticket.kind === "multipart") {
119
+ resumeAll[fp] = { ticket, parts: [], savedAt: Date.now() };
120
+ saveResume(resumeAll);
121
+ }
122
+ }
123
+ try {
124
+ const asset = await performUpload(
125
+ api,
126
+ ticket,
127
+ file,
128
+ file.type || "application/octet-stream",
129
+ {
130
+ signal: controller.signal,
131
+ concurrency: options.concurrency,
132
+ completedParts,
133
+ onProgress: (p) => {
134
+ setProgress(p);
135
+ options.onProgress?.(p);
136
+ },
137
+ onPartDone: (part) => {
138
+ const all2 = loadResume();
139
+ const rec = all2[fp];
140
+ if (rec) {
141
+ rec.parts = [...rec.parts.filter((x) => x.partNumber !== part.partNumber), part];
142
+ rec.savedAt = Date.now();
143
+ saveResume(all2);
144
+ }
145
+ }
146
+ }
147
+ );
148
+ const all = loadResume();
149
+ delete all[fp];
150
+ saveResume(all);
151
+ setResult(asset);
152
+ setStatus("done");
153
+ return asset;
154
+ } catch (e) {
155
+ const err = e instanceof PiFilesError ? e : new PiFilesError(0, "UNKNOWN", e?.message ?? "\u0E2D\u0E31\u0E1B\u0E42\u0E2B\u0E25\u0E14\u0E44\u0E21\u0E48\u0E2A\u0E33\u0E40\u0E23\u0E47\u0E08");
156
+ if (err.code === "ABORTED") {
157
+ setStatus("aborted");
158
+ await api.abort(ticket.uploadId).catch(() => {
159
+ });
160
+ const all = loadResume();
161
+ delete all[fp];
162
+ saveResume(all);
163
+ } else {
164
+ setStatus("error");
165
+ setError(err);
166
+ }
167
+ throw err;
168
+ }
169
+ },
170
+ [api, options.concurrency, options.onProgress]
171
+ );
172
+ const cancel = useCallback(() => abortRef.current?.abort(), []);
173
+ return { upload, cancel, status, progress, error, result };
174
+ }
175
+ function useUploadQueue(options = {}) {
176
+ const ctx = useContext(Ctx);
177
+ const endpoint = options.endpoint ?? ctx?.endpoint;
178
+ if (!endpoint) throw new Error("useUploadQueue \u0E15\u0E49\u0E2D\u0E07\u0E21\u0E35 endpoint (prop \u0E2B\u0E23\u0E37\u0E2D <PiFilesProvider>)");
179
+ const api = useMemo(
180
+ () => createEndpointApi({
181
+ endpoint,
182
+ headers: options.headers ?? ctx?.headers,
183
+ credentials: options.credentials ?? ctx?.credentials
184
+ }),
185
+ [endpoint, options.headers, ctx?.headers, options.credentials, ctx?.credentials]
186
+ );
187
+ const [items, setItems] = useState([]);
188
+ const controllers = useRef(/* @__PURE__ */ new Map());
189
+ const running = useRef(0);
190
+ const pending = useRef([]);
191
+ const parallel = options.parallel ?? 2;
192
+ const patch = useCallback(
193
+ (id, p) => setItems((prev) => prev.map((it) => it.id === id ? { ...it, ...p } : it)),
194
+ []
195
+ );
196
+ const pump = useCallback(() => {
197
+ while (running.current < parallel && pending.current.length) {
198
+ const next = pending.current.shift();
199
+ if (!next) break;
200
+ running.current++;
201
+ const { item, input } = next;
202
+ const controller = new AbortController();
203
+ controllers.current.set(item.id, controller);
204
+ patch(item.id, { status: "uploading" });
205
+ (async () => {
206
+ try {
207
+ const ticket = await api.create({
208
+ ...input,
209
+ filename: item.file.name,
210
+ mimeType: item.file.type || "application/octet-stream",
211
+ sizeBytes: item.file.size
212
+ });
213
+ const asset = await performUpload(
214
+ api,
215
+ ticket,
216
+ item.file,
217
+ item.file.type || "application/octet-stream",
218
+ {
219
+ signal: controller.signal,
220
+ concurrency: options.concurrency,
221
+ onProgress: (progress) => patch(item.id, { progress })
222
+ }
223
+ );
224
+ patch(item.id, {
225
+ status: "done",
226
+ result: asset,
227
+ progress: { loaded: item.file.size, total: item.file.size, fraction: 1 }
228
+ });
229
+ } catch (e) {
230
+ const err = e instanceof PiFilesError ? e : new PiFilesError(0, "UNKNOWN", e?.message ?? "\u0E2D\u0E31\u0E1B\u0E42\u0E2B\u0E25\u0E14\u0E44\u0E21\u0E48\u0E2A\u0E33\u0E40\u0E23\u0E47\u0E08");
231
+ patch(item.id, { status: err.code === "ABORTED" ? "aborted" : "error", error: err });
232
+ } finally {
233
+ controllers.current.delete(item.id);
234
+ running.current--;
235
+ pump();
236
+ }
237
+ })();
238
+ }
239
+ }, [api, parallel, options.concurrency, patch]);
240
+ const enqueue = useCallback(
241
+ (files, input = {}) => {
242
+ const created = files.map((file) => ({
243
+ id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
244
+ file,
245
+ status: "idle",
246
+ progress: { loaded: 0, total: file.size, fraction: 0 },
247
+ error: null,
248
+ result: null
249
+ }));
250
+ setItems((prev) => [...prev, ...created]);
251
+ for (const item of created) pending.current.push({ item, input });
252
+ pump();
253
+ return created.map((c) => c.id);
254
+ },
255
+ [pump]
256
+ );
257
+ const cancel = useCallback((id) => {
258
+ controllers.current.get(id)?.abort();
259
+ pending.current = pending.current.filter((p) => p.item.id !== id);
260
+ setItems(
261
+ (prev) => prev.map((it) => it.id === id && it.status === "idle" ? { ...it, status: "aborted" } : it)
262
+ );
263
+ }, []);
264
+ const clearFinished = useCallback(
265
+ () => setItems((prev) => prev.filter((it) => it.status === "uploading" || it.status === "idle")),
266
+ []
267
+ );
268
+ return { items, enqueue, cancel, clearFinished };
269
+ }
270
+ function useReplace(options = {}) {
271
+ const ctx = useContext(Ctx);
272
+ const endpoint = options.endpoint ?? ctx?.endpoint;
273
+ if (!endpoint) throw new Error("useReplace \u0E15\u0E49\u0E2D\u0E07\u0E21\u0E35 endpoint (prop \u0E2B\u0E23\u0E37\u0E2D PiFilesProvider)");
274
+ const api = useMemo(
275
+ () => createEndpointApi({
276
+ endpoint,
277
+ headers: options.headers ?? ctx?.headers,
278
+ credentials: options.credentials ?? ctx?.credentials
279
+ }),
280
+ [endpoint, options.headers, options.credentials, ctx?.headers, ctx?.credentials]
281
+ );
282
+ const replaceUrl = options.replaceUrl ?? ((id) => /\/api\/v1\/uploads\/?$/.test(endpoint) ? `${endpoint.replace(/\/uploads\/?$/, "")}/files/${id}/replace` : `${endpoint.replace(/\/$/, "")}/replace/${id}`);
283
+ const [status, setStatus] = useState("idle");
284
+ const [progress, setProgress] = useState({ loaded: 0, total: 0, fraction: 0 });
285
+ const [error, setError] = useState(null);
286
+ const abortRef = useRef(null);
287
+ const onProgress = options.onProgress;
288
+ const replace = useCallback(
289
+ async (fileId, file) => {
290
+ const controller = new AbortController();
291
+ abortRef.current = controller;
292
+ setStatus("uploading");
293
+ setError(null);
294
+ setProgress({ loaded: 0, total: file.size, fraction: 0 });
295
+ const ticket = await endpointFetch(
296
+ {
297
+ endpoint: replaceUrl(fileId),
298
+ headers: options.headers ?? ctx?.headers,
299
+ credentials: options.credentials ?? ctx?.credentials
300
+ },
301
+ "",
302
+ {
303
+ method: "POST",
304
+ body: JSON.stringify({
305
+ mimeType: file.type || "application/octet-stream",
306
+ sizeBytes: file.size
307
+ })
308
+ }
309
+ );
310
+ try {
311
+ const asset = await performUpload(
312
+ api,
313
+ ticket,
314
+ file,
315
+ file.type || "application/octet-stream",
316
+ {
317
+ signal: controller.signal,
318
+ concurrency: options.concurrency,
319
+ onProgress: (p) => {
320
+ setProgress(p);
321
+ onProgress?.(p);
322
+ }
323
+ }
324
+ );
325
+ setStatus("done");
326
+ return asset;
327
+ } catch (e) {
328
+ const err = e instanceof PiFilesError ? e : new PiFilesError(0, "REPLACE_FAILED", String(e));
329
+ setError(err);
330
+ setStatus(controller.signal.aborted ? "aborted" : "error");
331
+ await api.abort(ticket.uploadId).catch(() => {
332
+ });
333
+ throw err;
334
+ }
335
+ },
336
+ [
337
+ api,
338
+ replaceUrl,
339
+ onProgress,
340
+ options.concurrency,
341
+ options.headers,
342
+ options.credentials,
343
+ ctx?.headers,
344
+ ctx?.credentials
345
+ ]
346
+ );
347
+ const cancel = useCallback(() => abortRef.current?.abort(), []);
348
+ return { replace, cancel, status, progress, error };
349
+ }
350
+ export {
351
+ PiFilesError,
352
+ PiFilesProvider,
353
+ createEndpointApi,
354
+ useReplace,
355
+ useUpload,
356
+ useUploadQueue
357
+ };