@filefreak.io/sdk 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,259 @@
1
+ // Uploading a file: begin, send the parts straight to storage, complete.
2
+ //
3
+ // The three steps the API defines (POST /v1/uploads, a PUT for each part to
4
+ // the address given for it, POST /v1/uploads/:id/complete). No byte of the
5
+ // file goes to the API. The rules are the website's (web/lib/uploads.ts):
6
+ //
7
+ // - Each part goes with a Content-MD5, so storage refuses a part that
8
+ // changed on the way, and that part is sent again.
9
+ // - A part that fails for a passing reason (no answer, storage busy, its
10
+ // address run out) is sent again, a little later each time, six times at
11
+ // most. An address that is about to run out is replaced before it is used.
12
+ // - Storage itself says which parts it has. Carrying on with `resume` sends
13
+ // only the rest, from this process or another; so does a complete that
14
+ // finds a part missing.
15
+ // - A very large file can take storage a while to put together. Complete
16
+ // answers 202 until it has, and is asked again when it says.
17
+ //
18
+ // A part is read into memory whole to be hashed and sent, so the memory an
19
+ // upload needs is about partBytes times the parts sent at once.
20
+ import { base64, Md5 } from "./md5.js";
21
+ import { FileFreakError, sleep } from "./transport.js";
22
+ const MIB = 1024 * 1024;
23
+ /** The API's bounds on a part. */
24
+ const LEAST_PART = 8 * MIB;
25
+ const MOST_PART = 64 * MIB;
26
+ /** Parts sent at once, unless the caller says. Four of 32 MiB went at about 700 Mbps from a fast line (plan 04, B1.1). */
27
+ const AT_ONCE = 4;
28
+ /** How many times one part is tried before the upload stops. */
29
+ const PART_ATTEMPTS = 6;
30
+ /** An address this close to running out is not used; a fresh one is asked for instead. */
31
+ const ADDRESS_MARGIN_MS = 60_000;
32
+ /** How long to go on asking while storage puts a very large file together. */
33
+ const ASSEMBLY_PATIENCE_MS = 30 * 60_000;
34
+ /** An upload that stopped part way. What arrived stays in storage until the upload expires: carry on with `resume: uploadId`, or cancel it. */
35
+ export class UploadStopped extends Error {
36
+ uploadId;
37
+ constructor(uploadId, cause) {
38
+ const why = (cause instanceof Error ? cause.message : String(cause)).replace(/[.\s]*$/, ".");
39
+ super(`The upload stopped. ${why} Carry on with resume: "${uploadId}", or cancel it.`, { cause });
40
+ this.uploadId = uploadId;
41
+ this.name = "UploadStopped";
42
+ }
43
+ }
44
+ /** The part size to ask for: 32 MiB, halved while that would leave fewer parts than are sent at once. */
45
+ export function partSizeFor(fileBytes, atOnce) {
46
+ let size = 32 * MIB;
47
+ while (size > LEAST_PART && size > fileBytes / atOnce)
48
+ size /= 2;
49
+ return size;
50
+ }
51
+ function sizeOf(source) {
52
+ return source instanceof Blob ? source.size : source.byteLength;
53
+ }
54
+ async function bytesOf(source, from, to) {
55
+ if (source instanceof Blob)
56
+ return new Uint8Array(await source.slice(from, to).arrayBuffer());
57
+ if (source instanceof ArrayBuffer)
58
+ return new Uint8Array(source.slice(from, to));
59
+ return source.slice(from, to);
60
+ }
61
+ export async function upload(calls, fetcher, source, options = {}) {
62
+ const signal = options.signal;
63
+ const atOnce = Math.max(1, Math.min(16, options.concurrency ?? AT_ONCE));
64
+ const size = sizeOf(source);
65
+ const call = { signal };
66
+ let job;
67
+ if (options.resume) {
68
+ const state = await calls.get(options.resume, call);
69
+ if (state.upload.status === "completed" && state.upload.fileId)
70
+ return (await calls.file(state.upload.fileId, call)).item;
71
+ if (state.upload.status !== "pending") {
72
+ throw new FileFreakError(410, `upload_${state.upload.status}`, `That upload is ${state.upload.status}, so it cannot be carried on. Begin it again.`);
73
+ }
74
+ if (state.upload.sizeBytes !== size) {
75
+ throw new FileFreakError(400, "size_mismatch", `That upload is for ${state.upload.sizeBytes} bytes, and this source has ${size}. Carry it on with the same file it began with.`);
76
+ }
77
+ // Begun by a program that cut the file by its content, as filefreak does: its
78
+ // parts are blocks of different sizes, and this reads part n at n times
79
+ // partBytes, so every part would be sent from the wrong place and the
80
+ // upload could never complete.
81
+ if (state.blocks) {
82
+ throw new FileFreakError(400, "cut_by_content", "That upload was begun with its file cut by content, which this SDK does not do. Carry it on with the program that began it, or cancel it and begin again.");
83
+ }
84
+ job = { source, size, upload: state.upload, addresses: new Map(), fetching: null, done: new Set(state.uploadedParts), sentBytes: 0 };
85
+ }
86
+ else {
87
+ const name = options.name ?? (typeof File !== "undefined" && source instanceof File ? source.name : undefined);
88
+ if (!name)
89
+ throw new TypeError("Give the upload a name: { name: \"photo.jpg\" }.");
90
+ const type = options.contentType ?? (source instanceof Blob && source.type ? source.type : undefined);
91
+ const body = {
92
+ name,
93
+ sizeBytes: size,
94
+ parentId: options.parentId ?? null,
95
+ partBytes: Math.max(LEAST_PART, Math.min(MOST_PART, options.partBytes ?? partSizeFor(size, atOnce))),
96
+ };
97
+ if (options.onConflict)
98
+ body.onConflict = options.onConflict;
99
+ if (options.replacing)
100
+ body.replacing = options.replacing;
101
+ if (type)
102
+ body.contentType = type;
103
+ if (options.imageSizes)
104
+ body.imageSizes = options.imageSizes;
105
+ const begun = await calls.begin(body, { signal, ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}) });
106
+ job = { source, size, upload: begun.upload, addresses: new Map(begun.parts.map((p) => [p.number, p])), fetching: null, done: new Set(), sentBytes: 0 };
107
+ }
108
+ options.onBegun?.(job.upload);
109
+ try {
110
+ tally(job, options);
111
+ await sendParts(calls, fetcher, job, atOnce, options);
112
+ return await finish(calls, fetcher, job, atOnce, options);
113
+ }
114
+ catch (e) {
115
+ if (signal?.aborted)
116
+ throw signal.reason;
117
+ throw new UploadStopped(job.upload.id, e);
118
+ }
119
+ }
120
+ function tally(job, options) {
121
+ let sent = 0;
122
+ for (const n of job.done)
123
+ sent += Math.min(job.size, (n + 1) * job.upload.partBytes) - n * job.upload.partBytes;
124
+ job.sentBytes = sent;
125
+ options.onProgress?.(sent, job.size);
126
+ }
127
+ async function sendParts(calls, fetcher, job, atOnce, options) {
128
+ const todo = [];
129
+ for (let n = 0; n < job.upload.partCount; n++)
130
+ if (!job.done.has(n))
131
+ todo.push(n);
132
+ let next = 0;
133
+ // The first part that cannot be sent stops the others: the file cannot be finished without it.
134
+ const stop = new AbortController();
135
+ const outer = options.signal;
136
+ const passOn = () => stop.abort(outer?.reason);
137
+ if (outer?.aborted)
138
+ passOn();
139
+ outer?.addEventListener("abort", passOn);
140
+ const worker = async () => {
141
+ for (;;) {
142
+ if (stop.signal.aborted)
143
+ throw stop.signal.reason;
144
+ const n = todo[next++];
145
+ if (n === undefined)
146
+ return;
147
+ await sendPart(calls, fetcher, job, n, stop.signal);
148
+ job.done.add(n);
149
+ tally(job, options);
150
+ }
151
+ };
152
+ try {
153
+ await Promise.all(Array.from({ length: Math.min(atOnce, todo.length) }, () => worker().catch((e) => {
154
+ stop.abort(e);
155
+ throw e;
156
+ })));
157
+ }
158
+ finally {
159
+ outer?.removeEventListener("abort", passOn);
160
+ }
161
+ }
162
+ async function sendPart(calls, fetcher, job, n, signal) {
163
+ const from = n * job.upload.partBytes;
164
+ let bytes;
165
+ try {
166
+ bytes = await bytesOf(job.source, from, Math.min(job.size, from + job.upload.partBytes));
167
+ }
168
+ catch (e) {
169
+ // Browsers and Node both refuse to read a file that changed after it was
170
+ // opened, which is what keeps one from going up half old and half new.
171
+ throw Object.assign(new FileFreakError(0, "unreadable", "The file could not be read. It may have been changed, moved or deleted since it was opened: open it again, unchanged, to carry on."), { cause: e });
172
+ }
173
+ const md5 = base64(new Md5().update(bytes).digest());
174
+ for (let attempt = 1;; attempt++) {
175
+ const part = await address(calls, job, n, signal);
176
+ let status = 0;
177
+ let damaged = false;
178
+ try {
179
+ const res = await fetcher(part.url, { method: part.method, headers: { ...part.headers, "Content-MD5": md5 }, body: bytes, signal });
180
+ if (res.ok) {
181
+ await res.body?.cancel().catch(() => undefined);
182
+ return;
183
+ }
184
+ status = res.status;
185
+ // Storage hashed what arrived, and it was not what was hashed here.
186
+ damaged = status === 400 && /Md5Mismatch/.test(await res.text().catch(() => ""));
187
+ }
188
+ catch (e) {
189
+ if (signal.aborted)
190
+ throw signal.reason;
191
+ // No answer: the connection dropped. Sent again below.
192
+ status = 0;
193
+ if (attempt >= PART_ATTEMPTS)
194
+ throw new FileFreakError(0, "network_error", `Storage could not be reached: ${e instanceof Error ? e.message : String(e)}.`);
195
+ }
196
+ // 403: the address ran out, or was for an upload that has since ended. A fresh address tells which.
197
+ if (status === 403)
198
+ job.addresses.delete(n);
199
+ const passing = damaged || status === 0 || status === 403 || status === 408 || status === 429 || status >= 500;
200
+ if (!passing)
201
+ throw new FileFreakError(status, "storage_refused", `Storage refused part ${n} of the file with ${status}.`);
202
+ if (attempt >= PART_ATTEMPTS) {
203
+ throw new FileFreakError(status, damaged ? "part_damaged" : "storage_unreachable", `Part ${n} could not be sent after ${PART_ATTEMPTS} tries.`);
204
+ }
205
+ // 1, 2, 4, 8, 16 seconds.
206
+ await sleep(Math.min(16_000, 1000 * 2 ** (attempt - 1)), signal);
207
+ }
208
+ }
209
+ async function address(calls, job, n, signal) {
210
+ for (;;) {
211
+ const known = job.addresses.get(n);
212
+ // Addresses last half an hour and a long upload outlives them. One that is
213
+ // about to run out is replaced before it is used, not after it has failed.
214
+ if (known && Date.parse(known.expiresAt) - Date.now() > ADDRESS_MARGIN_MS)
215
+ return known;
216
+ if (job.fetching) {
217
+ await job.fetching;
218
+ if (job.addresses.has(n) && Date.parse(job.addresses.get(n).expiresAt) - Date.now() > ADDRESS_MARGIN_MS)
219
+ continue;
220
+ }
221
+ const fetching = calls.parts(job.upload.id, { from: n, count: 100 }, { signal }).then(({ parts }) => {
222
+ for (const p of parts)
223
+ job.addresses.set(p.number, p);
224
+ });
225
+ job.fetching = fetching.finally(() => (job.fetching = null)).catch(() => undefined);
226
+ await fetching;
227
+ const part = job.addresses.get(n);
228
+ if (!part)
229
+ throw new FileFreakError(0, "no_address", `The API gave no address for part ${n}.`);
230
+ return part;
231
+ }
232
+ }
233
+ async function finish(calls, fetcher, job, atOnce, options) {
234
+ const started = Date.now();
235
+ const body = options.sha256 ? { sha256: options.sha256 } : {};
236
+ for (let round = 0;;) {
237
+ try {
238
+ const result = await calls.complete(job.upload.id, body, { signal: options.signal });
239
+ if ("item" in result)
240
+ return result.item;
241
+ // Storage is still putting a very large file together. Ask again.
242
+ if (Date.now() - started > ASSEMBLY_PATIENCE_MS) {
243
+ throw new FileFreakError(202, "still_assembling", "Storage is taking a long time to put this file together. Nothing is lost: complete it again in a few minutes.");
244
+ }
245
+ await sleep(Math.max(1, result.retryAfterSeconds) * 1000, options.signal);
246
+ }
247
+ catch (e) {
248
+ // Storage does not have every part after all. Send what is missing.
249
+ if (e instanceof FileFreakError && e.code === "upload_incomplete" && round++ < 2) {
250
+ const have = Array.isArray(e.body?.uploadedParts) ? e.body.uploadedParts.filter((n) => typeof n === "number") : [];
251
+ job.done = new Set(have);
252
+ tally(job, options);
253
+ await sendParts(calls, fetcher, job, atOnce, options);
254
+ continue;
255
+ }
256
+ throw e;
257
+ }
258
+ }
259
+ }
package/package.json CHANGED
@@ -1,6 +1,29 @@
1
1
  {
2
2
  "name": "@filefreak.io/sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "FileFreak's developer API for Node and the browser: every endpoint, uploads straight to storage in parts, downloads in ranges, and the changes to follow.",
5
+ "license": "Apache-2.0",
6
+ "homepage": "https://filefreak.io/developers/reference",
7
+ "type": "module",
8
+ "sideEffects": false,
9
+ "engines": {
10
+ "node": ">=20"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ },
17
+ "./node": {
18
+ "types": "./dist/node.d.ts",
19
+ "default": "./dist/node.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "README.md"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ }
29
+ }