From 18ea5428eb08ad357c3dbf1ef833c31de1fecc75 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:49:50 -0400 Subject: [PATCH] fix(blossom): upload directly per-server instead of the failing /mirror flow [LOCAL] (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-device failure: multiServerUpload uploads to one server then has the OTHERS fetch it server-to-server via PUT /mirror — those /mirror requests fail from a browser (net::ERR_FAILED / CORS), so the whole upload returned no blob URL ("Blossom upload did not return a usable blob URL"). Switch blossomService.uploadFile to upload DIRECTLY to each server (BUD-02 PUT /upload) in parallel via uploadBlob, resilient: first success wins, per-server failures are collected, and if ALL fail the error names each server + the likely cause (CORS / size / payment) and suggests blossom.band. A fresh signed upload auth (BUD-11) is created per request. Tests: blossom-service 11/11 (added partial-failure fallback + all-fail error). Lint + build clean. Local only. Co-Authored-By: Claude Opus 4.8 --- js/services/blossomService.js | 45 ++++++++++++++++++++++------- tests/blossom-service.test.mjs | 53 ++++++++++++++++++++++++++-------- 2 files changed, 75 insertions(+), 23 deletions(-) diff --git a/js/services/blossomService.js b/js/services/blossomService.js index f252e0b0..00468b33 100644 --- a/js/services/blossomService.js +++ b/js/services/blossomService.js @@ -80,26 +80,49 @@ export class BlossomService { } const sdk = await this.loadSdk(); - const { multiServerUpload, createUploadAuth } = sdk; + const { uploadBlob, createUploadAuth } = sdk; if ( - typeof multiServerUpload !== "function" || + typeof uploadBlob !== "function" || typeof createUploadAuth !== "function" ) { throw new Error("Blossom SDK is missing expected exports."); } - // One auth per action, scoped to the blob's sha256; the SDK reuses it across - // the mirror set so a multi-server upload isn't N signer prompts. - const results = await multiServerUpload(serverList, file, { - onAuth: (server, sha256) => createUploadAuth(signer, sha256, { type }), - onProgress: typeof onProgress === "function" ? onProgress : undefined, + // Upload DIRECTLY to each server (BUD-02 `PUT /upload`), in parallel, rather + // than the SDK's upload-one-then-`/mirror` flow — the server-to-server mirror + // step fails from a browser (CORS) on many servers. A fresh signed upload auth + // (BUD-11, scoped to the blob's sha256) is created per request. + const onAuth = (server, sha256) => + createUploadAuth(signer, sha256, { type }); + const settled = await Promise.allSettled( + serverList.map((server) => + uploadBlob(server, file, { + onAuth, + onProgress: typeof onProgress === "function" ? onProgress : undefined, + }), + ), + ); + + const descriptors = []; + const failures = []; + settled.forEach((r, i) => { + if (r.status === "fulfilled" && r.value && typeof r.value.url === "string") { + descriptors.push(r.value); + } else { + const reason = r.reason; + failures.push( + `${serverList[i]}: ${reason?.message || reason || "no blob URL returned"}`, + ); + } }); - const descriptors = Array.from(results?.values?.() || []); - const primary = - descriptors.find((d) => typeof d?.url === "string" && d.url) || null; + const primary = descriptors.find((d) => typeof d?.url === "string" && d.url); if (!primary) { - throw new Error("Blossom upload did not return a usable blob URL."); + throw new Error( + `Blossom upload failed on all server(s) — ${failures.join("; ")}. ` + + `Common causes: the server blocks browser uploads (CORS), rejects the ` + + `file size, or requires payment/allowlisting. Try blossom.band.`, + ); } return { diff --git a/tests/blossom-service.test.mjs b/tests/blossom-service.test.mjs index bc22a9da..19e67922 100644 --- a/tests/blossom-service.test.mjs +++ b/tests/blossom-service.test.mjs @@ -16,23 +16,25 @@ import { FEATURE_BLOSSOM_STORAGE } from "../js/constants.js"; const SHA = "a".repeat(64); const signer = async (draft) => ({ ...draft, id: "id", sig: "sig", pubkey: "pk" }); -// A BlossomService whose SDK is mocked: multiServerUpload returns a descriptor -// map keyed by server, echoing the file so we can assert which blob was sent. -function mockService() { +// A BlossomService whose SDK is mocked: uploadBlob uploads directly to each +// server (BUD-02 /upload) and returns a descriptor, echoing the blob so we can +// assert which servers were hit. `failServers` simulates per-server failures. +function mockService({ failServers = [] } = {}) { const uploads = []; const svc = new BlossomService(); svc.loadSdk = async () => ({ createUploadAuth: async (s, sha256, opts) => ({ kind: 24242, sha256, opts }), - multiServerUpload: async (servers, file, opts) => { - uploads.push({ servers: [...servers], file }); + uploadBlob: async (server, file, opts) => { // Give each blob a distinct sha256 so video vs .torrent URLs differ. const sha = file?.name?.includes(".torrent") ? "b".repeat(64) : SHA; - if (typeof opts?.onAuth === "function") await opts.onAuth(servers[0], sha); - const map = new Map(); - for (const server of servers) { - map.set(server, { url: `${server}/${sha}`, sha256: sha, size: 1, type: "x" }); + if (typeof opts?.onAuth === "function") { + await opts.onAuth(server, sha, "upload", file); } - return map; + uploads.push({ server, file }); + if (failServers.includes(server)) { + throw new Error("CORS blocked"); + } + return { url: `${server}/${sha}`, sha256: sha, size: 1, type: "x" }; }, }); return { svc, uploads }; @@ -70,7 +72,7 @@ test("uploadFile guards: file, servers, signer required", async () => { ); }); -test("uploadFile returns the primary descriptor url + sha256 key, mirrored to all servers", async () => { +test("uploadFile uploads directly to every server and returns the primary url", async () => { const { svc, uploads } = mockService(); const out = await svc.uploadFile({ file: new File([new Uint8Array([1])], "v.mp4"), @@ -79,7 +81,34 @@ test("uploadFile returns the primary descriptor url + sha256 key, mirrored to al }); assert.equal(out.url, `https://a/${SHA}`); assert.equal(out.key, SHA); - assert.deepEqual(uploads[0].servers, ["https://a", "https://b"], "mirrored to both"); + assert.deepEqual( + uploads.map((u) => u.server).sort(), + ["https://a", "https://b"], + "uploaded to both servers directly (no /mirror)", + ); +}); + +test("uploadFile survives partial failure — one server down, another succeeds", async () => { + const { svc } = mockService({ failServers: ["https://a"] }); + const out = await svc.uploadFile({ + file: new File([new Uint8Array([1])], "v.mp4"), + servers: ["https://a", "https://b"], + signer, + }); + assert.equal(out.url, `https://b/${SHA}`, "falls back to the working server"); +}); + +test("uploadFile throws a descriptive error when ALL servers fail", async () => { + const { svc } = mockService({ failServers: ["https://a", "https://b"] }); + await assert.rejects( + () => + svc.uploadFile({ + file: new File([new Uint8Array([1])], "v.mp4"), + servers: ["https://a", "https://b"], + signer, + }), + /failed on all server\(s\).*CORS blocked/s, + ); }); test("uploadVideo without a torrent returns a blossom storagePointer and no magnet", async () => {