| 1 | // BUD-14 multipart uploads. Upload state belongs to the caller and supports |
| 2 | // retry, progress and cancellation for the active page. |
| 3 | (() => { |
| 4 | "use strict" ; |
| 5 | // Upload transport. plan describes signed requests for a payload; upload |
| 6 | // executes that plan with retry, progress and cancellation support. |
| 7 | |
| 8 | 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 | }; |
| 46 | |
| 47 | // resolveTarget checks the source and options and returns the blob URL |
| 48 | // the upload addresses. Both the live uploader and the background plan |
| 49 | // 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 | ? base |
| 73 | : base.replace(/\/$/, "" ) + "/" + hash + (extension ? "." + extension : "" ); |
| 74 | return {bytes, hash, type, url: withMetadata(target, options.metadata), probeURL: base.replace(/\/$/, "" )}; |
| 75 | }; |
| 76 | |
| 77 | // plan lists the signed chunk requests for one upload without sending |
| 78 | // them, so a browser can hand the transfer to Background Fetch and finish |
| 79 | // 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 | }; |
| 103 | |
| 104 | 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 : 1 |
| 121 | }); |
| 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 | type |
| 139 | }); |
| 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.signal |
| 149 | }); |
| 150 | // Keep the request deadline and cancellation active until the |
| 151 | // 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 () => descriptor |
| 159 | }; |
| 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 | }; |
| 214 | |
| 215 | 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 | state |
| 236 | }; |
| 237 | } |
| 238 | |
| 239 | 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) >= 60000 |
| 248 | ) { |
| 249 | state.clear(); |
| 250 | state.meta = { |
| 251 | hash, |
| 252 | size: bytes.byteLength, |
| 253 | chunkSize, |
| 254 | url, |
| 255 | type, |
| 256 | lastAcceptedAt: 0 |
| 257 | }; |
| 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 : chunk |
| 284 | ), |
| 285 | offset, |
| 286 | length |
| 287 | ); |
| 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 | state |
| 299 | }; |
| 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 | }; |
| 309 | |
| 310 | const upload = (source, options = {}) => { |
| 311 | const controller = new AbortController(); |
| 312 | if (options.signal) { |
| 313 | if (options.signal.aborted) controller.abort(); |
| 314 | else |
| 315 | options.signal.addEventListener("abort" , () => controller.abort(), { |
| 316 | once: true |
| 317 | }); |
| 318 | } |
| 319 | const task = uploadTask(source, options, controller); |
| 320 | task.cancel = () => controller.abort(); |
| 321 | return task; |
| 322 | }; |
| 323 | |
| 324 | const api = Object.freeze({upload, plan}); |
| 325 | globalThis.tiny = globalThis.tiny || {}; |
| 326 | globalThis.tiny.blossom = {...globalThis.tiny.blossom, upload: api}; |
| 327 | })(); |