@filefreak.io/sdk 0.1.0 → 0.3.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.
- package/README.md +43 -3
- package/dist/client.d.ts +3 -1
- package/dist/client.js +15 -11
- package/dist/generated.d.ts +134 -23
- package/dist/generated.js +43 -21
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -0
- package/dist/links.d.ts +72 -0
- package/dist/links.js +355 -0
- package/dist/node.d.ts +20 -0
- package/dist/node.js +79 -6
- package/dist/reader.d.ts +76 -0
- package/dist/reader.js +510 -0
- package/dist/shards.d.ts +3 -3
- package/dist/uploads.d.ts +2 -1
- package/dist/uploads.js +153 -7
- package/package.json +1 -1
package/dist/uploads.js
CHANGED
|
@@ -17,7 +17,21 @@
|
|
|
17
17
|
//
|
|
18
18
|
// A part is read into memory whole to be hashed and sent, so the memory an
|
|
19
19
|
// upload needs is about partBytes times the parts sent at once.
|
|
20
|
+
//
|
|
21
|
+
// SEALED AS IT IS SENT (plan 04, F6 step 4, answer 46). Every begin says the
|
|
22
|
+
// SDK can seal (protectable), as the website and the CLI do. Into a place
|
|
23
|
+
// under Shard Protection the API answers with shards, and each part is then
|
|
24
|
+
// a fragment: the file's bytes at that place, sealed with the file's own key
|
|
25
|
+
// (sealFragment, a nonce of its own each time) before it is hashed and sent.
|
|
26
|
+
// The key comes from uploads.get, whose answer nothing keeps, and is held
|
|
27
|
+
// only while the upload runs. Fragments sent are confirmed (uploads.fragments)
|
|
28
|
+
// as they go, at most one confirmation at a time: each is copied inside
|
|
29
|
+
// storage to its own name and recorded, or is to be sealed again and sent.
|
|
30
|
+
// Carrying on, every fragment not recorded is confirmed before it is sent
|
|
31
|
+
// again, since storage may hold it already. A fragment is up to 64 MiB and
|
|
32
|
+
// sealed in memory, so the memory a sealed upload needs is the same.
|
|
20
33
|
import { base64, Md5 } from "./md5.js";
|
|
34
|
+
import { sealFragment, ShardError, shardKey, ShardLayout } from "./shards.js";
|
|
21
35
|
import { FileFreakError, sleep } from "./transport.js";
|
|
22
36
|
const MIB = 1024 * 1024;
|
|
23
37
|
/** The API's bounds on a part. */
|
|
@@ -31,6 +45,14 @@ const PART_ATTEMPTS = 6;
|
|
|
31
45
|
const ADDRESS_MARGIN_MS = 60_000;
|
|
32
46
|
/** How long to go on asking while storage puts a very large file together. */
|
|
33
47
|
const ASSEMBLY_PATIENCE_MS = 30 * 60_000;
|
|
48
|
+
/** Fragments sent are confirmed this often, or as soon as this many are waiting, and at most this many at once (the API's window). */
|
|
49
|
+
const CONFIRM_MS = 2000;
|
|
50
|
+
const CONFIRM_AT = 32;
|
|
51
|
+
const CONFIRM_MOST = 100;
|
|
52
|
+
/** How long confirming may answer only "still copying" before the upload stops and says so. */
|
|
53
|
+
const CONFIRM_PATIENCE_MS = 5 * 60_000;
|
|
54
|
+
/** How many times fragments storage did not keep are sealed again and sent before the upload stops. */
|
|
55
|
+
const SEALED_ROUNDS = 4;
|
|
34
56
|
/** An upload that stopped part way. What arrived stays in storage until the upload expires: carry on with `resume: uploadId`, or cancel it. */
|
|
35
57
|
export class UploadStopped extends Error {
|
|
36
58
|
uploadId;
|
|
@@ -41,6 +63,34 @@ export class UploadStopped extends Error {
|
|
|
41
63
|
this.name = "UploadStopped";
|
|
42
64
|
}
|
|
43
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* The key and the cut of a sealed upload, from what uploads.get says, with
|
|
68
|
+
* every fragment it has not recorded waiting to be confirmed: one sent
|
|
69
|
+
* before, by this process or another, may be in storage already.
|
|
70
|
+
*/
|
|
71
|
+
async function sealingOf(state, size) {
|
|
72
|
+
const shards = state.shards;
|
|
73
|
+
if (!shards)
|
|
74
|
+
throw new FileFreakError(0, "unreadable", "The API began a sealed upload and then gave no key for it.");
|
|
75
|
+
let layout;
|
|
76
|
+
let raw;
|
|
77
|
+
try {
|
|
78
|
+
layout = new ShardLayout(size, shards.fragmentBytes);
|
|
79
|
+
raw = Uint8Array.from(atob(shards.key), (c) => c.charCodeAt(0));
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
throw Object.assign(new FileFreakError(0, "unreadable", "The API answered with a sealed upload whose cut or key could not be read."), { cause: e });
|
|
83
|
+
}
|
|
84
|
+
if (layout.count !== shards.count || layout.count !== state.upload.partCount || raw.length !== 32) {
|
|
85
|
+
throw new FileFreakError(0, "layout", `The API cut this file into ${shards.count} fragments, where ${size} bytes in fragments of ${shards.fragmentBytes} make ${layout.count}.`);
|
|
86
|
+
}
|
|
87
|
+
const recorded = new Set(state.uploadedParts);
|
|
88
|
+
const waiting = new Set();
|
|
89
|
+
for (let n = 0; n < layout.count; n++)
|
|
90
|
+
if (!recorded.has(n))
|
|
91
|
+
waiting.add(n);
|
|
92
|
+
return { key: await shardKey(raw), contentId: shards.contentId, layout, recorded, waiting };
|
|
93
|
+
}
|
|
44
94
|
/** The part size to ask for: 32 MiB, halved while that would leave fewer parts than are sent at once. */
|
|
45
95
|
export function partSizeFor(fileBytes, atOnce) {
|
|
46
96
|
let size = 32 * MIB;
|
|
@@ -74,14 +124,15 @@ export async function upload(calls, fetcher, source, options = {}) {
|
|
|
74
124
|
if (state.upload.sizeBytes !== size) {
|
|
75
125
|
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
126
|
}
|
|
77
|
-
// Begun by a program that cut the file by its content, as
|
|
127
|
+
// Begun by a program that cut the file by its content, as filefreak does: its
|
|
78
128
|
// parts are blocks of different sizes, and this reads part n at n times
|
|
79
129
|
// partBytes, so every part would be sent from the wrong place and the
|
|
80
130
|
// upload could never complete.
|
|
81
131
|
if (state.blocks) {
|
|
82
132
|
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
133
|
}
|
|
84
|
-
|
|
134
|
+
const sealing = state.shards ? await sealingOf(state, size) : null;
|
|
135
|
+
job = { source, size, upload: state.upload, addresses: new Map(), fetching: null, done: new Set(state.uploadedParts), sentBytes: 0, sealing };
|
|
85
136
|
}
|
|
86
137
|
else {
|
|
87
138
|
const name = options.name ?? (typeof File !== "undefined" && source instanceof File ? source.name : undefined);
|
|
@@ -93,6 +144,8 @@ export async function upload(calls, fetcher, source, options = {}) {
|
|
|
93
144
|
sizeBytes: size,
|
|
94
145
|
parentId: options.parentId ?? null,
|
|
95
146
|
partBytes: Math.max(LEAST_PART, Math.min(MOST_PART, options.partBytes ?? partSizeFor(size, atOnce))),
|
|
147
|
+
// Into a protected place, the parts are fragments this sends sealed.
|
|
148
|
+
protectable: true,
|
|
96
149
|
};
|
|
97
150
|
if (options.onConflict)
|
|
98
151
|
body.onConflict = options.onConflict;
|
|
@@ -103,12 +156,23 @@ export async function upload(calls, fetcher, source, options = {}) {
|
|
|
103
156
|
if (options.imageSizes)
|
|
104
157
|
body.imageSizes = options.imageSizes;
|
|
105
158
|
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 };
|
|
159
|
+
job = { source, size, upload: begun.upload, addresses: new Map(begun.parts.map((p) => [p.number, p])), fetching: null, done: new Set(), sentBytes: 0, sealing: null };
|
|
160
|
+
if (begun.shards) {
|
|
161
|
+
// Begin's answer is kept by the API under its Idempotency-Key, so the key is asked for here.
|
|
162
|
+
try {
|
|
163
|
+
job.sealing = await sealingOf(await calls.get(begun.upload.id, call), size);
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
throw new UploadStopped(begun.upload.id, e);
|
|
167
|
+
}
|
|
168
|
+
// Nothing of this upload can have been sent yet.
|
|
169
|
+
job.sealing.waiting.clear();
|
|
170
|
+
}
|
|
107
171
|
}
|
|
108
172
|
options.onBegun?.(job.upload);
|
|
109
173
|
try {
|
|
110
174
|
tally(job, options);
|
|
111
|
-
await
|
|
175
|
+
await deliver(calls, fetcher, job, atOnce, options);
|
|
112
176
|
return await finish(calls, fetcher, job, atOnce, options);
|
|
113
177
|
}
|
|
114
178
|
catch (e) {
|
|
@@ -124,6 +188,71 @@ function tally(job, options) {
|
|
|
124
188
|
job.sentBytes = sent;
|
|
125
189
|
options.onProgress?.(sent, job.size);
|
|
126
190
|
}
|
|
191
|
+
/** Every part storage does not have, sent: as they are for a plain upload, and confirmed until each is recorded for a sealed one. */
|
|
192
|
+
async function deliver(calls, fetcher, job, atOnce, options) {
|
|
193
|
+
const sealing = job.sealing;
|
|
194
|
+
if (!sealing)
|
|
195
|
+
return await sendParts(calls, fetcher, job, atOnce, options);
|
|
196
|
+
for (let round = 0;; round++) {
|
|
197
|
+
// Whatever may be in storage already is asked about before it is sent again.
|
|
198
|
+
await confirmAll(calls, job, sealing, options);
|
|
199
|
+
if (sealing.recorded.size === job.upload.partCount)
|
|
200
|
+
return;
|
|
201
|
+
// Round 0 sends each fragment once; each later round sends again what storage did not keep.
|
|
202
|
+
if (round > SEALED_ROUNDS) {
|
|
203
|
+
const left = job.upload.partCount - sealing.recorded.size;
|
|
204
|
+
throw new FileFreakError(0, "fragments_refused", `Storage did not keep ${left} of this file's fragments after ${SEALED_ROUNDS + 1} tries.`);
|
|
205
|
+
}
|
|
206
|
+
let sending = true;
|
|
207
|
+
const sent = sendParts(calls, fetcher, job, atOnce, options).finally(() => (sending = false));
|
|
208
|
+
await Promise.all([sent, confirmWhile(calls, job, sealing, () => sending, options)]);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** While fragments are being sent: those sent are confirmed every CONFIRM_MS, or as soon as CONFIRM_AT are waiting. */
|
|
212
|
+
async function confirmWhile(calls, job, sealing, sending, options) {
|
|
213
|
+
let last = Date.now();
|
|
214
|
+
while (sending()) {
|
|
215
|
+
await sleep(100, options.signal);
|
|
216
|
+
if (!sending())
|
|
217
|
+
return;
|
|
218
|
+
if (sealing.waiting.size >= CONFIRM_AT || (sealing.waiting.size > 0 && Date.now() - last >= CONFIRM_MS)) {
|
|
219
|
+
await confirmSome(calls, job, sealing, options);
|
|
220
|
+
last = Date.now();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** Every fragment waiting asked about until none is: each recorded, or to be sealed again and sent. */
|
|
225
|
+
async function confirmAll(calls, job, sealing, options) {
|
|
226
|
+
let heard = Date.now();
|
|
227
|
+
while (sealing.waiting.size > 0) {
|
|
228
|
+
if (await confirmSome(calls, job, sealing, options)) {
|
|
229
|
+
heard = Date.now();
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (Date.now() - heard > CONFIRM_PATIENCE_MS) {
|
|
233
|
+
throw new FileFreakError(0, "still_copying", "Storage has not finished taking part of this file. Nothing is lost: carry it on with resume in a few minutes.");
|
|
234
|
+
}
|
|
235
|
+
// Only fragments storage is still copying, or another confirmation holds, are left.
|
|
236
|
+
await sleep(CONFIRM_MS, options.signal);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** Ask about the first CONFIRM_MOST fragments waiting. True when one was recorded or is to be sent again: either way it stops waiting. */
|
|
240
|
+
async function confirmSome(calls, job, sealing, options) {
|
|
241
|
+
const places = [...sealing.waiting].sort((a, b) => a - b).slice(0, CONFIRM_MOST);
|
|
242
|
+
const answer = await calls.fragments(job.upload.id, { places }, { signal: options.signal });
|
|
243
|
+
for (const n of answer.recorded) {
|
|
244
|
+
sealing.recorded.add(n);
|
|
245
|
+
sealing.waiting.delete(n);
|
|
246
|
+
job.done.add(n);
|
|
247
|
+
}
|
|
248
|
+
for (const n of answer.resend) {
|
|
249
|
+
sealing.waiting.delete(n);
|
|
250
|
+
// Sent again, sealed again: never the same nonce over other bytes.
|
|
251
|
+
job.done.delete(n);
|
|
252
|
+
}
|
|
253
|
+
tally(job, options);
|
|
254
|
+
return answer.recorded.length > 0 || answer.resend.length > 0;
|
|
255
|
+
}
|
|
127
256
|
async function sendParts(calls, fetcher, job, atOnce, options) {
|
|
128
257
|
const todo = [];
|
|
129
258
|
for (let n = 0; n < job.upload.partCount; n++)
|
|
@@ -146,6 +275,7 @@ async function sendParts(calls, fetcher, job, atOnce, options) {
|
|
|
146
275
|
return;
|
|
147
276
|
await sendPart(calls, fetcher, job, n, stop.signal);
|
|
148
277
|
job.done.add(n);
|
|
278
|
+
job.sealing?.waiting.add(n);
|
|
149
279
|
tally(job, options);
|
|
150
280
|
}
|
|
151
281
|
};
|
|
@@ -160,16 +290,27 @@ async function sendParts(calls, fetcher, job, atOnce, options) {
|
|
|
160
290
|
}
|
|
161
291
|
}
|
|
162
292
|
async function sendPart(calls, fetcher, job, n, signal) {
|
|
163
|
-
const
|
|
293
|
+
const sealing = job.sealing;
|
|
294
|
+
const { start: from, end: to } = sealing ? sealing.layout.range(n) : { start: n * job.upload.partBytes, end: Math.min(job.size, (n + 1) * job.upload.partBytes) };
|
|
164
295
|
let bytes;
|
|
165
296
|
try {
|
|
166
|
-
bytes = await bytesOf(job.source, from,
|
|
297
|
+
bytes = await bytesOf(job.source, from, to);
|
|
167
298
|
}
|
|
168
299
|
catch (e) {
|
|
169
300
|
// Browsers and Node both refuse to read a file that changed after it was
|
|
170
301
|
// opened, which is what keeps one from going up half old and half new.
|
|
171
302
|
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
303
|
}
|
|
304
|
+
if (sealing) {
|
|
305
|
+
try {
|
|
306
|
+
bytes = await sealFragment(sealing.key, { contentId: sealing.contentId, layout: sealing.layout, place: n }, bytes);
|
|
307
|
+
}
|
|
308
|
+
catch (e) {
|
|
309
|
+
if (e instanceof ShardError)
|
|
310
|
+
throw Object.assign(new FileFreakError(0, "layout", e.message), { cause: e });
|
|
311
|
+
throw e;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
173
314
|
const md5 = base64(new Md5().update(bytes).digest());
|
|
174
315
|
for (let attempt = 1;; attempt++) {
|
|
175
316
|
const part = await address(calls, job, n, signal);
|
|
@@ -249,8 +390,13 @@ async function finish(calls, fetcher, job, atOnce, options) {
|
|
|
249
390
|
if (e instanceof FileFreakError && e.code === "upload_incomplete" && round++ < 2) {
|
|
250
391
|
const have = Array.isArray(e.body?.uploadedParts) ? e.body.uploadedParts.filter((n) => typeof n === "number") : [];
|
|
251
392
|
job.done = new Set(have);
|
|
393
|
+
if (job.sealing) {
|
|
394
|
+
// The fragments recorded: any other may be in storage, and is confirmed first.
|
|
395
|
+
job.sealing.recorded = new Set(have);
|
|
396
|
+
job.sealing.waiting = new Set(Array.from({ length: job.upload.partCount }, (_, n) => n).filter((n) => !job.sealing.recorded.has(n)));
|
|
397
|
+
}
|
|
252
398
|
tally(job, options);
|
|
253
|
-
await
|
|
399
|
+
await deliver(calls, fetcher, job, atOnce, options);
|
|
254
400
|
continue;
|
|
255
401
|
}
|
|
256
402
|
throw e;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@filefreak.io/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
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
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://filefreak.io/developers/reference",
|