tinyrelayfile
tinyrelay/tinyclient/blossom-upload.js/

tinyclient/blossom-upload.js raw
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})();

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs