From 4a2408bcaf3c648c0aecd46b8d79f117728f0d13 Mon Sep 17 00:00:00 2001 From: zymooll Date: Sun, 28 Jun 2026 03:31:06 +0800 Subject: [PATCH 1/3] feat(pds): add direct upload frontend --- src/pages/home/uploads/pds.ts | 201 ++++++++++++++++++++++++++++++ src/pages/home/uploads/uploads.ts | 8 ++ 2 files changed, 209 insertions(+) create mode 100644 src/pages/home/uploads/pds.ts diff --git a/src/pages/home/uploads/pds.ts b/src/pages/home/uploads/pds.ts new file mode 100644 index 000000000..8dbb93484 --- /dev/null +++ b/src/pages/home/uploads/pds.ts @@ -0,0 +1,201 @@ +import { Upload, SetUpload } from "./types" +import { Resp } from "~/types" +import { r, pathDir } from "~/utils" + +type DirectUploadCompletionInfo = { + url?: string + method?: string + headers?: Record + body?: unknown +} + +type PdsDirectUploadInfo = { + upload_url: string + headers?: Record + method?: string + complete?: DirectUploadCompletionInfo +} + +export const PdsDirectUpload: Upload = async ( + uploadPath: string, + file: File, + setUpload: SetUpload, + _asTask: boolean, + overwrite: boolean, + _rapid: boolean, +) => { + const path = pathDir(uploadPath) + + const resp = (await r.post( + "/fs/get_direct_upload_info", + { + path, + file_name: file.name, + file_size: file.size, + tool: "PdsDirect", + }, + { + headers: { + "File-Path": encodeURIComponent(uploadPath), + Overwrite: overwrite, + }, + }, + )) as Resp + + if (resp.code !== 200) { + throw new Error(resp.message) + } + + const uploadInfo = resp.data + + if (!uploadInfo?.upload_url) { + throw new Error("PDS Direct Upload not supported") + } + + await uploadSingle( + file, + uploadInfo.upload_url, + uploadInfo.method || "PUT", + uploadInfo.headers, + setUpload, + ) + + await completeDirectUpload(uploadInfo.complete, uploadPath, setUpload) + return undefined +} + +function getHeaderEntries(headers?: Record) { + return Object.entries(headers ?? {}).filter(([, value]) => value !== "") +} + +function shouldSuppressContentType(headers?: Record) { + return Object.entries(headers ?? {}).some( + ([key, value]) => key.toLowerCase() === "content-type" && value === "", + ) +} + +function getRequestBody(blob: Blob, suppressContentType: boolean): Blob { + if (!suppressContentType || blob.type === "") { + return blob + } + return blob.slice(0, blob.size, "") +} + +async function uploadSingle( + file: File, + uploadURL: string, + method: string, + headers?: Record, + setUpload?: SetUpload, +): Promise { + const xhr = new XMLHttpRequest() + const calcSpeed = createSpeedCalculator() + const suppressContentType = shouldSuppressContentType(headers) + + return new Promise((resolve, reject) => { + xhr.upload.addEventListener("progress", (e) => { + if (e.lengthComputable && setUpload) { + const progress = (e.loaded / e.total) * 100 + setUpload("progress", progress) + calcSpeed(e.loaded, setUpload) + } + }) + + xhr.addEventListener("load", () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(undefined) + } else { + reject(new Error(`Upload failed with status ${xhr.status}`)) + } + }) + + xhr.addEventListener("error", () => { + reject(new Error("Upload failed")) + }) + + xhr.open(method, uploadURL) + + getHeaderEntries(headers).forEach(([key, value]) => { + xhr.setRequestHeader(key, value) + }) + + xhr.send(getRequestBody(file, suppressContentType)) + }) +} + +function createSpeedCalculator(throttleMs = 500) { + let lastLoaded = 0 + let lastTime = Date.now() + + return (loaded: number, setUpload?: SetUpload) => { + const now = Date.now() + const timeDiff = (now - lastTime) / 1000 + + if (timeDiff >= throttleMs / 1000) { + const speed = (loaded - lastLoaded) / timeDiff + setUpload?.("speed", speed) + lastLoaded = loaded + lastTime = now + } + } +} + +async function completeDirectUpload( + complete?: DirectUploadCompletionInfo, + uploadPath?: string, + setUpload?: SetUpload, +): Promise { + if (!complete) { + return + } + if (!complete.url) { + throw new Error("Direct upload completion URL is missing") + } + + setUpload?.("status", "backending") + + const headers = new Headers() + getHeaderEntries(complete.headers).forEach(([key, value]) => { + headers.set(key, value) + }) + if (uploadPath && !headers.has("File-Path")) { + headers.set("File-Path", encodeURIComponent(uploadPath)) + } + if (!headers.has("Authorization")) { + const token = localStorage.getItem("token") + if (token) { + headers.set("Authorization", token) + } + } + + let body: BodyInit | undefined + if (complete.body !== undefined && complete.body !== null) { + if (typeof complete.body === "string") { + body = complete.body + } else { + if (!headers.has("Content-Type")) { + headers.set("Content-Type", "application/json") + } + body = JSON.stringify(complete.body) + } + } + + const resp = await fetch(complete.url, { + method: complete.method || "POST", + headers, + body, + }) + const text = await resp.text() + let data: Resp | undefined + try { + data = text ? (JSON.parse(text) as Resp) : undefined + } catch { + data = undefined + } + if (!resp.ok) { + throw new Error(data?.message || `Complete upload failed: ${resp.status}`) + } + if (data && data.code !== 200) { + throw new Error(data.message || "Complete upload failed") + } +} diff --git a/src/pages/home/uploads/uploads.ts b/src/pages/home/uploads/uploads.ts index 2ddb1f517..a4284e7fb 100644 --- a/src/pages/home/uploads/uploads.ts +++ b/src/pages/home/uploads/uploads.ts @@ -2,6 +2,7 @@ import { objStore } from "~/store" import { FormUpload } from "./form" import { StreamUpload } from "./stream" import { HttpDirectUpload } from "./direct" +import { PdsDirectUpload } from "./pds" import { Upload } from "./types" type Uploader = { @@ -19,6 +20,13 @@ const AllUploads: Uploader[] = [ return objStore.direct_upload_tools?.includes("HttpDirect") || false }, }, + { + name: "PDS Direct", + upload: PdsDirectUpload, + available: () => { + return objStore.direct_upload_tools?.includes("PdsDirect") || false + }, + }, { name: "Stream", upload: StreamUpload, From 356474b50b199e5bae3693f5b1ff5298ab869a37 Mon Sep 17 00:00:00 2001 From: zymooll Date: Mon, 29 Jun 2026 05:46:17 +0800 Subject: [PATCH 2/3] fix(pds): send canonical direct upload completion path --- src/pages/home/uploads/pds.ts | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/pages/home/uploads/pds.ts b/src/pages/home/uploads/pds.ts index 8dbb93484..45b8372aa 100644 --- a/src/pages/home/uploads/pds.ts +++ b/src/pages/home/uploads/pds.ts @@ -1,6 +1,6 @@ import { Upload, SetUpload } from "./types" import { Resp } from "~/types" -import { r, pathDir } from "~/utils" +import { r, pathBase, pathDir } from "~/utils" type DirectUploadCompletionInfo = { url?: string @@ -169,14 +169,15 @@ async function completeDirectUpload( } let body: BodyInit | undefined - if (complete.body !== undefined && complete.body !== null) { - if (typeof complete.body === "string") { - body = complete.body + const completeBody = normalizeCompleteBody(complete.body, uploadPath) + if (completeBody !== undefined && completeBody !== null) { + if (typeof completeBody === "string") { + body = completeBody } else { if (!headers.has("Content-Type")) { headers.set("Content-Type", "application/json") } - body = JSON.stringify(complete.body) + body = JSON.stringify(completeBody) } } @@ -199,3 +200,17 @@ async function completeDirectUpload( throw new Error(data.message || "Complete upload failed") } } + +function normalizeCompleteBody(body: unknown, uploadPath?: string): unknown { + if (!uploadPath || typeof body !== "object" || body === null) { + return body + } + if (Array.isArray(body)) { + return body + } + return { + ...(body as Record), + path: pathDir(uploadPath), + file_name: pathBase(uploadPath), + } +} From 516cd584fe2344bcd9beba59a6f3dc91f695d440 Mon Sep 17 00:00:00 2001 From: zymooll Date: Wed, 1 Jul 2026 21:32:33 +0800 Subject: [PATCH 3/3] refactor(pds): consolidate direct upload strategy - Move PDS direct upload into the existing direct upload module. - Reuse shared direct upload helpers for headers, progress, and completion. - Keep the PDS direct upload tool name and request flow unchanged. --- src/pages/home/uploads/direct.ts | 178 ++++++++++++++++++++++-- src/pages/home/uploads/pds.ts | 216 ------------------------------ src/pages/home/uploads/uploads.ts | 3 +- 3 files changed, 165 insertions(+), 232 deletions(-) delete mode 100644 src/pages/home/uploads/pds.ts diff --git a/src/pages/home/uploads/direct.ts b/src/pages/home/uploads/direct.ts index 5b54c73f9..9610314da 100644 --- a/src/pages/home/uploads/direct.ts +++ b/src/pages/home/uploads/direct.ts @@ -1,5 +1,20 @@ import { Upload, SetUpload } from "./types" -import { r, pathDir } from "~/utils" +import { Resp } from "~/types" +import { r, pathBase, pathDir } from "~/utils" + +type DirectUploadCompletionInfo = { + url?: string + method?: string + headers?: Record + body?: unknown +} + +type PdsDirectUploadInfo = { + upload_url: string + headers?: Record + method?: string + complete?: DirectUploadCompletionInfo +} // Create a speed calculator using closure function createSpeedCalculator(throttleMs = 500) { @@ -80,6 +95,71 @@ export const HttpDirectUpload: Upload = async ( } } +export const PdsDirectUpload: Upload = async ( + uploadPath: string, + file: File, + setUpload: SetUpload, + _asTask: boolean, + overwrite: boolean, + _rapid: boolean, +) => { + const path = pathDir(uploadPath) + + const resp = (await r.post( + "/fs/get_direct_upload_info", + { + path, + file_name: file.name, + file_size: file.size, + tool: "PdsDirect", + }, + { + headers: { + "File-Path": encodeURIComponent(uploadPath), + Overwrite: overwrite, + }, + }, + )) as Resp + + if (resp.code !== 200) { + throw new Error(resp.message) + } + + const uploadInfo = resp.data + + if (!uploadInfo?.upload_url) { + throw new Error("PDS Direct Upload not supported") + } + + await uploadSingle( + file, + uploadInfo.upload_url, + uploadInfo.method || "PUT", + uploadInfo.headers, + setUpload, + ) + + await completeDirectUpload(uploadInfo.complete, uploadPath, setUpload) + return undefined +} + +function getHeaderEntries(headers?: Record) { + return Object.entries(headers ?? {}).filter(([, value]) => value !== "") +} + +function shouldSuppressContentType(headers?: Record) { + return Object.entries(headers ?? {}).some( + ([key, value]) => key.toLowerCase() === "content-type" && value === "", + ) +} + +function getRequestBody(blob: Blob, suppressContentType: boolean): Blob { + if (!suppressContentType || blob.type === "") { + return blob + } + return blob.slice(0, blob.size, "") +} + async function uploadSingle( file: File, uploadURL: string, @@ -89,6 +169,7 @@ async function uploadSingle( ): Promise { const xhr = new XMLHttpRequest() const calcSpeed = createSpeedCalculator() + const suppressContentType = shouldSuppressContentType(headers) return new Promise((resolve, reject) => { xhr.upload.addEventListener("progress", (e) => { @@ -113,14 +194,11 @@ async function uploadSingle( xhr.open(method, uploadURL) - // Set custom headers if provided - if (headers) { - Object.entries(headers).forEach(([key, value]) => { - xhr.setRequestHeader(key, value) - }) - } + getHeaderEntries(headers).forEach(([key, value]) => { + xhr.setRequestHeader(key, value) + }) - xhr.send(file) + xhr.send(getRequestBody(file, suppressContentType)) }) } @@ -176,12 +254,9 @@ async function uploadChunked( `bytes ${start}-${end - 1}/${file.size}`, ) - // Set custom headers if provided - if (headers) { - Object.entries(headers).forEach(([key, value]) => { - xhr.setRequestHeader(key, value) - }) - } + getHeaderEntries(headers).forEach(([key, value]) => { + xhr.setRequestHeader(key, value) + }) xhr.send(chunk) }) @@ -189,3 +264,78 @@ async function uploadChunked( return undefined } + +async function completeDirectUpload( + complete?: DirectUploadCompletionInfo, + uploadPath?: string, + setUpload?: SetUpload, +): Promise { + if (!complete) { + return + } + if (!complete.url) { + throw new Error("Direct upload completion URL is missing") + } + + setUpload?.("status", "backending") + + const headers = new Headers() + getHeaderEntries(complete.headers).forEach(([key, value]) => { + headers.set(key, value) + }) + if (uploadPath && !headers.has("File-Path")) { + headers.set("File-Path", encodeURIComponent(uploadPath)) + } + if (!headers.has("Authorization")) { + const token = localStorage.getItem("token") + if (token) { + headers.set("Authorization", token) + } + } + + let body: BodyInit | undefined + const completeBody = normalizeCompleteBody(complete.body, uploadPath) + if (completeBody !== undefined && completeBody !== null) { + if (typeof completeBody === "string") { + body = completeBody + } else { + if (!headers.has("Content-Type")) { + headers.set("Content-Type", "application/json") + } + body = JSON.stringify(completeBody) + } + } + + const resp = await fetch(complete.url, { + method: complete.method || "POST", + headers, + body, + }) + const text = await resp.text() + let data: Resp | undefined + try { + data = text ? (JSON.parse(text) as Resp) : undefined + } catch { + data = undefined + } + if (!resp.ok) { + throw new Error(data?.message || `Complete upload failed: ${resp.status}`) + } + if (data && data.code !== 200) { + throw new Error(data.message || "Complete upload failed") + } +} + +function normalizeCompleteBody(body: unknown, uploadPath?: string): unknown { + if (!uploadPath || typeof body !== "object" || body === null) { + return body + } + if (Array.isArray(body)) { + return body + } + return { + ...(body as Record), + path: pathDir(uploadPath), + file_name: pathBase(uploadPath), + } +} diff --git a/src/pages/home/uploads/pds.ts b/src/pages/home/uploads/pds.ts deleted file mode 100644 index 45b8372aa..000000000 --- a/src/pages/home/uploads/pds.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { Upload, SetUpload } from "./types" -import { Resp } from "~/types" -import { r, pathBase, pathDir } from "~/utils" - -type DirectUploadCompletionInfo = { - url?: string - method?: string - headers?: Record - body?: unknown -} - -type PdsDirectUploadInfo = { - upload_url: string - headers?: Record - method?: string - complete?: DirectUploadCompletionInfo -} - -export const PdsDirectUpload: Upload = async ( - uploadPath: string, - file: File, - setUpload: SetUpload, - _asTask: boolean, - overwrite: boolean, - _rapid: boolean, -) => { - const path = pathDir(uploadPath) - - const resp = (await r.post( - "/fs/get_direct_upload_info", - { - path, - file_name: file.name, - file_size: file.size, - tool: "PdsDirect", - }, - { - headers: { - "File-Path": encodeURIComponent(uploadPath), - Overwrite: overwrite, - }, - }, - )) as Resp - - if (resp.code !== 200) { - throw new Error(resp.message) - } - - const uploadInfo = resp.data - - if (!uploadInfo?.upload_url) { - throw new Error("PDS Direct Upload not supported") - } - - await uploadSingle( - file, - uploadInfo.upload_url, - uploadInfo.method || "PUT", - uploadInfo.headers, - setUpload, - ) - - await completeDirectUpload(uploadInfo.complete, uploadPath, setUpload) - return undefined -} - -function getHeaderEntries(headers?: Record) { - return Object.entries(headers ?? {}).filter(([, value]) => value !== "") -} - -function shouldSuppressContentType(headers?: Record) { - return Object.entries(headers ?? {}).some( - ([key, value]) => key.toLowerCase() === "content-type" && value === "", - ) -} - -function getRequestBody(blob: Blob, suppressContentType: boolean): Blob { - if (!suppressContentType || blob.type === "") { - return blob - } - return blob.slice(0, blob.size, "") -} - -async function uploadSingle( - file: File, - uploadURL: string, - method: string, - headers?: Record, - setUpload?: SetUpload, -): Promise { - const xhr = new XMLHttpRequest() - const calcSpeed = createSpeedCalculator() - const suppressContentType = shouldSuppressContentType(headers) - - return new Promise((resolve, reject) => { - xhr.upload.addEventListener("progress", (e) => { - if (e.lengthComputable && setUpload) { - const progress = (e.loaded / e.total) * 100 - setUpload("progress", progress) - calcSpeed(e.loaded, setUpload) - } - }) - - xhr.addEventListener("load", () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve(undefined) - } else { - reject(new Error(`Upload failed with status ${xhr.status}`)) - } - }) - - xhr.addEventListener("error", () => { - reject(new Error("Upload failed")) - }) - - xhr.open(method, uploadURL) - - getHeaderEntries(headers).forEach(([key, value]) => { - xhr.setRequestHeader(key, value) - }) - - xhr.send(getRequestBody(file, suppressContentType)) - }) -} - -function createSpeedCalculator(throttleMs = 500) { - let lastLoaded = 0 - let lastTime = Date.now() - - return (loaded: number, setUpload?: SetUpload) => { - const now = Date.now() - const timeDiff = (now - lastTime) / 1000 - - if (timeDiff >= throttleMs / 1000) { - const speed = (loaded - lastLoaded) / timeDiff - setUpload?.("speed", speed) - lastLoaded = loaded - lastTime = now - } - } -} - -async function completeDirectUpload( - complete?: DirectUploadCompletionInfo, - uploadPath?: string, - setUpload?: SetUpload, -): Promise { - if (!complete) { - return - } - if (!complete.url) { - throw new Error("Direct upload completion URL is missing") - } - - setUpload?.("status", "backending") - - const headers = new Headers() - getHeaderEntries(complete.headers).forEach(([key, value]) => { - headers.set(key, value) - }) - if (uploadPath && !headers.has("File-Path")) { - headers.set("File-Path", encodeURIComponent(uploadPath)) - } - if (!headers.has("Authorization")) { - const token = localStorage.getItem("token") - if (token) { - headers.set("Authorization", token) - } - } - - let body: BodyInit | undefined - const completeBody = normalizeCompleteBody(complete.body, uploadPath) - if (completeBody !== undefined && completeBody !== null) { - if (typeof completeBody === "string") { - body = completeBody - } else { - if (!headers.has("Content-Type")) { - headers.set("Content-Type", "application/json") - } - body = JSON.stringify(completeBody) - } - } - - const resp = await fetch(complete.url, { - method: complete.method || "POST", - headers, - body, - }) - const text = await resp.text() - let data: Resp | undefined - try { - data = text ? (JSON.parse(text) as Resp) : undefined - } catch { - data = undefined - } - if (!resp.ok) { - throw new Error(data?.message || `Complete upload failed: ${resp.status}`) - } - if (data && data.code !== 200) { - throw new Error(data.message || "Complete upload failed") - } -} - -function normalizeCompleteBody(body: unknown, uploadPath?: string): unknown { - if (!uploadPath || typeof body !== "object" || body === null) { - return body - } - if (Array.isArray(body)) { - return body - } - return { - ...(body as Record), - path: pathDir(uploadPath), - file_name: pathBase(uploadPath), - } -} diff --git a/src/pages/home/uploads/uploads.ts b/src/pages/home/uploads/uploads.ts index a4284e7fb..1ee5b30f8 100644 --- a/src/pages/home/uploads/uploads.ts +++ b/src/pages/home/uploads/uploads.ts @@ -1,8 +1,7 @@ import { objStore } from "~/store" import { FormUpload } from "./form" import { StreamUpload } from "./stream" -import { HttpDirectUpload } from "./direct" -import { PdsDirectUpload } from "./pds" +import { HttpDirectUpload, PdsDirectUpload } from "./direct" import { Upload } from "./types" type Uploader = {