1// BUD-14 multipart uploads. Upload state belongs to the caller and supports2// retry, progress and cancellation for the active page.3(() => {4 "use strict";5 // Upload transport. plan describes signed requests for a payload; upload6 // executes that plan with retry, progress and cancellation support.78 const {sha256, hex} = globalThis.tiny.util;9 const probeCache = new Map();10 const DEFAULT_PROBE_CACHE_MS = 5 * 60 * 1000;11 const asBytes = async value => {12 if (value instanceof Uint8Array) return value;13 if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);14 if (value instanceof ArrayBuffer) return new Uint8Array(value);15 if (value instanceof Blob) return new Uint8Array(await value.arrayBuffer());16 throw new TypeError("expected Blob, ArrayBuffer, or Uint8Array");17 };18 const responseBody = async response => {19 try {20 return await response.json();21 } catch {22 return null;23 }24 };25 const abortError = () => {26 const error = new Error("upload canceled");27 error.name = "AbortError";28 return error;29 };30 const verifyDescriptor = (descriptor, hash, size) => {31 if (!descriptor || typeof descriptor !== "object" || descriptor.sha256 !== hash || descriptor.size !== size)32 throw new Error("invalid Blossom upload descriptor");33 return descriptor;34 };35 const withMetadata = (target, metadata) => {36 if (metadata === undefined) return target;37 if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) throw new Error("upload metadata must be a map");38 const parsed = new URL(target, globalThis.location?.href || "https://blossom.invalid");39 for (const [key, value] of Object.entries(metadata)) {40 if (!/^[A-Za-z0-9_-]{1,64}$/.test(key) || value === undefined || typeof value === "object")41 throw new Error("invalid upload metadata");42 parsed.searchParams.set(key, String(value));43 }44 return parsed.href;45 };4647 // resolveTarget checks the source and options and returns the blob URL48 // the upload addresses. Both the live uploader and the background plan49 // use it so they agree on every detail.50 const resolveTarget = async (source, options) => {51 const bytes = await asBytes(source);52 const hash = options.hash || hex(await sha256(bytes));53 if (!/^[0-9a-f]{64}$/.test(hash)) throw new Error("expected a lowercase SHA-256 hash");54 if (options.hash && hash !== hex(await sha256(bytes))) throw new Error("upload hash mismatch");55 const type = options.type || source.type || "application/octet-stream";56 const base = options.url || options.endpoint;57 if (typeof base !== "string" || base === "") throw new Error("upload URL is required");58 let parsed;59 try {60 parsed = new URL(base, globalThis.location?.href || "https://blossom.invalid");61 } catch {62 throw new Error("invalid upload URL");63 }64 if (parsed.protocol !== "http:" && parsed.protocol !== "https:")65 throw new Error("upload URL must use HTTP or HTTPS");66 if (parsed.username || parsed.password || parsed.hash || parsed.search)67 throw new Error("upload URL must not contain credentials, query, or fragment");68 const extension = options.extension ? String(options.extension).replace(/^\.+/, "") : "";69 if (extension && !/^[a-z0-9]{1,8}$/.test(extension)) throw new Error("invalid file extension");70 const path = parsed.pathname;71 const target = new RegExp("/" + hash + "(?:\\.[a-z0-9]{1,8})?/?$").test(path)72 ? base73 : base.replace(/\/$/, "") + "/" + hash + (extension ? "." + extension : "");74 return {bytes, hash, type, url: withMetadata(target, options.metadata), probeURL: base.replace(/\/$/, "")};75 };7677 // plan lists the signed chunk requests for one upload without sending78 // them, so a browser can hand the transfer to Background Fetch and finish79 // it after the page closes. Each chunk carries its own authorization.80 const plan = async (source, options = {}) => {81 const {bytes, hash, type, url} = await resolveTarget(source, options);82 const chunkSize =83 Number.isSafeInteger(options.chunkSize) && options.chunkSize > 0 ? options.chunkSize : 5 * 1024 * 1024;84 const requests = [];85 for (let offset = 0; offset < bytes.byteLength || (bytes.byteLength === 0 && offset === 0); offset += chunkSize) {86 const chunk = bytes.slice(offset, Math.min(offset + chunkSize, bytes.byteLength));87 const headers = {88 "upload-type": type,89 "upload-length": String(bytes.byteLength),90 "upload-offset": String(offset),91 "content-type": "application/octet-stream"92 };93 if (options.authorize) {94 const value = await options.authorize(url, "PATCH", chunk, {hash, type});95 if (typeof value === "string") headers.authorization = value;96 else if (value && typeof value === "object") Object.assign(headers, value);97 }98 requests.push({url, method: "PATCH", headers, body: chunk});99 if (bytes.byteLength === 0) break;100 }101 return {hash, url, size: bytes.byteLength, requests};102 };103104 const uploadTask = async (source, options = {}, controller) => {105 const {bytes, hash, type, url, probeURL} = await resolveTarget(source, options);106 const fetcher = options.fetch || globalThis.fetch;107 if (typeof fetcher !== "function") throw new Error("fetch is unavailable");108 const chunkSize =109 Number.isSafeInteger(options.chunkSize) && options.chunkSize > 0 ? options.chunkSize : 5 * 1024 * 1024;110 const timeoutMs = Number.isSafeInteger(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 30000;111 const retries = Number.isSafeInteger(options.retries) && options.retries >= 0 ? options.retries : 2;112 const retryDelayMs = Number.isFinite(options.retryDelayMs) && options.retryDelayMs >= 0 ? options.retryDelayMs : 250;113 const retryMaxDelayMs = Number.isFinite(options.retryMaxDelayMs) && options.retryMaxDelayMs >= 0 ? options.retryMaxDelayMs : 5000;114 const state = options.state instanceof Map ? options.state : new Map();115 const notify = (offset, length, status) => options.onState?.({offset, length, status, state});116 const progress = sent =>117 options.onProgress?.({118 sent,119 total: bytes.byteLength,120 fraction: bytes.byteLength ? sent / bytes.byteLength : 1121 });122 const request = async (method, body, headers, authBody = body) => {123 if (controller.signal.aborted) throw abortError();124 const requestController = new AbortController();125 let timer, rejectCanceled;126 const cancel = () => {127 requestController.abort();128 rejectCanceled?.(abortError());129 };130 controller.signal.addEventListener("abort", cancel, {once: true});131 try {132 return await Promise.race([133 (async () => {134 const requestHeaders = {...headers};135 if (options.authorize && method !== "OPTIONS") {136 const value = await options.authorize(url, method, authBody, {137 hash,138 type139 });140 if (typeof value === "string") requestHeaders.authorization = value;141 else if (value && typeof value === "object") Object.assign(requestHeaders, value);142 }143 if (requestController.signal.aborted) throw abortError();144 const response = await fetcher(url, {145 method,146 headers: requestHeaders,147 body: method === "OPTIONS" ? undefined : body,148 signal: requestController.signal149 });150 // Keep the request deadline and cancellation active until the151 // descriptor body has arrived, not only until headers arrive.152 const descriptor = response.status === 200 || response.status === 201 ? await responseBody(response) : null;153 if (!descriptor) await response.body?.cancel?.();154 return {155 status: response.status,156 ok: response.ok,157 headers: response.headers,158 json: async () => descriptor159 };160 })(),161 new Promise((_, reject) => {162 timer = setTimeout(() => {163 requestController.abort();164 reject(Error("upload request timed out"));165 }, timeoutMs);166 }),167 new Promise((_, reject) => {168 rejectCanceled = reject;169 })170 ]);171 } finally {172 clearTimeout(timer);173 controller.signal.removeEventListener("abort", cancel);174 requestController.abort();175 }176 };177 const waitForRetry = async attempt => {178 const delay = Math.min(retryMaxDelayMs, retryDelayMs * 2 ** Math.max(0, attempt - 1));179 if (!delay) return;180 await new Promise((resolve, reject) => {181 let timer;182 const cancel = () => {183 clearTimeout(timer);184 controller.signal.removeEventListener("abort", cancel);185 reject(abortError());186 };187 timer = setTimeout(() => {188 controller.signal.removeEventListener("abort", cancel);189 resolve();190 }, delay);191 controller.signal.addEventListener("abort", cancel, {once: true});192 });193 };194 const retry = async (action, offset, length) => {195 let attempt = 0;196 while (true) {197 if (controller.signal.aborted) throw abortError();198 try {199 const result = await action();200 if (!result.ok && result.status >= 500 && attempt++ < retries) {201 notify(offset, length, "retrying");202 await waitForRetry(attempt);203 continue;204 }205 return result;206 } catch (error) {207 if (controller.signal.aborted) throw abortError();208 if (attempt++ >= retries) throw error;209 notify(offset, length, "retrying");210 await waitForRetry(attempt);211 }212 }213 };214215 const cacheMs = options.probeCache === false ? 0 :216 Number.isFinite(options.probeCacheMs) && options.probeCacheMs >= 0 ? options.probeCacheMs : DEFAULT_PROBE_CACHE_MS;217 const cachedProbe = cacheMs ? probeCache.get(probeURL) : null;218 const cacheValid = Boolean(cachedProbe && cachedProbe.expiresAt > Date.now());219 let supportsPatch = options.probe === false || (cacheValid && cachedProbe.supportsPatch);220 if (options.probe !== false && !cacheValid) {221 try {222 const response = await request("OPTIONS", null, {});223 supportsPatch = /(?:^|,|\s)PATCH(?:,|\s|$)/i.test(response.headers?.get?.("allow") || "");224 if (cacheMs) probeCache.set(probeURL, {supportsPatch, expiresAt: Date.now() + cacheMs});225 } catch {226 supportsPatch = false;227 }228 }229 if (!supportsPatch) {230 const response = await retry(() => request("PUT", bytes, {"content-type": type}), 0, bytes.byteLength);231 if (!response.ok) throw new Error("Blossom upload failed: " + response.status);232 return {233 hash,234 descriptor: verifyDescriptor(await responseBody(response), hash, bytes.byteLength),235 state236 };237 }238239 const metadata = state.meta;240 if (241 !metadata ||242 metadata.hash !== hash ||243 metadata.size !== bytes.byteLength ||244 metadata.chunkSize !== chunkSize ||245 metadata.url !== url ||246 metadata.type !== type ||247 Date.now() - (metadata.lastAcceptedAt || 0) >= 60000248 ) {249 state.clear();250 state.meta = {251 hash,252 size: bytes.byteLength,253 chunkSize,254 url,255 type,256 lastAcceptedAt: 0257 };258 }259 let sent = 0;260 let replayed = false;261 while (true) {262 for (let offset = 0; offset < bytes.byteLength || (bytes.byteLength === 0 && offset === 0); offset += chunkSize) {263 const end = Math.min(offset + chunkSize, bytes.byteLength);264 const chunk = bytes.slice(offset, end);265 const length = chunk.byteLength;266 const prior = state.get(offset);267 if (!replayed && prior?.status === 204 && Date.now() - (state.meta.lastAcceptedAt || 0) < 60000) {268 sent = end;269 progress(sent);270 continue;271 }272 const result = await retry(273 () =>274 request(275 "PATCH",276 chunk,277 {278 "upload-type": type,279 "upload-length": String(bytes.byteLength),280 "upload-offset": String(offset),281 "content-type": "application/octet-stream"282 },283 options.authorizationMode === "final-hash" ? bytes : chunk284 ),285 offset,286 length287 );288 if (![200, 201, 204].includes(result.status)) throw new Error("Blossom chunk upload failed: " + result.status);289 state.set(offset, {offset, length, status: result.status});290 state.meta.lastAcceptedAt = Date.now();291 sent = end;292 notify(offset, length, result.status === 204 ? "uploaded" : "complete");293 progress(sent);294 if (result.status !== 204)295 return {296 hash,297 descriptor: verifyDescriptor(await responseBody(result), hash, bytes.byteLength),298 state299 };300 if (bytes.byteLength === 0) break;301 }302 if (!replayed) {303 replayed = true;304 continue;305 }306 throw new Error("multipart upload ended without completion");307 }308 };309310 const upload = (source, options = {}) => {311 const controller = new AbortController();312 if (options.signal) {313 if (options.signal.aborted) controller.abort();314 else315 options.signal.addEventListener("abort", () => controller.abort(), {316 once: true317 });318 }319 const task = uploadTask(source, options, controller);320 task.cancel = () => controller.abort();321 return task;322 };323324 const api = Object.freeze({upload, plan});325 globalThis.tiny = globalThis.tiny || {};326 globalThis.tiny.blossom = {...globalThis.tiny.blossom, upload: api};327})();